Compare commits
564 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee7e20706b | |||
| f08e9a4447 | |||
| cf79bc9ed7 | |||
| 090592e5ae | |||
| 398d99a052 | |||
| c18beddef8 | |||
| dc4f7bef69 | |||
| 48b490f5e9 | |||
| 5b87abb021 | |||
| d26ac431b8 | |||
| d7f53d93a6 | |||
| ec4e6a143e | |||
| 7961d00e56 | |||
| c1253663b7 | |||
| ec4eb7cd19 | |||
| 16706f7f49 | |||
| bf100dcde1 | |||
| c2a525170e | |||
| 0bf923feb4 | |||
| 42f03af2dc | |||
| 9bce3417e9 | |||
| 4b86c7a298 | |||
| a2c040a47f | |||
| 959b3f05d2 | |||
| a0c43a2b5a | |||
| b7d0bc16bb | |||
| ef09fc4157 | |||
| 84d638645d | |||
| 64e9791a3f | |||
| 0aa3b29eeb | |||
| 0a598ae1b8 | |||
| c0495ca23f | |||
| f057fe045e | |||
| eba42b245d | |||
| 2cf63eeeab | |||
| 926de96ce6 | |||
| 374a9b557b | |||
| 9e79a23bbe | |||
| 3212069eec | |||
| c1636ef7ab | |||
| 0b0ab99016 | |||
| 4f097d9106 | |||
| 461f2595b5 | |||
| 5722c830ff | |||
| 72ce81f0c2 | |||
| 0c9bf2835d | |||
| ba18776fc2 | |||
| fdae0dcbe5 | |||
| 25262b644f | |||
| 4291b38769 | |||
| ee4877b090 | |||
| b4f6184aa6 | |||
| 87de809e3d | |||
| 59caf1fce4 | |||
| 6eb5d2e95e | |||
| 32e7cfea3d | |||
| fc2e709ad4 | |||
| 3c7b8d560f | |||
| 4bb7d2df49 | |||
| a2cf8c7fc7 | |||
| 56a42dad17 | |||
| 13e093b3c2 | |||
| 2648032163 | |||
| 19aa97da02 | |||
| 034c928d9e | |||
| 953896a35a | |||
| 833a78181b | |||
| cf01ec1eb0 | |||
| 90882a9b26 | |||
| 0b68cc2da6 | |||
| 7ea539b753 | |||
| 1e34b01986 | |||
| 0d8a04b43c | |||
| 5a49de5592 | |||
| 0c60aaf557 | |||
| 0571901288 | |||
| 557f9d8e5f | |||
| 8d5729c3b2 | |||
| 0ff0b891a7 | |||
| 53d571d29e | |||
| 00d186ec52 | |||
| 69f8bfe874 | |||
| 1d15cfb5f3 | |||
| 3e03f5348f | |||
| 59537f8f1b | |||
| f1083a4c73 | |||
| 52bcb46b42 | |||
| d52ed51aab | |||
| c9dcb5c624 | |||
| 40899f6137 | |||
| 912d0c4d74 | |||
| adb607c3ee | |||
| c14a2eda5d | |||
| a0d03925e0 | |||
| 732bd1e652 | |||
| 2943f93098 | |||
| 0b3373c552 | |||
| a90138e42e | |||
| 466451abc9 | |||
| f0f9053115 | |||
| dc9b51ad02 | |||
| 0dbe44cb78 | |||
| a78260296c | |||
| 1e004611e4 | |||
| 2593110219 | |||
| 56ce58c718 | |||
| aac789f788 | |||
| 99ccf28bc6 | |||
| 48b0b7e8bd | |||
| 70c78a09a4 | |||
| 4458cc4370 | |||
| 0466da03ae | |||
| 7a2c1bab23 | |||
| 0435de50f8 | |||
| 661540c886 | |||
| f5cf057e84 | |||
| a91fd772e4 | |||
| 7f1651df71 | |||
| c9a079201a | |||
| be4a783845 | |||
| c30cd3b378 | |||
| 0d3cc28f45 | |||
| f004487be0 | |||
| 21231d53a5 | |||
| a99862120d | |||
| 776023b149 | |||
| 7d4187962a | |||
| a93534fc3c | |||
| cef84f6ced | |||
| a2180a467d | |||
| 1e3dceea4d | |||
| fd4514711f | |||
| 2dd7c13b88 | |||
| 40931b5668 | |||
| 25549b87c9 | |||
| 7ec62f12b5 | |||
| caf76f0713 | |||
| 6778653825 | |||
| c858b43717 | |||
| 6eb1b82541 | |||
| 71b6d8b569 | |||
| 3abfe3c99e | |||
| 171b6bb0a6 | |||
| 78c7ff855d | |||
| 57be9182d4 | |||
| 886e07604e | |||
| 431ffebddd | |||
| 74c921148b | |||
| eaf9e5e477 | |||
| 4478e524f8 | |||
| e72e41a7aa | |||
| efd8f0d648 | |||
| d2b5cc6a4a | |||
| 596c35dc00 | |||
| 112d6d60ec | |||
| c50241369a | |||
| b74f8273c2 | |||
| 8573c84605 | |||
| a4f805e99b | |||
| 7db07bbe61 | |||
| 3a9df6dae8 | |||
| c227f9893e | |||
| a3feca2580 | |||
| b21566c2fc | |||
| 1071396a4a | |||
| f58886514d | |||
| 17dc12cf7d | |||
| 6d4d0f86cf | |||
| 14b1425e98 | |||
| c70d9f9055 | |||
| 18fa6018c0 | |||
| 47fb9bd213 | |||
| 6e6deec40c | |||
| 877207a2e6 | |||
| 64cfd017b4 | |||
| 4c4ebf769f | |||
| 28d081338b | |||
| 50c9bc0336 | |||
| ed2e4e5217 | |||
| 1aa8707b8a | |||
| 103cb60a57 | |||
| 58e516c766 | |||
| bcdec25843 | |||
| 28f591d01b | |||
| dba365634a | |||
| 1c1e461936 | |||
| 2db76034b1 | |||
| 95e67a7b1d | |||
| 3deaaf14c0 | |||
| 084a97a77a | |||
| ed74213c63 | |||
| 7ce1662b05 | |||
| f959f2de85 | |||
| 07716bbff7 | |||
| 0f74b17000 | |||
| 3c7f70ec30 | |||
| 85df3f1e99 | |||
| a903898781 | |||
| 25e1887939 | |||
| 9c696e4c28 | |||
| 87a36846f4 | |||
| ded01401f8 | |||
| 8aff392275 | |||
| 14b798fcc4 | |||
| 97c099e26d | |||
| 3eb61a9d53 | |||
| e65b3a8ea0 | |||
| 1fdbc949d6 | |||
| 605da89425 | |||
| 0d062b32a8 | |||
| a4267ed60f | |||
| 58346465aa | |||
| ec76e5ef23 | |||
| 37eea2d353 | |||
| 8c9a092561 | |||
| e421fe9860 | |||
| e13d4c9c13 | |||
| 640f53fe0a | |||
| 1bca1b8bde | |||
| c902c93082 | |||
| f1ca1794a1 | |||
| ad5d115abe | |||
| 56079527ef | |||
| c77b9f359f | |||
| 7f504ec5fc | |||
| b1bcd67f5a | |||
| 2a26574808 | |||
| 1d427d0581 | |||
| 321868963d | |||
| 1529b67e41 | |||
| 190e4b55eb | |||
| 9e122764e7 | |||
| 327b9bd59c | |||
| 301c217303 | |||
| 9883473376 | |||
| 6631e6eedc | |||
| 933b6b67f5 | |||
| 56e0e5d6ad | |||
| 369bd17c8b | |||
| 9681f5b360 | |||
| b107894976 | |||
| 796c933fb8 | |||
| d43daaee81 | |||
| b6cdffa509 | |||
| 7b1562c050 | |||
| 20583d5334 | |||
| 2d03d7c373 | |||
| dd15d33bce | |||
| c5e8f38e1e | |||
| db45ec7434 | |||
| 4f4bceec94 | |||
| 7a16233584 | |||
| fff12e3d78 | |||
| da750ed838 | |||
| e49512896f | |||
| 0075243ed5 | |||
| 29e47dd7c1 | |||
| 105a74caca | |||
| 55b9acca78 | |||
| 0d130b8957 | |||
| 0aa5d3daeb | |||
| 4b622e6cfa | |||
| 82aeb3292a | |||
| 335c03d0b8 | |||
| 4681ff88d0 | |||
| 33fd27f113 | |||
| 527fd7d472 | |||
| 3a69e5e819 | |||
| 7e611fa99c | |||
| 71d346207d | |||
| 56d27bc1b4 | |||
| e1f8e96e28 | |||
| ab97f03cb5 | |||
| a2e38270e4 | |||
| 8f981b6052 | |||
| 939e91f9ed | |||
| 22c3d26604 | |||
| 7aaf48cb0c | |||
| afd7815420 | |||
| e5fe68cbf6 | |||
| 3b0fff3b3d | |||
| ec7015be88 | |||
| 19fafd8c10 | |||
| e47a89b274 | |||
| 66650f5944 | |||
| 0529076ed7 | |||
| 7f854432ae | |||
| 15a2e6feeb | |||
| 4ed68cf673 | |||
| 8337da183c | |||
| 6dfa9e1146 | |||
| 282bccaca5 | |||
| 62d307321a | |||
| f7afc0334e | |||
| 28e8e539f6 | |||
| 7cc19b50fc | |||
| 97c340b8a4 | |||
| 7b1d4a6787 | |||
| 0714d119d7 | |||
| 700622e521 | |||
| 3682e76dee | |||
| cd0e537e3e | |||
| 0849dbd1af | |||
| 4ab4a35eba | |||
| e5d9372708 | |||
| 8edc2fb46f | |||
| e520c0d1f5 | |||
| 506f9f6fb9 | |||
| 3cb8f54307 | |||
| ee4d9fff16 | |||
| 27be813011 | |||
| 861b72ef04 | |||
| fd83280b70 | |||
| 169d9d18b0 | |||
| 245535ee04 | |||
| 5bc9158724 | |||
| 6a4bc777a2 | |||
| 34cbb18ecd | |||
| e2d5a8c0bb | |||
| 94cd806675 | |||
| b6af7788e1 | |||
| c4bb9f49ff | |||
| 8e5695f06d | |||
| 395c28f5fa | |||
| 3e5183606c | |||
| 6a36bbd1d1 | |||
| 4b39d37cae | |||
| 25c9b779e4 | |||
| fcc3b81745 | |||
| 6f677b4fae | |||
| 78f202603c | |||
| b8c5363a15 | |||
| b8b3c487d4 | |||
| ec48fccb30 | |||
| 3f61019ca1 | |||
| 16513b4a6e | |||
| 9b705e4450 | |||
| e1c2135850 | |||
| 9fe80c25eb | |||
| 13b11a39a9 | |||
| 8bf571bf48 | |||
| 613b544bf0 | |||
| 916603d2d4 | |||
| e30eecba2c | |||
| 3a94be0212 | |||
| 6295edcdb7 | |||
| 335b4937ed | |||
| 06da0469c4 | |||
| 1ad893ded4 | |||
| 636f5fb933 | |||
| c45e28ab53 | |||
| c56c04a82b | |||
| d431d37454 | |||
| 1694dd146e | |||
| 4a6a0aa142 | |||
| c788160532 | |||
| cc66159f04 | |||
| c58a70ac8f | |||
| 1855aaea99 | |||
| 3901b5da44 | |||
| 5dc59b591d | |||
| 96a5710932 | |||
| d36d32d07b | |||
| 727b3b9f53 | |||
| c85563da50 | |||
| a771c563ba | |||
| 3cc800f93a | |||
| b449feb3e1 | |||
| b07a565e34 | |||
| 787eebcf1e | |||
| 604b8ff17c | |||
| 6e93ddf2f1 | |||
| b6e4be72f0 | |||
| 75aa8e6621 | |||
| 5b7417bf64 | |||
| db744f500b | |||
| a56cf35d8c | |||
| d1e6843f3e | |||
| d18868873e | |||
| 827014fa4b | |||
| 944b33983c | |||
| 2641185af2 | |||
| 64aac239f0 | |||
| d6823b128c | |||
| 508f32c08a | |||
| 8ed6ed4d2b | |||
| 1abb0bdae8 | |||
| 5ef6215546 | |||
| 95fb9c4365 | |||
| fa0a5107c2 | |||
| dc3c329431 | |||
| 2a9f2b4515 | |||
| 793049388b | |||
| 382b63954c | |||
| 87ccba7f9d | |||
| e21c96c0ef | |||
| 4de0b2f44e | |||
| b588a87d4a | |||
| 44ed1f0919 | |||
| 16d0df796c | |||
| 9fd5d2ad9c | |||
| 28ad004b01 | |||
| ef4a492cb1 | |||
| 6d9e7694b1 | |||
| 0c13c63bb6 | |||
| 907eb869bc | |||
| c1402eee8e | |||
| 84f7ca855a | |||
| 2dcce03352 | |||
| 96a22ec3c1 | |||
| 4b29bccc7c | |||
| 40e079a247 | |||
| 81f0265095 | |||
| 92cc647cf6 | |||
| 048d437b0b | |||
| ec9a6bca14 | |||
| bd5952b943 | |||
| 3f0d54c752 | |||
| dab4595a4e | |||
| 6d9ca82b19 | |||
| 373a03e819 | |||
| d97b0259fa | |||
| 2267ca1949 | |||
| 29be53e70d | |||
| 851fe4a49f | |||
| 30f499cf2e | |||
| 591a641d8d | |||
| 5b314ffd46 | |||
| 0b078c9f99 | |||
| 0d5584ecbb | |||
| 5e090646ba | |||
| c4e910dd3d | |||
| 5a2394af7c | |||
| 48e32269f4 | |||
| dd9d90d21e | |||
| 0544c687b9 | |||
| e810aae212 | |||
| 9c6a26de9f | |||
| e6f2bb9f89 | |||
| f908bd4a64 | |||
| 7395b03b1f | |||
| 63b4fc6f65 | |||
| f392fe7702 | |||
| 2daed747cd | |||
| 9e4bab7494 | |||
| 9274c0701b | |||
| 0bc773fd00 | |||
| c6d2408517 | |||
| 033f83a55a | |||
| 51841d627c | |||
| 50924f0b3d | |||
| 4aae1da841 | |||
| 1a2554548a | |||
| 40262c30cb | |||
| 761e7fdd2d | |||
| cd8a124b25 | |||
| 148428a564 | |||
| 14da671bf9 | |||
| e8f0f82db0 | |||
| b8278404a0 | |||
| 45671b0b8b | |||
| 321525ead5 | |||
| 1d24e20d22 | |||
| 3a045b33ca | |||
| a9438a9c2d | |||
| eea0a98090 | |||
| a491240aeb | |||
| 8c24a994e1 | |||
| 64f53e674c | |||
| 54fdf33fd9 | |||
| 997e9c5877 | |||
| e21c586cc5 | |||
| abedfd1015 | |||
| 2a0e1c0d3c | |||
| 5a6b71dda3 | |||
| a3dfa27a53 | |||
| cfb14ca80b | |||
| 0f79c4ff46 | |||
| 029dd99ae0 | |||
| a46366d336 | |||
| 07e8f79563 | |||
| 9ed7de50e7 | |||
| eed8e6b67a | |||
| 12fb90c232 | |||
| cda45f9bfb | |||
| ab4b8eca15 | |||
| 582cdcab82 | |||
| 5a589babcb | |||
| 0b8edb7671 | |||
| 9bd79ffc00 | |||
| c04dfdf38b | |||
| 2080aeee4d | |||
| 31f7e1aca3 | |||
| 1c4637cb43 | |||
| 559565d6a7 | |||
| ba38713fbc | |||
| 428b7b0c4e | |||
| 2f78bff97c | |||
| a85b147b3a | |||
| ee8e8a0c0f | |||
| d67cc00e4e | |||
| 63d252b603 | |||
| bd88a241ff | |||
| 76432341ed | |||
| ff2f4f8ed8 | |||
| 29c3a826c5 | |||
| 37e5b91dc2 | |||
| d67a6b7293 | |||
| 054df27929 | |||
| 2b1def4e7c | |||
| 08d64f1c25 | |||
| a7efd66ae9 | |||
| 92804fe4b2 | |||
| b07ed3f615 | |||
| 67b209808f | |||
| 82aabc63f5 | |||
| 17d7d9364f | |||
| 779f5d9b3d | |||
| 83198ef595 | |||
| 412c9bc76d | |||
| 72f9295490 | |||
| 1683bb75e1 | |||
| 727a8cd715 | |||
| 7489db9481 | |||
| 4733de25af | |||
| c24b6cf617 | |||
| 6bfa1fceec | |||
| 41504b9a2c | |||
| 3cd232f571 | |||
| 1b8844cb4a | |||
| a097e903c9 | |||
| def82a7354 | |||
| a94e45260e | |||
| bbed14a9ff | |||
| d09980f646 | |||
| e732cb68a7 | |||
| 4b4ebe4f80 | |||
| 7b5ff397b3 | |||
| 4b6206b32d | |||
| faab3aab0a | |||
| a1130b3e27 | |||
| de28f83d0d | |||
| b7e5407822 | |||
| 4023c665cc | |||
| 7aa75d5643 | |||
| cfece31649 | |||
| a326f7c833 | |||
| 078da36f20 | |||
| 6c8fad4cac | |||
| 8c3ff65402 | |||
| b33cb5fe3f | |||
| 84453d2e34 | |||
| d069ad5be4 | |||
| f1fa88a67e | |||
| 6aa4f2e67e | |||
| 053e8509a5 | |||
| d865e98e6f | |||
| 70d196dbdb | |||
| ea2755a559 | |||
| 932dd3c885 | |||
| 2de9a92fba | |||
| a41ffb5131 | |||
| ae34e4f59f | |||
| 8dc62bd29a |
@@ -6,6 +6,14 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Please use this form to request new feature for Immich
|
Please use this form to request new feature for Immich
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: I have searched the existing feature requests to make sure this is not a duplicate request.
|
||||||
|
options:
|
||||||
|
- label: "Yes"
|
||||||
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: feature
|
id: feature
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -87,6 +87,16 @@ body:
|
|||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Relevant log output
|
||||||
|
description: Please copy and paste any relevant logs below. (code formatting is
|
||||||
|
enabled, no need for backticks)
|
||||||
|
render: shell
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Additional information
|
label: Additional information
|
||||||
|
|||||||
@@ -37,15 +37,15 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-java@v4
|
- uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: "zulu"
|
distribution: 'zulu'
|
||||||
java-version: "11.0.21+9"
|
java-version: '17'
|
||||||
cache: "gradle"
|
cache: 'gradle'
|
||||||
|
|
||||||
- name: Setup Flutter SDK
|
- name: Setup Flutter SDK
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: 'stable'
|
||||||
flutter-version: "3.16.9"
|
flutter-version: '3.19.3'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Create the Keystore
|
- name: Create the Keystore
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
uses: docker/setup-qemu-action@v3.0.0
|
uses: docker/setup-qemu-action@v3.0.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3.1.0
|
uses: docker/setup-buildx-action@v3.3.0
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -87,7 +87,7 @@ jobs:
|
|||||||
type=raw,value=latest,enable=${{ github.event_name == 'workflow_dispatch' }}
|
type=raw,value=latest,enable=${{ github.event_name == 'workflow_dispatch' }}
|
||||||
|
|
||||||
- name: Build and push image
|
- name: Build and push image
|
||||||
uses: docker/build-push-action@v5.2.0
|
uses: docker/build-push-action@v5.3.0
|
||||||
with:
|
with:
|
||||||
file: cli/Dockerfile
|
file: cli/Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
name: Update Immich SDK
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
|
||||||
branches: ["main"]
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update-sdk-repos:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.GH_TOKEN }}
|
|
||||||
script: |
|
|
||||||
await github.rest.actions.createWorkflowDispatch({
|
|
||||||
owner: 'immich-app',
|
|
||||||
repo: 'immich-sdk-typescript-axios',
|
|
||||||
workflow_id: 'build.yml',
|
|
||||||
ref: 'main'
|
|
||||||
})
|
|
||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Clean temporary images
|
- name: Clean temporary images
|
||||||
if: "${{ env.TOKEN != '' }}"
|
if: "${{ env.TOKEN != '' }}"
|
||||||
uses: stumpylog/image-cleaner-action/ephemeral@v0.5.0
|
uses: stumpylog/image-cleaner-action/ephemeral@v0.6.0
|
||||||
with:
|
with:
|
||||||
token: "${{ env.TOKEN }}"
|
token: "${{ env.TOKEN }}"
|
||||||
owner: "immich-app"
|
owner: "immich-app"
|
||||||
@@ -64,7 +64,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Clean untagged images
|
- name: Clean untagged images
|
||||||
if: "${{ env.TOKEN != '' }}"
|
if: "${{ env.TOKEN != '' }}"
|
||||||
uses: stumpylog/image-cleaner-action/untagged@v0.5.0
|
uses: stumpylog/image-cleaner-action/untagged@v0.6.0
|
||||||
with:
|
with:
|
||||||
token: "${{ env.TOKEN }}"
|
token: "${{ env.TOKEN }}"
|
||||||
owner: "immich-app"
|
owner: "immich-app"
|
||||||
|
|||||||
@@ -66,13 +66,7 @@ jobs:
|
|||||||
uses: docker/setup-qemu-action@v3.0.0
|
uses: docker/setup-qemu-action@v3.0.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3.1.0
|
uses: docker/setup-buildx-action@v3.3.0
|
||||||
# Workaround to fix error:
|
|
||||||
# failed to push: failed to copy: io: read/write on closed pipe
|
|
||||||
# See https://github.com/docker/build-push-action/issues/761
|
|
||||||
with:
|
|
||||||
driver-opts: |
|
|
||||||
image=moby/buildkit:v0.10.6
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
# Only push to Docker Hub when making a release
|
# Only push to Docker Hub when making a release
|
||||||
@@ -121,7 +115,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Build and push image
|
- name: Build and push image
|
||||||
uses: docker/build-push-action@v5.2.0
|
uses: docker/build-push-action@v5.3.0
|
||||||
with:
|
with:
|
||||||
context: ${{ matrix.context }}
|
context: ${{ matrix.context }}
|
||||||
file: ${{ matrix.file }}
|
file: ${{ matrix.file }}
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
serverBump:
|
serverBump:
|
||||||
description: "Bump server version"
|
description: 'Bump server version'
|
||||||
required: true
|
required: true
|
||||||
default: "false"
|
default: 'false'
|
||||||
type: choice
|
type: choice
|
||||||
options:
|
options:
|
||||||
- "false"
|
- 'false'
|
||||||
- minor
|
- minor
|
||||||
- patch
|
- patch
|
||||||
mobileBump:
|
mobileBump:
|
||||||
description: "Bump mobile build number"
|
description: 'Bump mobile build number'
|
||||||
required: false
|
required: false
|
||||||
type: boolean
|
type: boolean
|
||||||
|
|
||||||
@@ -46,8 +46,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
author_name: Alex The Bot
|
author_name: Alex The Bot
|
||||||
author_email: alex.tran1502@gmail.com
|
author_email: alex.tran1502@gmail.com
|
||||||
default_author: user_info
|
default_author: user_info
|
||||||
message: "Version ${{ env.IMMICH_VERSION }}"
|
message: 'Version ${{ env.IMMICH_VERSION }}'
|
||||||
tag: ${{ env.IMMICH_VERSION }}
|
tag: ${{ env.IMMICH_VERSION }}
|
||||||
push: true
|
push: true
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ jobs:
|
|||||||
name: release-apk-signed
|
name: release-apk-signed
|
||||||
|
|
||||||
- name: Create draft release
|
- name: Create draft release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
draft: true
|
draft: true
|
||||||
tag_name: ${{ env.IMMICH_VERSION }}
|
tag_name: ${{ env.IMMICH_VERSION }}
|
||||||
@@ -85,4 +85,5 @@ jobs:
|
|||||||
docker/example.env
|
docker/example.env
|
||||||
docker/hwaccel.ml.yml
|
docker/hwaccel.ml.yml
|
||||||
docker/hwaccel.transcoding.yml
|
docker/hwaccel.transcoding.yml
|
||||||
|
docker/prometheus.yml
|
||||||
*.apk
|
*.apk
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
name: Update Immich SDK
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: Publish `@immich/sdk`
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./open-api/typescript-sdk
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
# Setup .npmrc file to publish to npm
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20.x'
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
- name: Install deps
|
||||||
|
run: npm ci
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
- name: Publish
|
||||||
|
run: npm publish
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: "3.16.9"
|
flutter-version: "3.19.3"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: dart pub get
|
run: dart pub get
|
||||||
|
|||||||
@@ -10,19 +10,6 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
server-e2e-jobs:
|
|
||||||
name: Server (e2e-jobs)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: 'recursive'
|
|
||||||
|
|
||||||
- name: Run e2e tests
|
|
||||||
run: make server-e2e-jobs
|
|
||||||
|
|
||||||
doc-tests:
|
doc-tests:
|
||||||
name: Docs
|
name: Docs
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -91,17 +78,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
- name: Run setup typescript-sdk
|
- name: Setup typescript-sdk
|
||||||
run: npm ci && npm run build
|
run: npm ci && npm run build
|
||||||
working-directory: ./open-api/typescript-sdk
|
working-directory: ./open-api/typescript-sdk
|
||||||
|
|
||||||
- name: Run npm install (cli)
|
- name: Install deps
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Run npm install (server)
|
|
||||||
run: npm ci
|
|
||||||
working-directory: ./server
|
|
||||||
|
|
||||||
- name: Run linter
|
- name: Run linter
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
@@ -225,7 +208,7 @@ jobs:
|
|||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: 'stable'
|
channel: 'stable'
|
||||||
flutter-version: '3.16.9'
|
flutter-version: '3.19.3'
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
working-directory: ./mobile
|
working-directory: ./mobile
|
||||||
run: flutter test -j 1
|
run: flutter test -j 1
|
||||||
@@ -333,14 +316,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Generate new migrations
|
- name: Generate new migrations
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: npm run typeorm:migrations:generate ./src/infra/migrations/TestMigration
|
run: npm run typeorm:migrations:generate ./src/migrations/TestMigration
|
||||||
|
|
||||||
- name: Find file changes
|
- name: Find file changes
|
||||||
uses: tj-actions/verify-changed-files@v19
|
uses: tj-actions/verify-changed-files@v19
|
||||||
id: verify-changed-files
|
id: verify-changed-files
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
server/src/infra/migrations/
|
server/src/migrations/
|
||||||
- name: Verify migration files have not changed
|
- name: Verify migration files have not changed
|
||||||
if: steps.verify-changed-files.outputs.files_changed == 'true'
|
if: steps.verify-changed-files.outputs.files_changed == 'true'
|
||||||
run: |
|
run: |
|
||||||
@@ -358,7 +341,7 @@ jobs:
|
|||||||
id: verify-changed-sql-files
|
id: verify-changed-sql-files
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
server/src/infra/sql
|
server/src/queries
|
||||||
|
|
||||||
- name: Verify SQL files have not changed
|
- name: Verify SQL files have not changed
|
||||||
if: steps.verify-changed-sql-files.outputs.files_changed == 'true'
|
if: steps.verify-changed-sql-files.outputs.files_changed == 'true'
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"[javascript][typescript][css]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.tabSize": 2,
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
},
|
||||||
|
"[svelte]": {
|
||||||
|
"editor.defaultFormatter": "svelte.svelte-vscode",
|
||||||
|
"editor.tabSize": 2
|
||||||
|
},
|
||||||
|
"svelte.enable-ts-plugin": true,
|
||||||
|
"eslint.validate": [
|
||||||
|
"javascript",
|
||||||
|
"svelte"
|
||||||
|
],
|
||||||
|
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||||
|
"[dart]": {
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.selectionHighlight": false,
|
||||||
|
"editor.suggest.snippetsPreventQuickSuggestions": false,
|
||||||
|
"editor.suggestSelection": "first",
|
||||||
|
"editor.tabCompletion": "onlySnippets",
|
||||||
|
"editor.wordBasedSuggestions": "off",
|
||||||
|
"editor.defaultFormatter": "Dart-Code.dart-code"
|
||||||
|
},
|
||||||
|
"cSpell.words": [
|
||||||
|
"immich"
|
||||||
|
],
|
||||||
|
"explorer.fileNesting.enabled": true,
|
||||||
|
"explorer.fileNesting.patterns": {
|
||||||
|
"*.ts": "${capture}.spec.ts,${capture}.mock.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/.github/ @bo0tzz
|
||||||
|
/docker/ @bo0tzz
|
||||||
|
/server/ @danieldietzler
|
||||||
|
/machine-learning/ @mertalev
|
||||||
|
/e2e/ @danieldietzler
|
||||||
@@ -16,9 +16,6 @@ stage:
|
|||||||
pull-stage:
|
pull-stage:
|
||||||
docker compose -f ./docker/docker-compose.staging.yml pull
|
docker compose -f ./docker/docker-compose.staging.yml pull
|
||||||
|
|
||||||
server-e2e-jobs:
|
|
||||||
docker compose -f ./server/e2e/docker-compose.server-e2e.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
|
|
||||||
|
|
||||||
.PHONY: e2e
|
.PHONY: e2e
|
||||||
e2e:
|
e2e:
|
||||||
docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
|
docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
|
||||||
|
|||||||
@@ -9,26 +9,27 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="design/immich-logo.svg" width="150" title="Login With Custom URL">
|
<img src="design/immich-logo-stacked-light.svg" width="300" title="Login With Custom URL">
|
||||||
</p>
|
</p>
|
||||||
<h3 align="center">Immich - High performance self-hosted photo and video backup solution</h3>
|
<h3 align="center">High performance self-hosted photo and video management solution</h3>
|
||||||
<br/>
|
<br/>
|
||||||
<a href="https://immich.app">
|
<a href="https://immich.app">
|
||||||
<img src="design/immich-screenshots.png" title="Main Screenshot">
|
<img src="design/immich-screenshots.png" title="Main Screenshot">
|
||||||
</a>
|
</a>
|
||||||
<br/>
|
<br/>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="README_ca_ES.md">Català</a>
|
<a href="readme_i18n/README_ca_ES.md">Català</a>
|
||||||
<a href="README_es_ES.md">Español</a>
|
<a href="readme_i18n/README_es_ES.md">Español</a>
|
||||||
<a href="README_fr_FR.md">Français</a>
|
<a href="readme_i18n/README_fr_FR.md">Français</a>
|
||||||
<a href="README_it_IT.md">Italiano</a>
|
<a href="readme_i18n/README_it_IT.md">Italiano</a>
|
||||||
<a href="README_ja_JP.md">日本語</a>
|
<a href="readme_i18n/README_ja_JP.md">日本語</a>
|
||||||
<a href="README_ko_KR.md">한국어</a>
|
<a href="readme_i18n/README_ko_KR.md">한국어</a>
|
||||||
<a href="README_de_DE.md">Deutsch</a>
|
<a href="readme_i18n/README_de_DE.md">Deutsch</a>
|
||||||
<a href="README_nl_NL.md">Nederlands</a>
|
<a href="readme_i18n/README_nl_NL.md">Nederlands</a>
|
||||||
<a href="README_tr_TR.md">Türkçe</a>
|
<a href="readme_i18n/README_tr_TR.md">Türkçe</a>
|
||||||
<a href="README_zh_CN.md">中文</a>
|
<a href="readme_i18n/README_zh_CN.md">中文</a>
|
||||||
<a href="README_ru_RU.md">Русский</a>
|
<a href="readme_i18n/README_ru_RU.md">Русский</a>
|
||||||
|
<a href="readme_i18n/README_pt_BR.md">Português Brasileiro</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
@@ -47,7 +48,6 @@
|
|||||||
- [Introduction](https://immich.app/docs/overview/introduction)
|
- [Introduction](https://immich.app/docs/overview/introduction)
|
||||||
- [Installation](https://immich.app/docs/install/requirements)
|
- [Installation](https://immich.app/docs/install/requirements)
|
||||||
- [Contribution Guidelines](https://immich.app/docs/overview/support-the-project)
|
- [Contribution Guidelines](https://immich.app/docs/overview/support-the-project)
|
||||||
- [Support The Project](#support-the-project)
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
@@ -106,23 +106,6 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
|
|||||||
| Read-only gallery | Yes | Yes |
|
| Read-only gallery | Yes | Yes |
|
||||||
| Stacked Photos | Yes | Yes |
|
| Stacked Photos | Yes | Yes |
|
||||||
|
|
||||||
## Support the project
|
|
||||||
|
|
||||||
I've committed to this project, and I will not stop. I will keep updating the docs, adding new features, and fixing bugs. But I can't do it alone. So I need your help to give me additional motivation to keep going.
|
|
||||||
|
|
||||||
As our hosts in the [selfhosted.show - In the episode 'The-organization-must-not-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) said, this is a massive undertaking of what the team and I are doing. And I would love to someday be able to do this full-time, and I am asking for your help to make that happen.
|
|
||||||
|
|
||||||
If you feel like this is the right cause and the app is something you are seeing yourself using for a long time, please consider supporting the project with the option below.
|
|
||||||
|
|
||||||
### Donation
|
|
||||||
|
|
||||||
- [Monthly donation](https://github.com/sponsors/immich-app) via GitHub Sponsors
|
|
||||||
- [One-time donation](https://github.com/sponsors/immich-app?frequency=one-time&sponsor=alextran1502) via GitHub Sponsors
|
|
||||||
- [Liberapay](https://liberapay.com/alex.tran1502/)
|
|
||||||
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
|
|
||||||
- Bitcoin: 3QVAb9dCHutquVejeNXitPqZX26Yg5kxb7
|
|
||||||
- ZCash: u1smm4wvqegcp46zss2jf5xptchgeczp4rx7a0wu3mermf2wxahm26yyz5w9mw3f2p4emwlljxjumg774kgs8rntt9yags0whnzane4n67z4c7gppq4yyvcj404ne3r769prwzd9j8ntvqp44fa6d67sf7rmcfjmds3gmeceff4u8e92rh38nd30cr96xw6vfhk6scu4ws90ldzupr3sz
|
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
||||||
@@ -131,6 +114,10 @@ If you feel like this is the right cause and the app is something you are seeing
|
|||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
<a href="https://star-history.com/#immich-app/immich">
|
<a href="https://star-history.com/#immich-app/immich&Date">
|
||||||
<img src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" alt="Star History Chart" width="100%" />
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
|
||||||
|
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
|
||||||
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ module.exports = {
|
|||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
'@typescript-eslint/no-floating-promises': 'error',
|
'@typescript-eslint/no-floating-promises': 'error',
|
||||||
'unicorn/prefer-module': 'off',
|
'unicorn/prefer-module': 'off',
|
||||||
|
'unicorn/prevent-abbreviations': 'off',
|
||||||
|
'unicorn/no-process-exit': 'off',
|
||||||
|
'unicorn/import-style': 'off',
|
||||||
curly: 2,
|
curly: 2,
|
||||||
'prettier/prettier': 0,
|
'prettier/prettier': 0,
|
||||||
'unicorn/prevent-abbreviations': 'error',
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
**/*.spec.js
|
**/*.spec.js
|
||||||
|
coverage/**
|
||||||
|
src/**
|
||||||
upload/**
|
upload/**
|
||||||
.editorconfig
|
.editorconfig
|
||||||
.eslintignore
|
.eslintignore
|
||||||
.eslintrc.js
|
.eslintrc.cjs
|
||||||
|
.gitignore
|
||||||
.prettierignore
|
.prettierignore
|
||||||
.prettierrc
|
.prettierrc
|
||||||
|
Dockerfile
|
||||||
package-lock.json
|
package-lock.json
|
||||||
testSetup.js
|
|
||||||
tsconfig.json
|
tsconfig.json
|
||||||
tsconfig.build.json
|
vite.config.ts
|
||||||
|
vitest.config.ts
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
v20.12
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine3.19@sha256:c0a3badbd8a0a760de903e00cedbca94588e609299820557e72cba2a53dbaa2c as core
|
FROM node:20-alpine3.19@sha256:7a91aa397f2e2dfbfcdad2e2d72599f374e0b0172be1d86eeb73f1d33f36a4b2 as core
|
||||||
|
|
||||||
WORKDIR /usr/src/open-api/typescript-sdk
|
WORKDIR /usr/src/open-api/typescript-sdk
|
||||||
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
|
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.0.8",
|
"version": "2.2.0",
|
||||||
"description": "Command Line Interface (CLI) for Immich",
|
"description": "Command Line Interface (CLI) for Immich",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": "./dist/index.js",
|
"exports": "./dist/index.js",
|
||||||
@@ -28,13 +28,13 @@
|
|||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
"eslint-plugin-unicorn": "^51.0.0",
|
"eslint-plugin-unicorn": "^52.0.0",
|
||||||
"glob": "^10.3.1",
|
|
||||||
"mock-fs": "^5.2.0",
|
"mock-fs": "^5.2.0",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
"prettier-plugin-organize-imports": "^3.2.4",
|
"prettier-plugin-organize-imports": "^3.2.4",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"vite": "^5.0.12",
|
"vite": "^5.0.12",
|
||||||
|
"vite-tsconfig-paths": "^4.3.2",
|
||||||
"vitest": "^1.2.2",
|
"vitest": "^1.2.2",
|
||||||
"yaml": "^2.3.1"
|
"yaml": "^2.3.1"
|
||||||
},
|
},
|
||||||
@@ -58,6 +58,10 @@
|
|||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"fast-glob": "^3.3.2",
|
||||||
"lodash-es": "^4.17.21"
|
"lodash-es": "^4.17.21"
|
||||||
|
},
|
||||||
|
"volta": {
|
||||||
|
"node": "20.12.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
import {
|
||||||
|
Action,
|
||||||
|
AssetBulkUploadCheckResult,
|
||||||
|
AssetFileUploadResponseDto,
|
||||||
|
addAssetsToAlbum,
|
||||||
|
checkBulkUpload,
|
||||||
|
createAlbum,
|
||||||
|
defaults,
|
||||||
|
getAllAlbums,
|
||||||
|
getSupportedMediaTypes,
|
||||||
|
} from '@immich/sdk';
|
||||||
|
import byteSize from 'byte-size';
|
||||||
|
import { Presets, SingleBar } from 'cli-progress';
|
||||||
|
import { chunk } from 'lodash-es';
|
||||||
|
import { Stats, createReadStream } from 'node:fs';
|
||||||
|
import { stat, unlink } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path, { basename } from 'node:path';
|
||||||
|
import { BaseOptions, authenticate, crawl, sha1 } from 'src/utils';
|
||||||
|
|
||||||
|
const s = (count: number) => (count === 1 ? '' : 's');
|
||||||
|
|
||||||
|
// TODO figure out why `id` is missing
|
||||||
|
type AssetBulkUploadCheckResults = Array<AssetBulkUploadCheckResult & { id: string }>;
|
||||||
|
type Asset = { id: string; filepath: string };
|
||||||
|
|
||||||
|
interface UploadOptionsDto {
|
||||||
|
recursive?: boolean;
|
||||||
|
ignore?: string;
|
||||||
|
dryRun?: boolean;
|
||||||
|
skipHash?: boolean;
|
||||||
|
delete?: boolean;
|
||||||
|
album?: boolean;
|
||||||
|
albumName?: string;
|
||||||
|
includeHidden?: boolean;
|
||||||
|
concurrency: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UploadFile extends File {
|
||||||
|
constructor(
|
||||||
|
private filepath: string,
|
||||||
|
private _size: number,
|
||||||
|
) {
|
||||||
|
super([], basename(filepath));
|
||||||
|
}
|
||||||
|
|
||||||
|
get size() {
|
||||||
|
return this._size;
|
||||||
|
}
|
||||||
|
|
||||||
|
stream() {
|
||||||
|
return createReadStream(this.filepath) as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const upload = async (paths: string[], baseOptions: BaseOptions, options: UploadOptionsDto) => {
|
||||||
|
await authenticate(baseOptions);
|
||||||
|
|
||||||
|
const scanFiles = await scan(paths, options);
|
||||||
|
if (scanFiles.length === 0) {
|
||||||
|
console.log('No files found, exiting');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { newFiles, duplicates } = await checkForDuplicates(scanFiles, options);
|
||||||
|
const newAssets = await uploadFiles(newFiles, options);
|
||||||
|
await updateAlbums([...newAssets, ...duplicates], options);
|
||||||
|
await deleteFiles(newFiles, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scan = async (pathsToCrawl: string[], options: UploadOptionsDto) => {
|
||||||
|
const { image, video } = await getSupportedMediaTypes();
|
||||||
|
|
||||||
|
console.log('Crawling for assets...');
|
||||||
|
const files = await crawl({
|
||||||
|
pathsToCrawl,
|
||||||
|
recursive: options.recursive,
|
||||||
|
exclusionPattern: options.ignore,
|
||||||
|
includeHidden: options.includeHidden,
|
||||||
|
extensions: [...image, ...video],
|
||||||
|
});
|
||||||
|
|
||||||
|
return files;
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkForDuplicates = async (files: string[], { concurrency, skipHash }: UploadOptionsDto) => {
|
||||||
|
if (skipHash) {
|
||||||
|
console.log('Skipping hash check, assuming all files are new');
|
||||||
|
return { newFiles: files, duplicates: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressBar = new SingleBar(
|
||||||
|
{ format: 'Checking files | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
|
||||||
|
Presets.shades_classic,
|
||||||
|
);
|
||||||
|
|
||||||
|
progressBar.start(files.length, 0);
|
||||||
|
|
||||||
|
const newFiles: string[] = [];
|
||||||
|
const duplicates: Asset[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO refactor into a queue
|
||||||
|
for (const items of chunk(files, concurrency)) {
|
||||||
|
const dto = await Promise.all(items.map(async (filepath) => ({ id: filepath, checksum: await sha1(filepath) })));
|
||||||
|
const { results } = await checkBulkUpload({ assetBulkUploadCheckDto: { assets: dto } });
|
||||||
|
|
||||||
|
for (const { id: filepath, assetId, action } of results as AssetBulkUploadCheckResults) {
|
||||||
|
if (action === Action.Accept) {
|
||||||
|
newFiles.push(filepath);
|
||||||
|
} else {
|
||||||
|
// rejects are always duplicates
|
||||||
|
duplicates.push({ id: assetId as string, filepath });
|
||||||
|
}
|
||||||
|
progressBar.increment();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
progressBar.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${newFiles.length} new files and ${duplicates.length} duplicate${s(duplicates.length)}`);
|
||||||
|
|
||||||
|
return { newFiles, duplicates };
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFiles = async (files: string[], { dryRun, concurrency }: UploadOptionsDto): Promise<Asset[]> => {
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.log('All assets were already uploaded, nothing to do.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute total size first
|
||||||
|
let totalSize = 0;
|
||||||
|
const statsMap = new Map<string, Stats>();
|
||||||
|
for (const filepath of files) {
|
||||||
|
const stats = await stat(filepath);
|
||||||
|
statsMap.set(filepath, stats);
|
||||||
|
totalSize += stats.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(`Would have uploaded ${files.length} asset${s(files.length)} (${byteSize(totalSize)})`);
|
||||||
|
return files.map((filepath) => ({ id: '', filepath }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadProgress = new SingleBar(
|
||||||
|
{ format: 'Uploading assets | {bar} | {percentage}% | ETA: {eta_formatted} | {value_formatted}/{total_formatted}' },
|
||||||
|
Presets.shades_classic,
|
||||||
|
);
|
||||||
|
uploadProgress.start(totalSize, 0);
|
||||||
|
uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) });
|
||||||
|
|
||||||
|
let duplicateCount = 0;
|
||||||
|
let duplicateSize = 0;
|
||||||
|
let successCount = 0;
|
||||||
|
let successSize = 0;
|
||||||
|
|
||||||
|
const newAssets: Asset[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const items of chunk(files, concurrency)) {
|
||||||
|
await Promise.all(
|
||||||
|
items.map(async (filepath) => {
|
||||||
|
const stats = statsMap.get(filepath) as Stats;
|
||||||
|
const response = await uploadFile(filepath, stats);
|
||||||
|
|
||||||
|
newAssets.push({ id: response.id, filepath });
|
||||||
|
|
||||||
|
if (response.duplicate) {
|
||||||
|
duplicateCount++;
|
||||||
|
duplicateSize += stats.size ?? 0;
|
||||||
|
} else {
|
||||||
|
successCount++;
|
||||||
|
successSize += stats.size ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadProgress.update(successSize, { value_formatted: byteSize(successSize + duplicateSize) });
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
uploadProgress.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Successfully uploaded ${successCount} new asset${s(successCount)} (${byteSize(successSize)})`);
|
||||||
|
if (duplicateCount > 0) {
|
||||||
|
console.log(`Skipped ${duplicateCount} duplicate asset${s(duplicateCount)} (${byteSize(duplicateSize)})`);
|
||||||
|
}
|
||||||
|
return newAssets;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFile = async (input: string, stats: Stats): Promise<AssetFileUploadResponseDto> => {
|
||||||
|
const { baseUrl, headers } = defaults;
|
||||||
|
|
||||||
|
const assetPath = path.parse(input);
|
||||||
|
const noExtension = path.join(assetPath.dir, assetPath.name);
|
||||||
|
|
||||||
|
const sidecarsFiles = await Promise.all(
|
||||||
|
// XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp
|
||||||
|
[`${noExtension}.xmp`, `${input}.xmp`].map(async (sidecarPath) => {
|
||||||
|
try {
|
||||||
|
const stats = await stat(sidecarPath);
|
||||||
|
return new UploadFile(sidecarPath, stats.size);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sidecarData = sidecarsFiles.find((file): file is UploadFile => file !== false);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('deviceAssetId', `${basename(input)}-${stats.size}`.replaceAll(/\s+/g, ''));
|
||||||
|
formData.append('deviceId', 'CLI');
|
||||||
|
formData.append('fileCreatedAt', stats.mtime.toISOString());
|
||||||
|
formData.append('fileModifiedAt', stats.mtime.toISOString());
|
||||||
|
formData.append('fileSize', String(stats.size));
|
||||||
|
formData.append('isFavorite', 'false');
|
||||||
|
formData.append('assetData', new UploadFile(input, stats.size));
|
||||||
|
|
||||||
|
if (sidecarData) {
|
||||||
|
formData.append('sidecarData', sidecarData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/asset/upload`, {
|
||||||
|
method: 'post',
|
||||||
|
redirect: 'error',
|
||||||
|
headers: headers as Record<string, string>,
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (response.status !== 200 && response.status !== 201) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteFiles = async (files: string[], options: UploadOptionsDto): Promise<void> => {
|
||||||
|
if (!options.delete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.dryRun) {
|
||||||
|
console.log(`Would have deleted ${files.length} local asset${s(files.length)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Deleting assets that have been uploaded...');
|
||||||
|
|
||||||
|
const deletionProgress = new SingleBar(
|
||||||
|
{ format: 'Deleting local assets | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
|
||||||
|
Presets.shades_classic,
|
||||||
|
);
|
||||||
|
deletionProgress.start(files.length, 0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const assetBatch of chunk(files, options.concurrency)) {
|
||||||
|
await Promise.all(assetBatch.map((input: string) => unlink(input)));
|
||||||
|
deletionProgress.update(assetBatch.length);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
deletionProgress.stop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAlbums = async (assets: Asset[], options: UploadOptionsDto) => {
|
||||||
|
if (!options.album && !options.albumName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { dryRun, concurrency } = options;
|
||||||
|
|
||||||
|
const albums = await getAllAlbums({});
|
||||||
|
const existingAlbums = new Map(albums.map((album) => [album.albumName, album.id]));
|
||||||
|
const newAlbums: Set<string> = new Set();
|
||||||
|
for (const { filepath } of assets) {
|
||||||
|
const albumName = getAlbumName(filepath, options);
|
||||||
|
if (albumName && !existingAlbums.has(albumName)) {
|
||||||
|
newAlbums.add(albumName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
// TODO print asset counts for new albums
|
||||||
|
console.log(`Would have created ${newAlbums.size} new album${s(newAlbums.size)}`);
|
||||||
|
console.log(`Would have updated albums of ${assets.length} asset${s(assets.length)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressBar = new SingleBar(
|
||||||
|
{ format: 'Creating albums | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} albums' },
|
||||||
|
Presets.shades_classic,
|
||||||
|
);
|
||||||
|
progressBar.start(newAlbums.size, 0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const albumNames of chunk([...newAlbums], concurrency)) {
|
||||||
|
const items = await Promise.all(
|
||||||
|
albumNames.map((albumName: string) => createAlbum({ createAlbumDto: { albumName } })),
|
||||||
|
);
|
||||||
|
for (const { id, albumName } of items) {
|
||||||
|
existingAlbums.set(albumName, id);
|
||||||
|
}
|
||||||
|
progressBar.increment(albumNames.length);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
progressBar.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Successfully created ${newAlbums.size} new album${s(newAlbums.size)}`);
|
||||||
|
console.log(`Successfully updated ${assets.length} asset${s(assets.length)}`);
|
||||||
|
|
||||||
|
const albumToAssets = new Map<string, string[]>();
|
||||||
|
for (const asset of assets) {
|
||||||
|
const albumName = getAlbumName(asset.filepath, options);
|
||||||
|
if (!albumName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const albumId = existingAlbums.get(albumName);
|
||||||
|
if (albumId) {
|
||||||
|
if (!albumToAssets.has(albumId)) {
|
||||||
|
albumToAssets.set(albumId, []);
|
||||||
|
}
|
||||||
|
albumToAssets.get(albumId)?.push(asset.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const albumUpdateProgress = new SingleBar(
|
||||||
|
{ format: 'Adding assets to albums | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
|
||||||
|
Presets.shades_classic,
|
||||||
|
);
|
||||||
|
albumUpdateProgress.start(assets.length, 0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const [albumId, assets] of albumToAssets.entries()) {
|
||||||
|
for (const assetBatch of chunk(assets, Math.min(1000 * concurrency, 65_000))) {
|
||||||
|
await addAssetsToAlbum({ id: albumId, bulkIdsDto: { ids: assetBatch } });
|
||||||
|
albumUpdateProgress.increment(assetBatch.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
albumUpdateProgress.stop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAlbumName = (filepath: string, options: UploadOptionsDto) => {
|
||||||
|
const folderName = os.platform() === 'win32' ? filepath.split('\\').at(-2) : filepath.split('/').at(-2);
|
||||||
|
return options.albumName ?? folderName;
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { getMyUserInfo } from '@immich/sdk';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { mkdir, unlink } from 'node:fs/promises';
|
||||||
|
import { BaseOptions, connect, getAuthFilePath, logError, withError, writeAuthFile } from 'src/utils';
|
||||||
|
|
||||||
|
export const login = async (url: string, key: string, options: BaseOptions) => {
|
||||||
|
console.log(`Logging in to ${url}`);
|
||||||
|
|
||||||
|
const { configDirectory: configDir } = options;
|
||||||
|
|
||||||
|
await connect(url, key);
|
||||||
|
|
||||||
|
const [error, userInfo] = await withError(getMyUserInfo());
|
||||||
|
if (error) {
|
||||||
|
logError(error, 'Failed to load user info');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Logged in as ${userInfo.email}`);
|
||||||
|
|
||||||
|
if (!existsSync(configDir)) {
|
||||||
|
// Create config folder if it doesn't exist
|
||||||
|
const created = await mkdir(configDir, { recursive: true });
|
||||||
|
if (!created) {
|
||||||
|
console.log(`Failed to create config folder: ${configDir}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeAuthFile(configDir, { url, key });
|
||||||
|
|
||||||
|
console.log(`Wrote auth info to ${getAuthFilePath(configDir)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logout = async (options: BaseOptions) => {
|
||||||
|
console.log('Logging out...');
|
||||||
|
|
||||||
|
const { configDirectory: configDir } = options;
|
||||||
|
|
||||||
|
const authFile = getAuthFilePath(configDir);
|
||||||
|
|
||||||
|
if (existsSync(authFile)) {
|
||||||
|
await unlink(authFile);
|
||||||
|
console.log(`Removed auth file: ${authFile}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Successfully logged out');
|
||||||
|
};
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { ServerVersionResponseDto, UserResponseDto } from '@immich/sdk';
|
|
||||||
import { ImmichApi } from 'src/services/api.service';
|
|
||||||
import { SessionService } from '../services/session.service';
|
|
||||||
|
|
||||||
export abstract class BaseCommand {
|
|
||||||
protected sessionService!: SessionService;
|
|
||||||
protected user!: UserResponseDto;
|
|
||||||
protected serverVersion!: ServerVersionResponseDto;
|
|
||||||
|
|
||||||
constructor(options: { configDirectory?: string }) {
|
|
||||||
if (!options.configDirectory) {
|
|
||||||
throw new Error('Config directory is required');
|
|
||||||
}
|
|
||||||
this.sessionService = new SessionService(options.configDirectory);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async connect(): Promise<ImmichApi> {
|
|
||||||
return await this.sessionService.connect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { BaseCommand } from './base-command';
|
|
||||||
|
|
||||||
export class LoginCommand extends BaseCommand {
|
|
||||||
public async run(instanceUrl: string, apiKey: string): Promise<void> {
|
|
||||||
await this.sessionService.login(instanceUrl, apiKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { BaseCommand } from './base-command';
|
|
||||||
|
|
||||||
export class LogoutCommand extends BaseCommand {
|
|
||||||
public static readonly description = 'Logout and remove persisted credentials';
|
|
||||||
public async run(): Promise<void> {
|
|
||||||
await this.sessionService.logout();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { BaseCommand } from './base-command';
|
|
||||||
|
|
||||||
export class ServerInfoCommand extends BaseCommand {
|
|
||||||
public async run() {
|
|
||||||
const api = await this.connect();
|
|
||||||
const versionInfo = await api.getServerVersion();
|
|
||||||
const mediaTypes = await api.getSupportedMediaTypes();
|
|
||||||
const statistics = await api.getAssetStatistics();
|
|
||||||
|
|
||||||
console.log(`Server Version: ${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}`);
|
|
||||||
console.log(`Image Types: ${mediaTypes.image.map((extension) => extension.replace('.', ''))}`);
|
|
||||||
console.log(`Video Types: ${mediaTypes.video.map((extension) => extension.replace('.', ''))}`);
|
|
||||||
console.log(
|
|
||||||
`Statistics:\n Images: ${statistics.images}\n Videos: ${statistics.videos}\n Total: ${statistics.total}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { getAssetStatistics, getMyUserInfo, getServerVersion, getSupportedMediaTypes } from '@immich/sdk';
|
||||||
|
import { BaseOptions, authenticate } from 'src/utils';
|
||||||
|
|
||||||
|
export const serverInfo = async (options: BaseOptions) => {
|
||||||
|
const { url } = await authenticate(options);
|
||||||
|
|
||||||
|
const [versionInfo, mediaTypes, stats, userInfo] = await Promise.all([
|
||||||
|
getServerVersion(),
|
||||||
|
getSupportedMediaTypes(),
|
||||||
|
getAssetStatistics({}),
|
||||||
|
getMyUserInfo(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log(`Server Info (via ${userInfo.email})`);
|
||||||
|
console.log(` Url: ${url}`);
|
||||||
|
console.log(` Version: ${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}`);
|
||||||
|
console.log(` Formats:`);
|
||||||
|
console.log(` Images: ${mediaTypes.image.map((extension) => extension.replace('.', ''))}`);
|
||||||
|
console.log(` Videos: ${mediaTypes.video.map((extension) => extension.replace('.', ''))}`);
|
||||||
|
console.log(` Statistics:`);
|
||||||
|
console.log(` Images: ${stats.images}`);
|
||||||
|
console.log(` Videos: ${stats.videos}`);
|
||||||
|
console.log(` Total: ${stats.total}`);
|
||||||
|
};
|
||||||
@@ -1,444 +0,0 @@
|
|||||||
import { AssetBulkUploadCheckResult } from '@immich/sdk';
|
|
||||||
import byteSize from 'byte-size';
|
|
||||||
import cliProgress from 'cli-progress';
|
|
||||||
import { chunk, zip } from 'lodash-es';
|
|
||||||
import { createHash } from 'node:crypto';
|
|
||||||
import fs, { createReadStream } from 'node:fs';
|
|
||||||
import { access, constants, stat, unlink } from 'node:fs/promises';
|
|
||||||
import os from 'node:os';
|
|
||||||
import { basename } from 'node:path';
|
|
||||||
import { ImmichApi } from 'src/services/api.service';
|
|
||||||
import { CrawlService } from '../services/crawl.service';
|
|
||||||
import { BaseCommand } from './base-command';
|
|
||||||
|
|
||||||
const zipDefined = zip as <T, U>(a: T[], b: U[]) => [T, U][];
|
|
||||||
|
|
||||||
enum CheckResponseStatus {
|
|
||||||
ACCEPT = 'accept',
|
|
||||||
REJECT = 'reject',
|
|
||||||
DUPLICATE = 'duplicate',
|
|
||||||
}
|
|
||||||
|
|
||||||
class Asset {
|
|
||||||
readonly path: string;
|
|
||||||
|
|
||||||
id?: string;
|
|
||||||
deviceAssetId?: string;
|
|
||||||
fileCreatedAt?: Date;
|
|
||||||
fileModifiedAt?: Date;
|
|
||||||
sidecarPath?: string;
|
|
||||||
fileSize?: number;
|
|
||||||
albumName?: string;
|
|
||||||
|
|
||||||
constructor(path: string) {
|
|
||||||
this.path = path;
|
|
||||||
}
|
|
||||||
|
|
||||||
async prepare() {
|
|
||||||
const stats = await stat(this.path);
|
|
||||||
this.deviceAssetId = `${basename(this.path)}-${stats.size}`.replaceAll(/\s+/g, '');
|
|
||||||
this.fileCreatedAt = stats.mtime;
|
|
||||||
this.fileModifiedAt = stats.mtime;
|
|
||||||
this.fileSize = stats.size;
|
|
||||||
this.albumName = this.extractAlbumName();
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUploadFormData(): Promise<FormData> {
|
|
||||||
if (!this.deviceAssetId) {
|
|
||||||
throw new Error('Device asset id not set');
|
|
||||||
}
|
|
||||||
if (!this.fileCreatedAt) {
|
|
||||||
throw new Error('File created at not set');
|
|
||||||
}
|
|
||||||
if (!this.fileModifiedAt) {
|
|
||||||
throw new Error('File modified at not set');
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: doesn't xmp replace the file extension? Will need investigation
|
|
||||||
const sideCarPath = `${this.path}.xmp`;
|
|
||||||
let sidecarData: Blob | undefined = undefined;
|
|
||||||
try {
|
|
||||||
await access(sideCarPath, constants.R_OK);
|
|
||||||
sidecarData = new File([await fs.openAsBlob(sideCarPath)], basename(sideCarPath));
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
const data: any = {
|
|
||||||
assetData: new File([await fs.openAsBlob(this.path)], basename(this.path)),
|
|
||||||
deviceAssetId: this.deviceAssetId,
|
|
||||||
deviceId: 'CLI',
|
|
||||||
fileCreatedAt: this.fileCreatedAt.toISOString(),
|
|
||||||
fileModifiedAt: this.fileModifiedAt.toISOString(),
|
|
||||||
isFavorite: String(false),
|
|
||||||
};
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
for (const property in data) {
|
|
||||||
formData.append(property, data[property]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sidecarData) {
|
|
||||||
formData.append('sidecarData', sidecarData);
|
|
||||||
}
|
|
||||||
|
|
||||||
return formData;
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(): Promise<void> {
|
|
||||||
return unlink(this.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async hash(): Promise<string> {
|
|
||||||
const sha1 = (filePath: string) => {
|
|
||||||
const hash = createHash('sha1');
|
|
||||||
return new Promise<string>((resolve, reject) => {
|
|
||||||
const rs = createReadStream(filePath);
|
|
||||||
rs.on('error', reject);
|
|
||||||
rs.on('data', (chunk) => hash.update(chunk));
|
|
||||||
rs.on('end', () => resolve(hash.digest('hex')));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return await sha1(this.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractAlbumName(): string | undefined {
|
|
||||||
return os.platform() === 'win32' ? this.path.split('\\').at(-2) : this.path.split('/').at(-2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UploadOptionsDto {
|
|
||||||
recursive? = false;
|
|
||||||
exclusionPatterns?: string[] = [];
|
|
||||||
dryRun? = false;
|
|
||||||
skipHash? = false;
|
|
||||||
delete? = false;
|
|
||||||
album? = false;
|
|
||||||
albumName? = '';
|
|
||||||
includeHidden? = false;
|
|
||||||
concurrency? = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UploadCommand extends BaseCommand {
|
|
||||||
api!: ImmichApi;
|
|
||||||
|
|
||||||
public async run(paths: string[], options: UploadOptionsDto): Promise<void> {
|
|
||||||
this.api = await this.connect();
|
|
||||||
|
|
||||||
console.log('Crawling for assets...');
|
|
||||||
const files = await this.getFiles(paths, options);
|
|
||||||
|
|
||||||
if (files.length === 0) {
|
|
||||||
console.log('No assets found, exiting');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const assetsToCheck = files.map((path) => new Asset(path));
|
|
||||||
|
|
||||||
const { newAssets, duplicateAssets } = await this.checkAssets(assetsToCheck, options.concurrency ?? 4);
|
|
||||||
|
|
||||||
const totalSizeUploaded = await this.upload(newAssets, options);
|
|
||||||
const messageStart = options.dryRun ? 'Would have' : 'Successfully';
|
|
||||||
if (newAssets.length === 0) {
|
|
||||||
console.log('All assets were already uploaded, nothing to do.');
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
`${messageStart} uploaded ${newAssets.length} asset${newAssets.length === 1 ? '' : 's'} (${byteSize(totalSizeUploaded)})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.album || options.albumName) {
|
|
||||||
const { createdAlbumCount, updatedAssetCount } = await this.updateAlbums(
|
|
||||||
[...newAssets, ...duplicateAssets],
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
console.log(`${messageStart} created ${createdAlbumCount} new album${createdAlbumCount === 1 ? '' : 's'}`);
|
|
||||||
console.log(`${messageStart} updated ${updatedAssetCount} asset${updatedAssetCount === 1 ? '' : 's'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!options.delete) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.dryRun) {
|
|
||||||
console.log(`Would now have deleted assets, but skipped due to dry run`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Deleting assets that have been uploaded...');
|
|
||||||
|
|
||||||
await this.deleteAssets(newAssets, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async checkAssets(
|
|
||||||
assetsToCheck: Asset[],
|
|
||||||
concurrency: number,
|
|
||||||
): Promise<{ newAssets: Asset[]; duplicateAssets: Asset[]; rejectedAssets: Asset[] }> {
|
|
||||||
for (const assets of chunk(assetsToCheck, concurrency)) {
|
|
||||||
await Promise.all(assets.map((asset: Asset) => asset.prepare()));
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkProgress = new cliProgress.SingleBar(
|
|
||||||
{ format: 'Checking assets | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
|
|
||||||
cliProgress.Presets.shades_classic,
|
|
||||||
);
|
|
||||||
checkProgress.start(assetsToCheck.length, 0);
|
|
||||||
|
|
||||||
const newAssets = [];
|
|
||||||
const duplicateAssets = [];
|
|
||||||
const rejectedAssets = [];
|
|
||||||
try {
|
|
||||||
for (const assets of chunk(assetsToCheck, concurrency)) {
|
|
||||||
const checkedAssets = await this.getStatus(assets);
|
|
||||||
for (const checked of checkedAssets) {
|
|
||||||
if (checked.status === CheckResponseStatus.ACCEPT) {
|
|
||||||
newAssets.push(checked.asset);
|
|
||||||
} else if (checked.status === CheckResponseStatus.DUPLICATE) {
|
|
||||||
duplicateAssets.push(checked.asset);
|
|
||||||
} else {
|
|
||||||
rejectedAssets.push(checked.asset);
|
|
||||||
}
|
|
||||||
checkProgress.increment();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
checkProgress.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
return { newAssets, duplicateAssets, rejectedAssets };
|
|
||||||
}
|
|
||||||
|
|
||||||
public async upload(assetsToUpload: Asset[], options: UploadOptionsDto): Promise<number> {
|
|
||||||
let totalSize = 0;
|
|
||||||
|
|
||||||
// Compute total size first
|
|
||||||
for (const asset of assetsToUpload) {
|
|
||||||
totalSize += asset.fileSize ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.dryRun) {
|
|
||||||
return totalSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploadProgress = new cliProgress.SingleBar(
|
|
||||||
{
|
|
||||||
format: 'Uploading assets | {bar} | {percentage}% | ETA: {eta_formatted} | {value_formatted}/{total_formatted}',
|
|
||||||
},
|
|
||||||
cliProgress.Presets.shades_classic,
|
|
||||||
);
|
|
||||||
uploadProgress.start(totalSize, 0);
|
|
||||||
uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) });
|
|
||||||
|
|
||||||
let totalSizeUploaded = 0;
|
|
||||||
try {
|
|
||||||
for (const assets of chunk(assetsToUpload, options.concurrency)) {
|
|
||||||
const ids = await this.uploadAssets(assets);
|
|
||||||
for (const [asset, id] of zipDefined(assets, ids)) {
|
|
||||||
asset.id = id;
|
|
||||||
if (asset.fileSize) {
|
|
||||||
totalSizeUploaded += asset.fileSize ?? 0;
|
|
||||||
} else {
|
|
||||||
console.log(`Could not determine file size for ${asset.path}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
uploadProgress.update(totalSizeUploaded, { value_formatted: byteSize(totalSizeUploaded) });
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
uploadProgress.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
return totalSizeUploaded;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getFiles(paths: string[], options: UploadOptionsDto): Promise<string[]> {
|
|
||||||
const inputFiles: string[] = [];
|
|
||||||
for (const pathArgument of paths) {
|
|
||||||
const fileStat = await fs.promises.lstat(pathArgument);
|
|
||||||
if (fileStat.isFile()) {
|
|
||||||
inputFiles.push(pathArgument);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const files: string[] = await this.crawl(paths, options);
|
|
||||||
files.push(...inputFiles);
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getAlbums(): Promise<Map<string, string>> {
|
|
||||||
const existingAlbums = await this.api.getAllAlbums();
|
|
||||||
|
|
||||||
const albumMapping = new Map<string, string>();
|
|
||||||
for (const album of existingAlbums) {
|
|
||||||
albumMapping.set(album.albumName, album.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return albumMapping;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async updateAlbums(
|
|
||||||
assets: Asset[],
|
|
||||||
options: UploadOptionsDto,
|
|
||||||
): Promise<{ createdAlbumCount: number; updatedAssetCount: number }> {
|
|
||||||
if (options.albumName) {
|
|
||||||
for (const asset of assets) {
|
|
||||||
asset.albumName = options.albumName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingAlbums = await this.getAlbums();
|
|
||||||
const assetsToUpdate = assets.filter(
|
|
||||||
(asset): asset is Asset & { albumName: string; id: string } => !!(asset.albumName && asset.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
const newAlbumsSet: Set<string> = new Set();
|
|
||||||
for (const asset of assetsToUpdate) {
|
|
||||||
if (!existingAlbums.has(asset.albumName)) {
|
|
||||||
newAlbumsSet.add(asset.albumName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newAlbums = [...newAlbumsSet];
|
|
||||||
|
|
||||||
if (options.dryRun) {
|
|
||||||
return { createdAlbumCount: newAlbums.length, updatedAssetCount: assetsToUpdate.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
const albumCreationProgress = new cliProgress.SingleBar(
|
|
||||||
{
|
|
||||||
format: 'Creating albums | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} albums',
|
|
||||||
},
|
|
||||||
cliProgress.Presets.shades_classic,
|
|
||||||
);
|
|
||||||
albumCreationProgress.start(newAlbums.length, 0);
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const albumNames of chunk(newAlbums, options.concurrency)) {
|
|
||||||
const newAlbumIds = await Promise.all(
|
|
||||||
albumNames.map((albumName: string) => this.api.createAlbum({ albumName }).then((r) => r.id)),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const [albumName, albumId] of zipDefined(albumNames, newAlbumIds)) {
|
|
||||||
existingAlbums.set(albumName, albumId);
|
|
||||||
}
|
|
||||||
|
|
||||||
albumCreationProgress.increment(albumNames.length);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
albumCreationProgress.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
const albumToAssets = new Map<string, string[]>();
|
|
||||||
for (const asset of assetsToUpdate) {
|
|
||||||
const albumId = existingAlbums.get(asset.albumName);
|
|
||||||
if (albumId) {
|
|
||||||
if (!albumToAssets.has(albumId)) {
|
|
||||||
albumToAssets.set(albumId, []);
|
|
||||||
}
|
|
||||||
albumToAssets.get(albumId)?.push(asset.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const albumUpdateProgress = new cliProgress.SingleBar(
|
|
||||||
{
|
|
||||||
format: 'Adding assets to albums | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets',
|
|
||||||
},
|
|
||||||
cliProgress.Presets.shades_classic,
|
|
||||||
);
|
|
||||||
albumUpdateProgress.start(assetsToUpdate.length, 0);
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const [albumId, assets] of albumToAssets.entries()) {
|
|
||||||
for (const assetBatch of chunk(assets, Math.min(1000 * (options.concurrency ?? 4), 65_000))) {
|
|
||||||
await this.api.addAssetsToAlbum(albumId, { ids: assetBatch });
|
|
||||||
albumUpdateProgress.increment(assetBatch.length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
albumUpdateProgress.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
return { createdAlbumCount: newAlbums.length, updatedAssetCount: assetsToUpdate.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
public async deleteAssets(assets: Asset[], options: UploadOptionsDto): Promise<void> {
|
|
||||||
const deletionProgress = new cliProgress.SingleBar(
|
|
||||||
{
|
|
||||||
format: 'Deleting local assets | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets',
|
|
||||||
},
|
|
||||||
cliProgress.Presets.shades_classic,
|
|
||||||
);
|
|
||||||
deletionProgress.start(assets.length, 0);
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const assetBatch of chunk(assets, options.concurrency)) {
|
|
||||||
await Promise.all(assetBatch.map((asset: Asset) => asset.delete()));
|
|
||||||
deletionProgress.update(assetBatch.length);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
deletionProgress.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getStatus(assets: Asset[]): Promise<{ asset: Asset; status: CheckResponseStatus }[]> {
|
|
||||||
const checkResponse = await this.checkHashes(assets);
|
|
||||||
|
|
||||||
const responses = [];
|
|
||||||
for (const [check, asset] of zipDefined(checkResponse, assets)) {
|
|
||||||
if (check.assetId) {
|
|
||||||
asset.id = check.assetId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (check.action === 'accept') {
|
|
||||||
responses.push({ asset, status: CheckResponseStatus.ACCEPT });
|
|
||||||
} else if (check.reason === 'duplicate') {
|
|
||||||
responses.push({ asset, status: CheckResponseStatus.DUPLICATE });
|
|
||||||
} else {
|
|
||||||
responses.push({ asset, status: CheckResponseStatus.REJECT });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return responses;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async checkHashes(assetsToCheck: Asset[]): Promise<AssetBulkUploadCheckResult[]> {
|
|
||||||
const checksums = await Promise.all(assetsToCheck.map((asset) => asset.hash()));
|
|
||||||
const assetBulkUploadCheckDto = {
|
|
||||||
assets: zipDefined(assetsToCheck, checksums).map(([asset, checksum]) => ({ id: asset.path, checksum })),
|
|
||||||
};
|
|
||||||
const checkResponse = await this.api.checkBulkUpload(assetBulkUploadCheckDto);
|
|
||||||
return checkResponse.results;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async uploadAssets(assets: Asset[]): Promise<string[]> {
|
|
||||||
const fileRequests = await Promise.all(assets.map((asset) => asset.getUploadFormData()));
|
|
||||||
return Promise.all(fileRequests.map((request) => this.uploadAsset(request).then((response) => response.id)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async crawl(paths: string[], options: UploadOptionsDto): Promise<string[]> {
|
|
||||||
const formatResponse = await this.api.getSupportedMediaTypes();
|
|
||||||
const crawlService = new CrawlService(formatResponse.image, formatResponse.video);
|
|
||||||
|
|
||||||
return crawlService.crawl({
|
|
||||||
pathsToCrawl: paths,
|
|
||||||
recursive: options.recursive,
|
|
||||||
exclusionPatterns: options.exclusionPatterns,
|
|
||||||
includeHidden: options.includeHidden,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async uploadAsset(data: FormData): Promise<{ id: string }> {
|
|
||||||
const url = this.api.instanceUrl + '/asset/upload';
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'post',
|
|
||||||
redirect: 'error',
|
|
||||||
headers: {
|
|
||||||
'x-api-key': this.api.apiKey,
|
|
||||||
},
|
|
||||||
body: data,
|
|
||||||
});
|
|
||||||
if (response.status !== 200 && response.status !== 201) {
|
|
||||||
throw new Error(await response.text());
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,11 +2,10 @@
|
|||||||
import { Command, Option } from 'commander';
|
import { Command, Option } from 'commander';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { upload } from 'src/commands/asset';
|
||||||
|
import { login, logout } from 'src/commands/auth';
|
||||||
|
import { serverInfo } from 'src/commands/server-info';
|
||||||
import { version } from '../package.json';
|
import { version } from '../package.json';
|
||||||
import { LoginCommand } from './commands/login.command';
|
|
||||||
import { LogoutCommand } from './commands/logout.command';
|
|
||||||
import { ServerInfoCommand } from './commands/server-info.command';
|
|
||||||
import { UploadCommand } from './commands/upload.command';
|
|
||||||
|
|
||||||
const defaultConfigDirectory = path.join(os.homedir(), '.config/immich/');
|
const defaultConfigDirectory = path.join(os.homedir(), '.config/immich/');
|
||||||
|
|
||||||
@@ -18,14 +17,34 @@ const program = new Command()
|
|||||||
new Option('-d, --config-directory <directory>', 'Configuration directory where auth.yml will be stored')
|
new Option('-d, --config-directory <directory>', 'Configuration directory where auth.yml will be stored')
|
||||||
.env('IMMICH_CONFIG_DIR')
|
.env('IMMICH_CONFIG_DIR')
|
||||||
.default(defaultConfigDirectory),
|
.default(defaultConfigDirectory),
|
||||||
);
|
)
|
||||||
|
.addOption(new Option('-u, --url [url]', 'Immich server URL').env('IMMICH_INSTANCE_URL'))
|
||||||
|
.addOption(new Option('-k, --key [key]', 'Immich API key').env('IMMICH_API_KEY'));
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('login')
|
||||||
|
.alias('login-key')
|
||||||
|
.description('Login using an API key')
|
||||||
|
.argument('url', 'Immich server URL')
|
||||||
|
.argument('key', 'Immich API key')
|
||||||
|
.action((url, key) => login(url, key, program.opts()));
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('logout')
|
||||||
|
.description('Remove stored credentials')
|
||||||
|
.action(() => logout(program.opts()));
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('server-info')
|
||||||
|
.description('Display server information')
|
||||||
|
.action(() => serverInfo(program.opts()));
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('upload')
|
.command('upload')
|
||||||
.description('Upload assets')
|
.description('Upload assets')
|
||||||
.usage('[options] [paths...]')
|
.usage('[paths...] [options]')
|
||||||
.addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false))
|
.addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false))
|
||||||
.addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS'))
|
.addOption(new Option('-i, --ignore <pattern>', 'Pattern to ignore').env('IMMICH_IGNORE_PATHS'))
|
||||||
.addOption(new Option('-h, --skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false))
|
.addOption(new Option('-h, --skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false))
|
||||||
.addOption(new Option('-H, --include-hidden', 'Include hidden folders').env('IMMICH_INCLUDE_HIDDEN').default(false))
|
.addOption(new Option('-H, --include-hidden', 'Include hidden folders').env('IMMICH_INCLUDE_HIDDEN').default(false))
|
||||||
.addOption(
|
.addOption(
|
||||||
@@ -41,41 +60,16 @@ program
|
|||||||
.addOption(
|
.addOption(
|
||||||
new Option('-n, --dry-run', "Don't perform any actions, just show what will be done")
|
new Option('-n, --dry-run', "Don't perform any actions, just show what will be done")
|
||||||
.env('IMMICH_DRY_RUN')
|
.env('IMMICH_DRY_RUN')
|
||||||
.default(false),
|
.default(false)
|
||||||
|
.conflicts('skipHash'),
|
||||||
)
|
)
|
||||||
.addOption(
|
.addOption(
|
||||||
new Option('-c, --concurrency', 'Number of assets to upload at the same time')
|
new Option('-c, --concurrency <number>', 'Number of assets to upload at the same time')
|
||||||
.env('IMMICH_UPLOAD_CONCURRENCY')
|
.env('IMMICH_UPLOAD_CONCURRENCY')
|
||||||
.default(4),
|
.default(4),
|
||||||
)
|
)
|
||||||
.addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS'))
|
.addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS'))
|
||||||
.argument('[paths...]', 'One or more paths to assets to be uploaded')
|
.argument('[paths...]', 'One or more paths to assets to be uploaded')
|
||||||
.action(async (paths, options) => {
|
.action((paths, options) => upload(paths, program.opts(), options));
|
||||||
options.exclusionPatterns = options.ignore;
|
|
||||||
await new UploadCommand(program.opts()).run(paths, options);
|
|
||||||
});
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('server-info')
|
|
||||||
.description('Display server information')
|
|
||||||
.action(async () => {
|
|
||||||
await new ServerInfoCommand(program.opts()).run();
|
|
||||||
});
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('login-key')
|
|
||||||
.description('Login using an API key')
|
|
||||||
.argument('url')
|
|
||||||
.argument('key')
|
|
||||||
.action(async (url, key) => {
|
|
||||||
await new LoginCommand(program.opts()).run(url, key);
|
|
||||||
});
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('logout')
|
|
||||||
.description('Remove stored credentials')
|
|
||||||
.action(async () => {
|
|
||||||
await new LogoutCommand(program.opts()).run();
|
|
||||||
});
|
|
||||||
|
|
||||||
program.parse(process.argv);
|
program.parse(process.argv);
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
import {
|
|
||||||
ApiKeyCreateDto,
|
|
||||||
AssetBulkUploadCheckDto,
|
|
||||||
BulkIdsDto,
|
|
||||||
CreateAlbumDto,
|
|
||||||
CreateAssetDto,
|
|
||||||
LoginCredentialDto,
|
|
||||||
SignUpDto,
|
|
||||||
addAssetsToAlbum,
|
|
||||||
checkBulkUpload,
|
|
||||||
createAlbum,
|
|
||||||
createApiKey,
|
|
||||||
getAllAlbums,
|
|
||||||
getAllAssets,
|
|
||||||
getAssetStatistics,
|
|
||||||
getMyUserInfo,
|
|
||||||
getServerVersion,
|
|
||||||
getSupportedMediaTypes,
|
|
||||||
login,
|
|
||||||
pingServer,
|
|
||||||
signUpAdmin,
|
|
||||||
uploadFile,
|
|
||||||
} from '@immich/sdk';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps the underlying API to abstract away the options and make API calls mockable for testing.
|
|
||||||
*/
|
|
||||||
export class ImmichApi {
|
|
||||||
private readonly options;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
public instanceUrl: string,
|
|
||||||
public apiKey: string,
|
|
||||||
) {
|
|
||||||
this.options = {
|
|
||||||
baseUrl: instanceUrl,
|
|
||||||
headers: {
|
|
||||||
'x-api-key': apiKey,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
setApiKey(apiKey: string) {
|
|
||||||
this.apiKey = apiKey;
|
|
||||||
if (!this.options.headers) {
|
|
||||||
throw new Error('missing headers');
|
|
||||||
}
|
|
||||||
this.options.headers['x-api-key'] = apiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
addAssetsToAlbum(id: string, bulkIdsDto: BulkIdsDto) {
|
|
||||||
return addAssetsToAlbum({ id, bulkIdsDto }, this.options);
|
|
||||||
}
|
|
||||||
|
|
||||||
checkBulkUpload(assetBulkUploadCheckDto: AssetBulkUploadCheckDto) {
|
|
||||||
return checkBulkUpload({ assetBulkUploadCheckDto }, this.options);
|
|
||||||
}
|
|
||||||
|
|
||||||
createAlbum(createAlbumDto: CreateAlbumDto) {
|
|
||||||
return createAlbum({ createAlbumDto }, this.options);
|
|
||||||
}
|
|
||||||
|
|
||||||
createApiKey(apiKeyCreateDto: ApiKeyCreateDto, options: { headers: { Authorization: string } }) {
|
|
||||||
return createApiKey({ apiKeyCreateDto }, { ...this.options, ...options });
|
|
||||||
}
|
|
||||||
|
|
||||||
getAllAlbums() {
|
|
||||||
return getAllAlbums({}, this.options);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAllAssets() {
|
|
||||||
return getAllAssets({}, this.options);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAssetStatistics() {
|
|
||||||
return getAssetStatistics({}, this.options);
|
|
||||||
}
|
|
||||||
|
|
||||||
getMyUserInfo() {
|
|
||||||
return getMyUserInfo(this.options);
|
|
||||||
}
|
|
||||||
|
|
||||||
getServerVersion() {
|
|
||||||
return getServerVersion(this.options);
|
|
||||||
}
|
|
||||||
|
|
||||||
getSupportedMediaTypes() {
|
|
||||||
return getSupportedMediaTypes(this.options);
|
|
||||||
}
|
|
||||||
|
|
||||||
login(loginCredentialDto: LoginCredentialDto) {
|
|
||||||
return login({ loginCredentialDto }, this.options);
|
|
||||||
}
|
|
||||||
|
|
||||||
pingServer() {
|
|
||||||
return pingServer(this.options);
|
|
||||||
}
|
|
||||||
|
|
||||||
signUpAdmin(signUpDto: SignUpDto) {
|
|
||||||
return signUpAdmin({ signUpDto }, this.options);
|
|
||||||
}
|
|
||||||
|
|
||||||
uploadFile(createAssetDto: CreateAssetDto) {
|
|
||||||
return uploadFile({ createAssetDto }, this.options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { glob } from 'glob';
|
|
||||||
import * as fs from 'node:fs';
|
|
||||||
|
|
||||||
export class CrawlOptions {
|
|
||||||
pathsToCrawl!: string[];
|
|
||||||
recursive? = false;
|
|
||||||
includeHidden? = false;
|
|
||||||
exclusionPatterns?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CrawlService {
|
|
||||||
private readonly extensions!: string[];
|
|
||||||
|
|
||||||
constructor(image: string[], video: string[]) {
|
|
||||||
this.extensions = [...image, ...video].map((extension) => extension.replace('.', ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
async crawl(options: CrawlOptions): Promise<string[]> {
|
|
||||||
const { recursive, pathsToCrawl, exclusionPatterns, includeHidden } = options;
|
|
||||||
|
|
||||||
if (!pathsToCrawl) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const patterns: string[] = [];
|
|
||||||
const crawledFiles: string[] = [];
|
|
||||||
|
|
||||||
for await (const currentPath of pathsToCrawl) {
|
|
||||||
try {
|
|
||||||
const stats = await fs.promises.stat(currentPath);
|
|
||||||
if (stats.isFile() || stats.isSymbolicLink()) {
|
|
||||||
crawledFiles.push(currentPath);
|
|
||||||
} else {
|
|
||||||
patterns.push(currentPath);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.code === 'ENOENT') {
|
|
||||||
patterns.push(currentPath);
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let searchPattern: string;
|
|
||||||
if (patterns.length === 1) {
|
|
||||||
searchPattern = patterns[0];
|
|
||||||
} else if (patterns.length === 0) {
|
|
||||||
return crawledFiles;
|
|
||||||
} else {
|
|
||||||
searchPattern = '{' + patterns.join(',') + '}';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recursive) {
|
|
||||||
searchPattern = searchPattern + '/**/';
|
|
||||||
}
|
|
||||||
|
|
||||||
searchPattern = `${searchPattern}/*.{${this.extensions.join(',')}}`;
|
|
||||||
|
|
||||||
const globbedFiles = await glob(searchPattern, {
|
|
||||||
absolute: true,
|
|
||||||
nocase: true,
|
|
||||||
nodir: true,
|
|
||||||
dot: includeHidden,
|
|
||||||
ignore: exclusionPatterns,
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...crawledFiles, ...globbedFiles].sort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import yaml from 'yaml';
|
|
||||||
import { SessionService } from './session.service';
|
|
||||||
|
|
||||||
const TEST_CONFIG_DIR = '/tmp/immich/';
|
|
||||||
const TEST_AUTH_FILE = path.join(TEST_CONFIG_DIR, 'auth.yml');
|
|
||||||
const TEST_IMMICH_INSTANCE_URL = 'https://test/api';
|
|
||||||
const TEST_IMMICH_API_KEY = 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg';
|
|
||||||
|
|
||||||
const spyOnConsole = () => vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
|
|
||||||
const createTestAuthFile = async (contents: string) => {
|
|
||||||
if (!fs.existsSync(TEST_CONFIG_DIR)) {
|
|
||||||
// Create config folder if it doesn't exist
|
|
||||||
const created = await fs.promises.mkdir(TEST_CONFIG_DIR, { recursive: true });
|
|
||||||
if (!created) {
|
|
||||||
throw new Error(`Failed to create config folder ${TEST_CONFIG_DIR}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(TEST_AUTH_FILE, contents);
|
|
||||||
};
|
|
||||||
|
|
||||||
const readTestAuthFile = async (): Promise<string> => {
|
|
||||||
return await fs.promises.readFile(TEST_AUTH_FILE, 'utf8');
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteAuthFile = () => {
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(TEST_AUTH_FILE);
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.code !== 'ENOENT') {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => {
|
|
||||||
return {
|
|
||||||
getMyUserInfo: vi.fn(() => Promise.resolve({ email: 'admin@example.com' })),
|
|
||||||
pingServer: vi.fn(() => Promise.resolve({ res: 'pong' })),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./api.service', async (importOriginal) => {
|
|
||||||
const module = await importOriginal<typeof import('./api.service')>();
|
|
||||||
// @ts-expect-error this is only a partial implementation of the return value
|
|
||||||
module.ImmichApi.prototype.getMyUserInfo = mocks.getMyUserInfo;
|
|
||||||
module.ImmichApi.prototype.pingServer = mocks.pingServer;
|
|
||||||
return module;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('SessionService', () => {
|
|
||||||
let sessionService: SessionService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
deleteAuthFile();
|
|
||||||
sessionService = new SessionService(TEST_CONFIG_DIR);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
deleteAuthFile();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should connect to immich', async () => {
|
|
||||||
await createTestAuthFile(
|
|
||||||
JSON.stringify({
|
|
||||||
apiKey: TEST_IMMICH_API_KEY,
|
|
||||||
instanceUrl: TEST_IMMICH_INSTANCE_URL,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await sessionService.connect();
|
|
||||||
expect(mocks.pingServer).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should error if no auth file exists', async () => {
|
|
||||||
await sessionService.connect().catch((error) => {
|
|
||||||
expect(error.message).toEqual('No auth file exist. Please login first');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should error if auth file is missing instance URl', async () => {
|
|
||||||
await createTestAuthFile(
|
|
||||||
JSON.stringify({
|
|
||||||
apiKey: TEST_IMMICH_API_KEY,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await sessionService.connect().catch((error) => {
|
|
||||||
expect(error.message).toEqual(`Instance URL missing in auth config file ${TEST_AUTH_FILE}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should error if auth file is missing api key', async () => {
|
|
||||||
await createTestAuthFile(
|
|
||||||
JSON.stringify({
|
|
||||||
instanceUrl: TEST_IMMICH_INSTANCE_URL,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(sessionService.connect()).rejects.toThrow(`API key missing in auth config file ${TEST_AUTH_FILE}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create auth file when logged in', async () => {
|
|
||||||
await sessionService.login(TEST_IMMICH_INSTANCE_URL, TEST_IMMICH_API_KEY);
|
|
||||||
|
|
||||||
const data: string = await readTestAuthFile();
|
|
||||||
const authConfig = yaml.parse(data);
|
|
||||||
expect(authConfig.instanceUrl).toBe(TEST_IMMICH_INSTANCE_URL);
|
|
||||||
expect(authConfig.apiKey).toBe(TEST_IMMICH_API_KEY);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should delete auth file when logging out', async () => {
|
|
||||||
const consoleSpy = spyOnConsole();
|
|
||||||
|
|
||||||
await createTestAuthFile(
|
|
||||||
JSON.stringify({
|
|
||||||
apiKey: TEST_IMMICH_API_KEY,
|
|
||||||
instanceUrl: TEST_IMMICH_INSTANCE_URL,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await sessionService.logout();
|
|
||||||
|
|
||||||
await fs.promises.access(TEST_AUTH_FILE, fs.constants.F_OK).catch((error) => {
|
|
||||||
expect(error.message).toContain('ENOENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(consoleSpy.mock.calls).toEqual([
|
|
||||||
['Logging out...'],
|
|
||||||
[`Removed auth file ${TEST_AUTH_FILE}`],
|
|
||||||
['Successfully logged out'],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { access, constants, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import yaml from 'yaml';
|
|
||||||
import { ImmichApi } from './api.service';
|
|
||||||
|
|
||||||
class LoginError extends Error {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(message);
|
|
||||||
|
|
||||||
this.name = this.constructor.name;
|
|
||||||
|
|
||||||
Error.captureStackTrace(this, this.constructor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SessionService {
|
|
||||||
private get authPath() {
|
|
||||||
return path.join(this.configDirectory, '/auth.yml');
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(private configDirectory: string) {}
|
|
||||||
|
|
||||||
async connect(): Promise<ImmichApi> {
|
|
||||||
let instanceUrl = process.env.IMMICH_INSTANCE_URL;
|
|
||||||
let apiKey = process.env.IMMICH_API_KEY;
|
|
||||||
|
|
||||||
if (!instanceUrl || !apiKey) {
|
|
||||||
await access(this.authPath, constants.F_OK).catch((error) => {
|
|
||||||
if (error.code === 'ENOENT') {
|
|
||||||
throw new LoginError('No auth file exist. Please login first');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const data: string = await readFile(this.authPath, 'utf8');
|
|
||||||
const parsedConfig = yaml.parse(data);
|
|
||||||
|
|
||||||
instanceUrl = parsedConfig.instanceUrl;
|
|
||||||
apiKey = parsedConfig.apiKey;
|
|
||||||
|
|
||||||
if (!instanceUrl) {
|
|
||||||
throw new LoginError(`Instance URL missing in auth config file ${this.authPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!apiKey) {
|
|
||||||
throw new LoginError(`API key missing in auth config file ${this.authPath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
instanceUrl = await this.resolveApiEndpoint(instanceUrl);
|
|
||||||
|
|
||||||
const api = new ImmichApi(instanceUrl, apiKey);
|
|
||||||
|
|
||||||
const pingResponse = await api.pingServer().catch((error) => {
|
|
||||||
throw new Error(`Failed to connect to server ${instanceUrl}: ${error.message}`, error);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (pingResponse.res !== 'pong') {
|
|
||||||
throw new Error(`Could not parse response. Is Immich listening on ${instanceUrl}?`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return api;
|
|
||||||
}
|
|
||||||
|
|
||||||
async login(instanceUrl: string, apiKey: string): Promise<ImmichApi> {
|
|
||||||
console.log(`Logging in to ${instanceUrl}`);
|
|
||||||
|
|
||||||
instanceUrl = await this.resolveApiEndpoint(instanceUrl);
|
|
||||||
|
|
||||||
const api = new ImmichApi(instanceUrl, apiKey);
|
|
||||||
|
|
||||||
// Check if server and api key are valid
|
|
||||||
const userInfo = await api.getMyUserInfo().catch((error) => {
|
|
||||||
throw new LoginError(`Failed to connect to server ${instanceUrl}: ${error.message}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Logged in as ${userInfo.email}`);
|
|
||||||
|
|
||||||
if (!existsSync(this.configDirectory)) {
|
|
||||||
// Create config folder if it doesn't exist
|
|
||||||
const created = await mkdir(this.configDirectory, { recursive: true });
|
|
||||||
if (!created) {
|
|
||||||
throw new Error(`Failed to create config folder ${this.configDirectory}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(this.authPath, yaml.stringify({ instanceUrl, apiKey }), { mode: 0o600 });
|
|
||||||
|
|
||||||
console.log(`Wrote auth info to ${this.authPath}`);
|
|
||||||
|
|
||||||
return api;
|
|
||||||
}
|
|
||||||
|
|
||||||
async logout(): Promise<void> {
|
|
||||||
console.log('Logging out...');
|
|
||||||
|
|
||||||
if (existsSync(this.authPath)) {
|
|
||||||
await unlink(this.authPath);
|
|
||||||
console.log('Removed auth file ' + this.authPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Successfully logged out');
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resolveApiEndpoint(instanceUrl: string): Promise<string> {
|
|
||||||
const wellKnownUrl = new URL('.well-known/immich', instanceUrl);
|
|
||||||
try {
|
|
||||||
const wellKnown = await fetch(wellKnownUrl).then((response) => response.json());
|
|
||||||
const endpoint = new URL(wellKnown.api.endpoint, instanceUrl).toString();
|
|
||||||
if (endpoint !== instanceUrl) {
|
|
||||||
console.debug(`Discovered API at ${endpoint}`);
|
|
||||||
}
|
|
||||||
return endpoint;
|
|
||||||
} catch {
|
|
||||||
return instanceUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,31 @@
|
|||||||
import mockfs from 'mock-fs';
|
import mockfs from 'mock-fs';
|
||||||
import { CrawlOptions, CrawlService } from './crawl.service';
|
import { CrawlOptions, crawl } from 'src/utils';
|
||||||
|
|
||||||
interface Test {
|
interface Test {
|
||||||
test: string;
|
test: string;
|
||||||
options: CrawlOptions;
|
options: Omit<CrawlOptions, 'extensions'>;
|
||||||
files: Record<string, boolean>;
|
files: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
|
|
||||||
|
const extensions = [
|
||||||
|
'.jpg',
|
||||||
|
'.jpeg',
|
||||||
|
'.png',
|
||||||
|
'.heif',
|
||||||
|
'.heic',
|
||||||
|
'.tif',
|
||||||
|
'.nef',
|
||||||
|
'.webp',
|
||||||
|
'.tiff',
|
||||||
|
'.dng',
|
||||||
|
'.gif',
|
||||||
|
'.mov',
|
||||||
|
'.mp4',
|
||||||
|
'.webm',
|
||||||
|
];
|
||||||
|
|
||||||
const tests: Test[] = [
|
const tests: Test[] = [
|
||||||
{
|
{
|
||||||
test: 'should return empty when crawling an empty path list',
|
test: 'should return empty when crawling an empty path list',
|
||||||
@@ -49,7 +66,7 @@ const tests: Test[] = [
|
|||||||
test: 'should exclude by file extension',
|
test: 'should exclude by file extension',
|
||||||
options: {
|
options: {
|
||||||
pathsToCrawl: ['/photos/'],
|
pathsToCrawl: ['/photos/'],
|
||||||
exclusionPatterns: ['**/*.tif'],
|
exclusionPattern: '**/*.tif',
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
'/photos/image.jpg': true,
|
'/photos/image.jpg': true,
|
||||||
@@ -60,7 +77,7 @@ const tests: Test[] = [
|
|||||||
test: 'should exclude by file extension without case sensitivity',
|
test: 'should exclude by file extension without case sensitivity',
|
||||||
options: {
|
options: {
|
||||||
pathsToCrawl: ['/photos/'],
|
pathsToCrawl: ['/photos/'],
|
||||||
exclusionPatterns: ['**/*.TIF'],
|
exclusionPattern: '**/*.TIF',
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
'/photos/image.jpg': true,
|
'/photos/image.jpg': true,
|
||||||
@@ -71,7 +88,7 @@ const tests: Test[] = [
|
|||||||
test: 'should exclude by folder',
|
test: 'should exclude by folder',
|
||||||
options: {
|
options: {
|
||||||
pathsToCrawl: ['/photos/'],
|
pathsToCrawl: ['/photos/'],
|
||||||
exclusionPatterns: ['**/raw/**'],
|
exclusionPattern: '**/raw/**',
|
||||||
recursive: true,
|
recursive: true,
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
@@ -201,7 +218,7 @@ const tests: Test[] = [
|
|||||||
test: 'should support ignoring full filename',
|
test: 'should support ignoring full filename',
|
||||||
options: {
|
options: {
|
||||||
pathsToCrawl: ['/photos'],
|
pathsToCrawl: ['/photos'],
|
||||||
exclusionPatterns: ['**/image2.jpg'],
|
exclusionPattern: '**/image2.jpg',
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
'/photos/image1.jpg': true,
|
'/photos/image1.jpg': true,
|
||||||
@@ -213,7 +230,7 @@ const tests: Test[] = [
|
|||||||
test: 'should support ignoring file extensions',
|
test: 'should support ignoring file extensions',
|
||||||
options: {
|
options: {
|
||||||
pathsToCrawl: ['/photos'],
|
pathsToCrawl: ['/photos'],
|
||||||
exclusionPatterns: ['**/*.png'],
|
exclusionPattern: '**/*.png',
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
'/photos/image1.jpg': true,
|
'/photos/image1.jpg': true,
|
||||||
@@ -226,7 +243,7 @@ const tests: Test[] = [
|
|||||||
options: {
|
options: {
|
||||||
pathsToCrawl: ['/photos'],
|
pathsToCrawl: ['/photos'],
|
||||||
recursive: true,
|
recursive: true,
|
||||||
exclusionPatterns: ['**/raw/**'],
|
exclusionPattern: '**/raw/**',
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
'/photos/image1.jpg': true,
|
'/photos/image1.jpg': true,
|
||||||
@@ -241,7 +258,7 @@ const tests: Test[] = [
|
|||||||
options: {
|
options: {
|
||||||
pathsToCrawl: ['/'],
|
pathsToCrawl: ['/'],
|
||||||
recursive: true,
|
recursive: true,
|
||||||
exclusionPatterns: ['/images/**'],
|
exclusionPattern: '/images/**',
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
'/photos/image1.jpg': true,
|
'/photos/image1.jpg': true,
|
||||||
@@ -251,12 +268,7 @@ const tests: Test[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
describe(CrawlService.name, () => {
|
describe('crawl', () => {
|
||||||
const sut = new CrawlService(
|
|
||||||
['.jpg', '.jpeg', '.png', '.heif', '.heic', '.tif', '.nef', '.webp', '.tiff', '.dng', '.gif'],
|
|
||||||
['.mov', '.mp4', '.webm'],
|
|
||||||
);
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mockfs.restore();
|
mockfs.restore();
|
||||||
});
|
});
|
||||||
@@ -266,7 +278,7 @@ describe(CrawlService.name, () => {
|
|||||||
it(test, async () => {
|
it(test, async () => {
|
||||||
mockfs(Object.fromEntries(Object.keys(files).map((file) => [file, ''])));
|
mockfs(Object.fromEntries(Object.keys(files).map((file) => [file, ''])));
|
||||||
|
|
||||||
const actual = await sut.crawl(options);
|
const actual = await crawl({ ...options, extensions });
|
||||||
const expected = Object.entries(files)
|
const expected = Object.entries(files)
|
||||||
.filter((entry) => entry[1])
|
.filter((entry) => entry[1])
|
||||||
.map(([file]) => file);
|
.map(([file]) => file);
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { defaults, getMyUserInfo, isHttpError } from '@immich/sdk';
|
||||||
|
import { glob } from 'fast-glob';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { readFile, stat, writeFile } from 'node:fs/promises';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import yaml from 'yaml';
|
||||||
|
|
||||||
|
export interface BaseOptions {
|
||||||
|
configDirectory: string;
|
||||||
|
key?: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthDto = { url: string; key: string };
|
||||||
|
type OldAuthDto = { instanceUrl: string; apiKey: string };
|
||||||
|
|
||||||
|
export const authenticate = async (options: BaseOptions): Promise<AuthDto> => {
|
||||||
|
const { configDirectory: configDir, url, key } = options;
|
||||||
|
|
||||||
|
// provided in command
|
||||||
|
if (url && key) {
|
||||||
|
return connect(url, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback to auth file
|
||||||
|
const config = await readAuthFile(configDir);
|
||||||
|
const auth = await connect(config.url, config.key);
|
||||||
|
if (auth.url !== config.url) {
|
||||||
|
await writeAuthFile(configDir, auth);
|
||||||
|
}
|
||||||
|
|
||||||
|
return auth;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const connect = async (url: string, key: string) => {
|
||||||
|
const wellKnownUrl = new URL('.well-known/immich', url);
|
||||||
|
try {
|
||||||
|
const wellKnown = await fetch(wellKnownUrl).then((response) => response.json());
|
||||||
|
const endpoint = new URL(wellKnown.api.endpoint, url).toString();
|
||||||
|
if (endpoint !== url) {
|
||||||
|
console.debug(`Discovered API at ${endpoint}`);
|
||||||
|
}
|
||||||
|
url = endpoint;
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
|
||||||
|
defaults.baseUrl = url;
|
||||||
|
defaults.headers = { 'x-api-key': key };
|
||||||
|
|
||||||
|
const [error] = await withError(getMyUserInfo());
|
||||||
|
if (isHttpError(error)) {
|
||||||
|
logError(error, 'Failed to connect to server');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { url, key };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logError = (error: unknown, message: string) => {
|
||||||
|
if (isHttpError(error)) {
|
||||||
|
console.error(`${message}: ${error.status}`);
|
||||||
|
console.error(JSON.stringify(error.data, undefined, 2));
|
||||||
|
} else {
|
||||||
|
console.error(`${message} - ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAuthFilePath = (dir: string) => join(dir, 'auth.yml');
|
||||||
|
|
||||||
|
export const readAuthFile = async (dir: string) => {
|
||||||
|
try {
|
||||||
|
const data = await readFile(getAuthFilePath(dir));
|
||||||
|
// TODO add class-transform/validation
|
||||||
|
const auth = yaml.parse(data.toString()) as AuthDto | OldAuthDto;
|
||||||
|
const { instanceUrl, apiKey } = auth as OldAuthDto;
|
||||||
|
if (instanceUrl && apiKey) {
|
||||||
|
return { url: instanceUrl, key: apiKey };
|
||||||
|
}
|
||||||
|
return auth as AuthDto;
|
||||||
|
} catch (error: Error | any) {
|
||||||
|
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
||||||
|
console.log('No auth file exists. Please login first.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writeAuthFile = async (dir: string, auth: AuthDto) =>
|
||||||
|
writeFile(getAuthFilePath(dir), yaml.stringify(auth), { mode: 0o600 });
|
||||||
|
|
||||||
|
export const withError = async <T>(promise: Promise<T>): Promise<[Error, undefined] | [undefined, T]> => {
|
||||||
|
try {
|
||||||
|
const result = await promise;
|
||||||
|
return [undefined, result];
|
||||||
|
} catch (error: Error | any) {
|
||||||
|
return [error, undefined];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CrawlOptions {
|
||||||
|
pathsToCrawl: string[];
|
||||||
|
recursive?: boolean;
|
||||||
|
includeHidden?: boolean;
|
||||||
|
exclusionPattern?: string;
|
||||||
|
extensions: string[];
|
||||||
|
}
|
||||||
|
export const crawl = async (options: CrawlOptions): Promise<string[]> => {
|
||||||
|
const { extensions: extensionsWithPeriod, recursive, pathsToCrawl, exclusionPattern, includeHidden } = options;
|
||||||
|
const extensions = extensionsWithPeriod.map((extension) => extension.replace('.', ''));
|
||||||
|
|
||||||
|
if (pathsToCrawl.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const patterns: string[] = [];
|
||||||
|
const crawledFiles: string[] = [];
|
||||||
|
|
||||||
|
for await (const currentPath of pathsToCrawl) {
|
||||||
|
try {
|
||||||
|
const absolutePath = resolve(currentPath);
|
||||||
|
const stats = await stat(absolutePath);
|
||||||
|
if (stats.isFile() || stats.isSymbolicLink()) {
|
||||||
|
crawledFiles.push(absolutePath);
|
||||||
|
} else {
|
||||||
|
patterns.push(absolutePath);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
patterns.push(currentPath);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let searchPattern: string;
|
||||||
|
if (patterns.length === 1) {
|
||||||
|
searchPattern = patterns[0];
|
||||||
|
} else if (patterns.length === 0) {
|
||||||
|
return crawledFiles;
|
||||||
|
} else {
|
||||||
|
searchPattern = '{' + patterns.join(',') + '}';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recursive) {
|
||||||
|
searchPattern = searchPattern + '/**/';
|
||||||
|
}
|
||||||
|
|
||||||
|
searchPattern = `${searchPattern}/*.{${extensions.join(',')}}`;
|
||||||
|
|
||||||
|
const globbedFiles = await glob(searchPattern, {
|
||||||
|
absolute: true,
|
||||||
|
caseSensitiveMatch: false,
|
||||||
|
onlyFiles: true,
|
||||||
|
dot: includeHidden,
|
||||||
|
ignore: [`**/${exclusionPattern}`],
|
||||||
|
});
|
||||||
|
globbedFiles.push(...crawledFiles);
|
||||||
|
return globbedFiles.sort();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sha1 = (filepath: string) => {
|
||||||
|
const hash = createHash('sha1');
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const rs = createReadStream(filepath);
|
||||||
|
rs.on('error', reject);
|
||||||
|
rs.on('data', (chunk) => hash.update(chunk));
|
||||||
|
rs.on('end', () => resolve(hash.digest('hex')));
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -15,19 +15,7 @@
|
|||||||
"incremental": true,
|
"incremental": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"rootDirs": ["src", "../server/src"],
|
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"paths": {
|
|
||||||
"@test": ["../server/test"],
|
|
||||||
"@test/*": ["../server/test/*"],
|
|
||||||
"@test-utils": ["../server/src/test-utils/utils"],
|
|
||||||
"@app/immich": ["../server/src/immich"],
|
|
||||||
"@app/immich/*": ["../server/src/immich/*"],
|
|
||||||
"@app/infra": ["../server/src/infra"],
|
|
||||||
"@app/infra/*": ["../server/src/infra/*"],
|
|
||||||
"@app/domain": ["../server/src/domain"],
|
|
||||||
"@app/domain/*": ["../server/src/domain/*"]
|
|
||||||
},
|
|
||||||
"types": ["vitest/globals"]
|
"types": ["vitest/globals"]
|
||||||
},
|
},
|
||||||
"exclude": ["dist", "node_modules"]
|
"exclude": ["dist", "node_modules"]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
build: {
|
build: {
|
||||||
@@ -14,4 +15,5 @@ export default defineConfig({
|
|||||||
// bundle everything except for Node built-ins
|
// bundle everything except for Node built-ins
|
||||||
noExternal: /^(?!node:).*$/,
|
noExternal: /^(?!node:).*$/,
|
||||||
},
|
},
|
||||||
|
plugins: [tsconfigPaths()],
|
||||||
});
|
});
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 58 KiB |
@@ -0,0 +1,63 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Router_Medium_x5F_Black_00000159464448132936669960000002337362428709113490_"
|
||||||
|
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 792 266.25"
|
||||||
|
style="enable-background:new 0 0 792 266.25;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#ACCBFA;}
|
||||||
|
.st1{fill:#FA2921;}
|
||||||
|
.st2{fill:#ED79B5;}
|
||||||
|
.st3{fill:#FFB400;}
|
||||||
|
.st4{fill:#1E83F7;}
|
||||||
|
.st5{fill:#18C249;}
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M268.73,63.18c6.34,0,11.52,5.18,11.52,11.35c0,6.34-5.18,11.35-11.52,11.35s-11.69-5.01-11.69-11.35
|
||||||
|
C257.04,68.36,262.39,63.18,268.73,63.18z M258.88,122.45c0-3.01-0.67-7.85-0.67-10.68c0-6.01,4.67-10.68,10.52-10.68
|
||||||
|
c5.84,0,10.52,4.67,10.52,10.68c0,2.84-0.83,7.68-0.83,10.68v38.73c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.67,10.68-10.52,10.68
|
||||||
|
c-5.84,0-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68V122.45z"/>
|
||||||
|
<path class="st0" d="M394.28,171.87c0-2.84,0.83-7.68,0.83-10.68V132.3c0-10.18-5.34-16.86-14.52-16.86c-6.01,0-11.35,3-14.86,8.85
|
||||||
|
c0.33,1.84,0.5,3.67,0.5,5.68v31.22c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.67,10.68-10.68,10.68c-5.51,0-10.35-4.67-10.35-10.68
|
||||||
|
c0-2.84,0.83-7.68,0.83-10.68V131.8c0-3.17-0.5-6.01-1.67-8.51c-2.17-4.84-6.51-7.85-12.52-7.85c-6.18,0-11.19,3.17-14.86,8.85
|
||||||
|
v36.9c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.84,10.68-10.52,10.68c-5.84,0-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68
|
||||||
|
v-38.57c0-3.01-1.5-8.35-1.5-10.85c0-6.01,4.34-10.68,10.18-10.68c5.51,0,8.68,3.67,9.68,8.51c5.01-6.68,12.02-10.85,21.2-10.85
|
||||||
|
c10.85,0,18.7,5.18,23.54,13.52c5.51-8.68,13.52-13.52,23.54-13.52c16.86,0,29.72,12.19,29.72,31.72v30.72
|
||||||
|
c0,3.01,0.67,7.85,0.67,10.68c0,6.01-4.51,10.68-10.52,10.68C399.12,182.55,394.28,177.88,394.28,171.87z"/>
|
||||||
|
<path class="st0" d="M528.5,171.87c0-2.84,0.83-7.68,0.83-10.68V132.3c0-10.18-5.34-16.86-14.52-16.86c-6.01,0-11.35,3-14.86,8.85
|
||||||
|
c0.33,1.84,0.5,3.67,0.5,5.68v31.22c0,3.01,0.84,7.85,0.84,10.68c0,6.01-4.67,10.68-10.68,10.68c-5.51,0-10.35-4.67-10.35-10.68
|
||||||
|
c0-2.84,0.84-7.68,0.84-10.68V131.8c0-3.17-0.5-6.01-1.67-8.51c-2.17-4.84-6.51-7.85-12.52-7.85c-6.18,0-11.19,3.17-14.86,8.85
|
||||||
|
v36.9c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.84,10.68-10.52,10.68c-5.84,0-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68
|
||||||
|
v-38.57c0-3.01-1.5-8.35-1.5-10.85c0-6.01,4.34-10.68,10.18-10.68c5.51,0,8.68,3.67,9.68,8.51c5.01-6.68,12.02-10.85,21.2-10.85
|
||||||
|
c10.85,0,18.7,5.18,23.54,13.52c5.51-8.68,13.52-13.52,23.54-13.52c16.86,0,29.72,12.19,29.72,31.72v30.72
|
||||||
|
c0,3.01,0.67,7.85,0.67,10.68c0,6.01-4.51,10.68-10.52,10.68C533.35,182.55,528.5,177.88,528.5,171.87z"/>
|
||||||
|
<path class="st0" d="M576.92,63.18c6.34,0,11.52,5.18,11.52,11.35c0,6.34-5.18,11.35-11.52,11.35s-11.69-5.01-11.69-11.35
|
||||||
|
C565.23,68.36,570.57,63.18,576.92,63.18z M567.07,122.45c0-3.01-0.67-7.85-0.67-10.68c0-6.01,4.67-10.68,10.52-10.68
|
||||||
|
s10.52,4.67,10.52,10.68c0,2.84-0.84,7.68-0.84,10.68v38.73c0,3.01,0.84,7.85,0.84,10.68c0,6.01-4.67,10.68-10.52,10.68
|
||||||
|
s-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68V122.45z"/>
|
||||||
|
<path class="st0" d="M601.79,141.31c0-23.54,14.69-42.57,39.07-42.57c12.86,0,24.71,5.84,30.05,14.53c2,3.17,2.34,5.01,2.34,6.51
|
||||||
|
c0,5.18-4.01,9.52-9.85,9.52c-3.84,0-7.34-2.17-8.85-6.01c-2.34-5.18-6.85-8.18-13.69-8.18c-12.86,0-20.03,11.52-20.03,26.04
|
||||||
|
c0,14.69,7.51,26.04,20.53,26.04c7.01,0,12.02-2.5,14.36-7.68c1.67-3.51,4.84-6.51,9.18-6.51c6.01,0,9.68,4.17,9.68,9.35
|
||||||
|
c0,2.5-1,5.51-3.17,8.35c-5.51,7.35-15.86,13.19-30.05,13.19C616.32,183.89,601.79,165.19,601.79,141.31z"/>
|
||||||
|
<path class="st0" d="M737.69,171.87c0-2.84,0.67-7.68,0.67-10.68v-28.55c0-10.18-5.68-17.2-15.36-17.2
|
||||||
|
c-6.68,0-12.35,3.17-16.03,8.35v37.4c0,3.01,0.67,7.85,0.67,10.68c0,6.01-4.67,10.68-10.52,10.68s-10.52-4.67-10.52-10.68
|
||||||
|
c0-2.84,0.84-7.68,0.84-10.68v-80.8c0-3.01-0.84-7.85-0.84-10.68c0-6.01,4.84-10.68,10.52-10.68c5.84,0,10.52,4.67,10.52,10.68
|
||||||
|
c0,2.84-0.67,7.68-0.67,10.68v27.21c5.01-5.51,12.19-8.85,21.37-8.85c17.2,0,29.55,12.86,29.55,31.22v31.22
|
||||||
|
c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.84,10.68-10.52,10.68C742.36,182.55,737.69,177.88,737.69,171.87z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st1" d="M114.82,96.21c11.92,10.55,21.52,21.86,27.7,32.52c10.62-18.99,17.71-41.55,17.8-55.92c0-0.1,0-0.19,0-0.28
|
||||||
|
c0-21.26-21.21-29.54-39.48-29.54s-39.48,8.28-39.48,29.54c0,0.29,0,0.68,0,1.15C91.54,78.2,103.61,86.29,114.82,96.21z"/>
|
||||||
|
<path class="st2" d="M49.8,154.19c7.45-8.29,18.88-17.27,31.77-24.86c13.72-8.07,27.44-13.71,39.49-16.3
|
||||||
|
c-14.78-15.96-34.04-29.68-47.68-34.21c-0.1-0.03-0.18-0.06-0.27-0.09c-20.22-6.57-34.65,11.05-40.3,28.42s-4.33,40.11,15.89,46.68
|
||||||
|
C48.99,153.93,49.35,154.05,49.8,154.19z"/>
|
||||||
|
<path class="st3" d="M209.07,106.86c-5.65-17.38-20.07-34.99-40.3-28.42c-0.28,0.09-0.65,0.21-1.09,0.35
|
||||||
|
c-1.16,11.08-5.12,25.07-11.09,38.79c-6.35,14.6-14.14,27.23-22.36,36.39c21.34,4.23,44.99,4,58.68-0.35
|
||||||
|
c0.1-0.03,0.19-0.06,0.27-0.09C213.4,146.97,214.71,124.24,209.07,106.86z"/>
|
||||||
|
<path class="st4" d="M102.8,171.18c-3.44-15.54-4.56-30.34-3.3-42.59c-19.75,9.12-38.75,23.2-47.27,34.78
|
||||||
|
c-0.06,0.08-0.11,0.16-0.16,0.23c-12.5,17.2-0.2,36.37,14.58,47.11s36.81,16.51,49.31-0.69c0.17-0.24,0.4-0.55,0.68-0.93
|
||||||
|
C111.05,199.44,106.04,185.79,102.8,171.18z"/>
|
||||||
|
<path class="st5" d="M189.48,162.49c-10.9,2.33-25.42,2.88-40.32,1.44c-15.84-1.53-30.26-5.03-41.52-10.02
|
||||||
|
c2.57,21.6,10.09,44.02,18.47,55.7c0.06,0.08,0.11,0.16,0.16,0.23c12.5,17.2,34.52,11.43,49.31,0.69
|
||||||
|
c14.78-10.74,27.08-29.9,14.58-47.11C189.99,163.18,189.76,162.86,189.48,162.49z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 57 KiB |
@@ -0,0 +1,62 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Router_Medium_x5F_White" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
x="0px" y="0px" viewBox="0 0 792 266.25" style="enable-background:new 0 0 792 266.25;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#4251B0;}
|
||||||
|
.st1{fill:#FA2921;}
|
||||||
|
.st2{fill:#ED79B5;}
|
||||||
|
.st3{fill:#FFB400;}
|
||||||
|
.st4{fill:#1E83F7;}
|
||||||
|
.st5{fill:#18C249;}
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M268.73,63.18c6.34,0,11.52,5.18,11.52,11.35c0,6.34-5.18,11.35-11.52,11.35s-11.69-5.01-11.69-11.35
|
||||||
|
C257.04,68.36,262.39,63.18,268.73,63.18z M258.88,122.45c0-3.01-0.67-7.85-0.67-10.68c0-6.01,4.67-10.68,10.52-10.68
|
||||||
|
c5.84,0,10.52,4.67,10.52,10.68c0,2.84-0.83,7.68-0.83,10.68v38.73c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.67,10.68-10.52,10.68
|
||||||
|
c-5.84,0-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68V122.45z"/>
|
||||||
|
<path class="st0" d="M394.28,171.87c0-2.84,0.83-7.68,0.83-10.68V132.3c0-10.18-5.34-16.86-14.52-16.86c-6.01,0-11.35,3-14.86,8.85
|
||||||
|
c0.33,1.84,0.5,3.67,0.5,5.68v31.22c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.67,10.68-10.68,10.68c-5.51,0-10.35-4.67-10.35-10.68
|
||||||
|
c0-2.84,0.83-7.68,0.83-10.68V131.8c0-3.17-0.5-6.01-1.67-8.51c-2.17-4.84-6.51-7.85-12.52-7.85c-6.18,0-11.19,3.17-14.86,8.85
|
||||||
|
v36.9c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.84,10.68-10.52,10.68c-5.84,0-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68
|
||||||
|
v-38.57c0-3.01-1.5-8.35-1.5-10.85c0-6.01,4.34-10.68,10.18-10.68c5.51,0,8.68,3.67,9.68,8.51c5.01-6.68,12.02-10.85,21.2-10.85
|
||||||
|
c10.85,0,18.7,5.18,23.54,13.52c5.51-8.68,13.52-13.52,23.54-13.52c16.86,0,29.72,12.19,29.72,31.72v30.72
|
||||||
|
c0,3.01,0.67,7.85,0.67,10.68c0,6.01-4.51,10.68-10.52,10.68C399.12,182.55,394.28,177.88,394.28,171.87z"/>
|
||||||
|
<path class="st0" d="M528.5,171.87c0-2.84,0.83-7.68,0.83-10.68V132.3c0-10.18-5.34-16.86-14.52-16.86c-6.01,0-11.35,3-14.86,8.85
|
||||||
|
c0.33,1.84,0.5,3.67,0.5,5.68v31.22c0,3.01,0.84,7.85,0.84,10.68c0,6.01-4.67,10.68-10.68,10.68c-5.51,0-10.35-4.67-10.35-10.68
|
||||||
|
c0-2.84,0.84-7.68,0.84-10.68V131.8c0-3.17-0.5-6.01-1.67-8.51c-2.17-4.84-6.51-7.85-12.52-7.85c-6.18,0-11.19,3.17-14.86,8.85
|
||||||
|
v36.9c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.84,10.68-10.52,10.68c-5.84,0-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68
|
||||||
|
v-38.57c0-3.01-1.5-8.35-1.5-10.85c0-6.01,4.34-10.68,10.18-10.68c5.51,0,8.68,3.67,9.68,8.51c5.01-6.68,12.02-10.85,21.2-10.85
|
||||||
|
c10.85,0,18.7,5.18,23.54,13.52c5.51-8.68,13.52-13.52,23.54-13.52c16.86,0,29.72,12.19,29.72,31.72v30.72
|
||||||
|
c0,3.01,0.67,7.85,0.67,10.68c0,6.01-4.51,10.68-10.52,10.68C533.35,182.55,528.5,177.88,528.5,171.87z"/>
|
||||||
|
<path class="st0" d="M576.92,63.18c6.34,0,11.52,5.18,11.52,11.35c0,6.34-5.18,11.35-11.52,11.35s-11.69-5.01-11.69-11.35
|
||||||
|
C565.23,68.36,570.57,63.18,576.92,63.18z M567.07,122.45c0-3.01-0.67-7.85-0.67-10.68c0-6.01,4.67-10.68,10.52-10.68
|
||||||
|
s10.52,4.67,10.52,10.68c0,2.84-0.84,7.68-0.84,10.68v38.73c0,3.01,0.84,7.85,0.84,10.68c0,6.01-4.67,10.68-10.52,10.68
|
||||||
|
s-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68V122.45z"/>
|
||||||
|
<path class="st0" d="M601.79,141.31c0-23.54,14.69-42.57,39.07-42.57c12.86,0,24.71,5.84,30.05,14.53c2,3.17,2.34,5.01,2.34,6.51
|
||||||
|
c0,5.18-4.01,9.52-9.85,9.52c-3.84,0-7.34-2.17-8.85-6.01c-2.34-5.18-6.85-8.18-13.69-8.18c-12.86,0-20.03,11.52-20.03,26.04
|
||||||
|
c0,14.69,7.51,26.04,20.53,26.04c7.01,0,12.02-2.5,14.36-7.68c1.67-3.51,4.84-6.51,9.18-6.51c6.01,0,9.68,4.17,9.68,9.35
|
||||||
|
c0,2.5-1,5.51-3.17,8.35c-5.51,7.35-15.86,13.19-30.05,13.19C616.32,183.89,601.79,165.19,601.79,141.31z"/>
|
||||||
|
<path class="st0" d="M737.69,171.87c0-2.84,0.67-7.68,0.67-10.68v-28.55c0-10.18-5.68-17.2-15.36-17.2
|
||||||
|
c-6.68,0-12.35,3.17-16.03,8.35v37.4c0,3.01,0.67,7.85,0.67,10.68c0,6.01-4.67,10.68-10.52,10.68s-10.52-4.67-10.52-10.68
|
||||||
|
c0-2.84,0.84-7.68,0.84-10.68v-80.8c0-3.01-0.84-7.85-0.84-10.68c0-6.01,4.84-10.68,10.52-10.68c5.84,0,10.52,4.67,10.52,10.68
|
||||||
|
c0,2.84-0.67,7.68-0.67,10.68v27.21c5.01-5.51,12.19-8.85,21.37-8.85c17.2,0,29.55,12.86,29.55,31.22v31.22
|
||||||
|
c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.84,10.68-10.52,10.68C742.36,182.55,737.69,177.88,737.69,171.87z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st1" d="M114.82,96.21c11.92,10.55,21.52,21.86,27.7,32.52c10.62-18.99,17.71-41.55,17.8-55.92c0-0.1,0-0.19,0-0.28
|
||||||
|
c0-21.26-21.21-29.54-39.48-29.54s-39.48,8.28-39.48,29.54c0,0.29,0,0.68,0,1.15C91.54,78.2,103.61,86.29,114.82,96.21z"/>
|
||||||
|
<path class="st2" d="M49.8,154.19c7.45-8.29,18.88-17.27,31.77-24.86c13.72-8.07,27.44-13.71,39.49-16.3
|
||||||
|
c-14.78-15.96-34.04-29.68-47.68-34.21c-0.1-0.03-0.18-0.06-0.27-0.09c-20.22-6.57-34.65,11.05-40.3,28.42s-4.33,40.11,15.89,46.68
|
||||||
|
C48.99,153.93,49.35,154.05,49.8,154.19z"/>
|
||||||
|
<path class="st3" d="M209.07,106.86c-5.65-17.38-20.07-34.99-40.3-28.42c-0.28,0.09-0.65,0.21-1.09,0.35
|
||||||
|
c-1.16,11.08-5.12,25.07-11.09,38.79c-6.35,14.6-14.14,27.23-22.36,36.39c21.34,4.23,44.99,4,58.68-0.35
|
||||||
|
c0.1-0.03,0.19-0.06,0.27-0.09C213.4,146.97,214.71,124.24,209.07,106.86z"/>
|
||||||
|
<path class="st4" d="M102.8,171.18c-3.44-15.54-4.56-30.34-3.3-42.59c-19.75,9.12-38.75,23.2-47.27,34.78
|
||||||
|
c-0.06,0.08-0.11,0.16-0.16,0.23c-12.5,17.2-0.2,36.37,14.58,47.11s36.81,16.51,49.31-0.69c0.17-0.24,0.4-0.55,0.68-0.93
|
||||||
|
C111.05,199.44,106.04,185.79,102.8,171.18z"/>
|
||||||
|
<path class="st5" d="M189.48,162.49c-10.9,2.33-25.42,2.88-40.32,1.44c-15.84-1.53-30.26-5.03-41.52-10.02
|
||||||
|
c2.57,21.6,10.09,44.02,18.47,55.7c0.06,0.08,0.11,0.16,0.16,0.23c12.5,17.2,34.52,11.43,49.31,0.69
|
||||||
|
c14.78-10.74,27.08-29.9,14.58-47.11C189.99,163.18,189.76,162.86,189.48,162.49z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 110 KiB |
@@ -0,0 +1,66 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Router_Medium_x5F_Black_00000037681990313894948460000012967653829507626171_"
|
||||||
|
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 792 792"
|
||||||
|
style="enable-background:new 0 0 792 792;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#ACCBFA;}
|
||||||
|
.st1{fill:#FA2921;}
|
||||||
|
.st2{fill:#ED79B5;}
|
||||||
|
.st3{fill:#FFB400;}
|
||||||
|
.st4{fill:#1E83F7;}
|
||||||
|
.st5{fill:#18C249;}
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M110.16,537.4c7.85,0,14.25,6.4,14.25,14.04c0,7.85-6.4,14.04-14.25,14.04s-14.45-6.19-14.45-14.04
|
||||||
|
C95.71,543.8,102.32,537.4,110.16,537.4z M97.98,610.7c0-3.72-0.83-9.71-0.83-13.22c0-7.43,5.78-13.22,13.01-13.22
|
||||||
|
s13.01,5.78,13.01,13.22c0,3.51-1.03,9.5-1.03,13.22v47.9c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.78,13.22-13.01,13.22
|
||||||
|
s-13.01-5.78-13.01-13.22c0-3.51,0.83-9.5,0.83-13.22V610.7z"/>
|
||||||
|
<path class="st0" d="M265.44,671.82c0-3.51,1.03-9.5,1.03-13.22v-35.72c0-12.6-6.61-20.85-17.96-20.85
|
||||||
|
c-7.43,0-14.04,3.72-18.38,10.94c0.41,2.27,0.62,4.54,0.62,7.02v38.61c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.78,13.22-13.22,13.22
|
||||||
|
c-6.81,0-12.8-5.78-12.8-13.22c0-3.51,1.03-9.5,1.03-13.22v-36.34c0-3.92-0.62-7.43-2.06-10.53c-2.69-5.99-8.05-9.71-15.49-9.71
|
||||||
|
c-7.64,0-13.83,3.92-18.38,10.94v45.63c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.99,13.22-13.01,13.22c-7.23,0-13.01-5.78-13.01-13.22
|
||||||
|
c0-3.51,0.83-9.5,0.83-13.22v-47.7c0-3.72-1.86-10.32-1.86-13.42c0-7.43,5.37-13.22,12.6-13.22c6.81,0,10.74,4.54,11.98,10.53
|
||||||
|
c6.19-8.26,14.87-13.42,26.22-13.42c13.42,0,23.13,6.4,29.11,16.73c6.81-10.74,16.73-16.73,29.11-16.73
|
||||||
|
c20.86,0,36.75,15.07,36.75,39.23v37.99c0,3.72,0.83,9.71,0.83,13.22c0,7.43-5.57,13.22-13.01,13.22
|
||||||
|
C271.43,685.04,265.44,679.26,265.44,671.82z"/>
|
||||||
|
<path class="st0" d="M431.45,671.82c0-3.51,1.03-9.5,1.03-13.22v-35.72c0-12.6-6.61-20.85-17.96-20.85
|
||||||
|
c-7.43,0-14.04,3.72-18.38,10.94c0.41,2.27,0.62,4.54,0.62,7.02v38.61c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.78,13.22-13.22,13.22
|
||||||
|
c-6.82,0-12.8-5.78-12.8-13.22c0-3.51,1.03-9.5,1.03-13.22v-36.34c0-3.92-0.62-7.43-2.06-10.53c-2.68-5.99-8.05-9.71-15.49-9.71
|
||||||
|
c-7.64,0-13.83,3.92-18.38,10.94v45.63c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.99,13.22-13.01,13.22c-7.23,0-13.01-5.78-13.01-13.22
|
||||||
|
c0-3.51,0.83-9.5,0.83-13.22v-47.7c0-3.72-1.86-10.32-1.86-13.42c0-7.43,5.37-13.22,12.6-13.22c6.82,0,10.74,4.54,11.98,10.53
|
||||||
|
c6.2-8.26,14.87-13.42,26.22-13.42c13.42,0,23.13,6.4,29.11,16.73c6.81-10.74,16.72-16.73,29.11-16.73
|
||||||
|
c20.86,0,36.75,15.07,36.75,39.23v37.99c0,3.72,0.83,9.71,0.83,13.22c0,7.43-5.57,13.22-13.01,13.22
|
||||||
|
C437.44,685.04,431.45,679.26,431.45,671.82z"/>
|
||||||
|
<path class="st0" d="M491.33,537.4c7.85,0,14.25,6.4,14.25,14.04c0,7.85-6.4,14.04-14.25,14.04s-14.45-6.19-14.45-14.04
|
||||||
|
C476.87,543.8,483.48,537.4,491.33,537.4z M479.15,610.7c0-3.72-0.83-9.71-0.83-13.22c0-7.43,5.78-13.22,13.01-13.22
|
||||||
|
s13.01,5.78,13.01,13.22c0,3.51-1.03,9.5-1.03,13.22v47.9c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.78,13.22-13.01,13.22
|
||||||
|
s-13.01-5.78-13.01-13.22c0-3.51,0.83-9.5,0.83-13.22V610.7z"/>
|
||||||
|
<path class="st0" d="M522.09,634.04c0-29.11,18.17-52.65,48.32-52.65c15.9,0,30.56,7.23,37.17,17.97c2.48,3.92,2.89,6.19,2.89,8.05
|
||||||
|
c0,6.4-4.96,11.77-12.18,11.77c-4.75,0-9.08-2.68-10.94-7.43c-2.89-6.4-8.47-10.12-16.93-10.12c-15.9,0-24.78,14.25-24.78,32.21
|
||||||
|
c0,18.17,9.29,32.21,25.4,32.21c8.67,0,14.87-3.1,17.76-9.5c2.06-4.34,5.99-8.05,11.36-8.05c7.43,0,11.98,5.16,11.98,11.56
|
||||||
|
c0,3.1-1.24,6.81-3.92,10.32c-6.82,9.09-19.62,16.31-37.17,16.31C540.06,686.69,522.09,663.56,522.09,634.04z"/>
|
||||||
|
<path class="st0" d="M690.17,671.82c0-3.51,0.83-9.5,0.83-13.22v-35.3c0-12.6-7.02-21.27-19-21.27c-8.26,0-15.28,3.92-19.82,10.32
|
||||||
|
v46.25c0,3.72,0.83,9.71,0.83,13.22c0,7.43-5.78,13.22-13.01,13.22s-13.01-5.78-13.01-13.22c0-3.51,1.03-9.5,1.03-13.22v-99.94
|
||||||
|
c0-3.72-1.03-9.71-1.03-13.22c0-7.43,5.99-13.22,13.01-13.22c7.23,0,13.01,5.78,13.01,13.22c0,3.51-0.83,9.5-0.83,13.22v33.66
|
||||||
|
c6.2-6.81,15.07-10.94,26.43-10.94c21.27,0,36.55,15.9,36.55,38.61v38.61c0,3.72,1.03,9.71,1.03,13.22
|
||||||
|
c0,7.43-5.99,13.22-13.01,13.22C695.95,685.04,690.17,679.26,690.17,671.82z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st1" d="M376.76,216.42c28.32,25.07,51.15,51.95,65.83,77.27c25.23-45.12,42.08-98.73,42.3-132.88
|
||||||
|
c0-0.24,0-0.46,0-0.66c0-50.53-50.41-70.2-93.82-70.2s-93.82,19.66-93.82,70.2c0,0.69,0,1.62,0,2.73
|
||||||
|
C321.44,173.62,350.14,192.84,376.76,216.42z"/>
|
||||||
|
<path class="st2" d="M222.27,354.21c17.7-19.69,44.85-41.04,75.5-59.08c32.6-19.19,65.21-32.59,93.83-38.73
|
||||||
|
c-35.11-37.94-80.89-70.53-113.31-81.29c-0.23-0.07-0.44-0.14-0.63-0.21c-48.06-15.61-82.34,26.25-95.75,67.54
|
||||||
|
c-13.42,41.29-10.29,95.31,37.77,110.92C220.33,353.58,221.21,353.86,222.27,354.21z"/>
|
||||||
|
<path class="st3" d="M600.73,241.74c-13.42-41.29-47.69-83.15-95.75-67.54c-0.66,0.21-1.54,0.5-2.6,0.84
|
||||||
|
c-2.75,26.34-12.16,59.57-26.36,92.17c-15.09,34.68-33.6,64.69-53.14,86.48c50.7,10.05,106.9,9.52,139.45-0.83
|
||||||
|
c0.23-0.07,0.44-0.14,0.63-0.21C611.02,337.05,614.15,283.03,600.73,241.74z"/>
|
||||||
|
<path class="st4" d="M348.22,394.58c-8.17-36.93-10.84-72.09-7.84-101.2c-46.93,21.67-92.08,55.14-112.33,82.64
|
||||||
|
c-0.14,0.19-0.27,0.37-0.39,0.54c-29.7,40.88-0.48,86.42,34.64,111.94s87.46,39.24,117.16-1.64c0.41-0.56,0.95-1.31,1.6-2.21
|
||||||
|
C367.81,461.72,355.9,429.3,348.22,394.58z"/>
|
||||||
|
<path class="st5" d="M554.19,373.91c-25.9,5.53-60.41,6.84-95.81,3.42c-37.65-3.64-71.91-11.96-98.67-23.82
|
||||||
|
c6.11,51.33,23.99,104.61,43.89,132.37c0.14,0.19,0.27,0.37,0.39,0.54c29.7,40.88,82.04,27.16,117.16,1.64S585.5,417,555.8,376.12
|
||||||
|
C555.39,375.56,554.85,374.81,554.19,373.91z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 110 KiB |
@@ -0,0 +1,66 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Router_Medium_x5F_White_00000062189486027058041470000012691761407447023025_"
|
||||||
|
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 792 792"
|
||||||
|
style="enable-background:new 0 0 792 792;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#4251B0;}
|
||||||
|
.st1{fill:#FA2921;}
|
||||||
|
.st2{fill:#ED79B5;}
|
||||||
|
.st3{fill:#FFB400;}
|
||||||
|
.st4{fill:#1E83F7;}
|
||||||
|
.st5{fill:#18C249;}
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M110.16,537.4c7.85,0,14.25,6.4,14.25,14.04c0,7.85-6.4,14.04-14.25,14.04s-14.45-6.19-14.45-14.04
|
||||||
|
C95.71,543.8,102.32,537.4,110.16,537.4z M97.98,610.7c0-3.72-0.83-9.71-0.83-13.22c0-7.43,5.78-13.22,13.01-13.22
|
||||||
|
s13.01,5.78,13.01,13.22c0,3.51-1.03,9.5-1.03,13.22v47.9c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.78,13.22-13.01,13.22
|
||||||
|
s-13.01-5.78-13.01-13.22c0-3.51,0.83-9.5,0.83-13.22V610.7z"/>
|
||||||
|
<path class="st0" d="M265.44,671.82c0-3.51,1.03-9.5,1.03-13.22v-35.72c0-12.6-6.61-20.85-17.96-20.85
|
||||||
|
c-7.43,0-14.04,3.72-18.38,10.94c0.41,2.27,0.62,4.54,0.62,7.02v38.61c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.78,13.22-13.22,13.22
|
||||||
|
c-6.81,0-12.8-5.78-12.8-13.22c0-3.51,1.03-9.5,1.03-13.22v-36.34c0-3.92-0.62-7.43-2.06-10.53c-2.69-5.99-8.05-9.71-15.49-9.71
|
||||||
|
c-7.64,0-13.83,3.92-18.38,10.94v45.63c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.99,13.22-13.01,13.22c-7.23,0-13.01-5.78-13.01-13.22
|
||||||
|
c0-3.51,0.83-9.5,0.83-13.22v-47.7c0-3.72-1.86-10.32-1.86-13.42c0-7.43,5.37-13.22,12.6-13.22c6.81,0,10.74,4.54,11.98,10.53
|
||||||
|
c6.19-8.26,14.87-13.42,26.22-13.42c13.42,0,23.13,6.4,29.11,16.73c6.81-10.74,16.73-16.73,29.11-16.73
|
||||||
|
c20.86,0,36.75,15.07,36.75,39.23v37.99c0,3.72,0.83,9.71,0.83,13.22c0,7.43-5.57,13.22-13.01,13.22
|
||||||
|
C271.43,685.04,265.44,679.26,265.44,671.82z"/>
|
||||||
|
<path class="st0" d="M431.45,671.82c0-3.51,1.03-9.5,1.03-13.22v-35.72c0-12.6-6.61-20.85-17.96-20.85
|
||||||
|
c-7.43,0-14.04,3.72-18.38,10.94c0.41,2.27,0.62,4.54,0.62,7.02v38.61c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.78,13.22-13.22,13.22
|
||||||
|
c-6.82,0-12.8-5.78-12.8-13.22c0-3.51,1.03-9.5,1.03-13.22v-36.34c0-3.92-0.62-7.43-2.06-10.53c-2.68-5.99-8.05-9.71-15.49-9.71
|
||||||
|
c-7.64,0-13.83,3.92-18.38,10.94v45.63c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.99,13.22-13.01,13.22c-7.23,0-13.01-5.78-13.01-13.22
|
||||||
|
c0-3.51,0.83-9.5,0.83-13.22v-47.7c0-3.72-1.86-10.32-1.86-13.42c0-7.43,5.37-13.22,12.6-13.22c6.82,0,10.74,4.54,11.98,10.53
|
||||||
|
c6.2-8.26,14.87-13.42,26.22-13.42c13.42,0,23.13,6.4,29.11,16.73c6.81-10.74,16.72-16.73,29.11-16.73
|
||||||
|
c20.86,0,36.75,15.07,36.75,39.23v37.99c0,3.72,0.83,9.71,0.83,13.22c0,7.43-5.57,13.22-13.01,13.22
|
||||||
|
C437.44,685.04,431.45,679.26,431.45,671.82z"/>
|
||||||
|
<path class="st0" d="M491.33,537.4c7.85,0,14.25,6.4,14.25,14.04c0,7.85-6.4,14.04-14.25,14.04s-14.45-6.19-14.45-14.04
|
||||||
|
C476.87,543.8,483.48,537.4,491.33,537.4z M479.15,610.7c0-3.72-0.83-9.71-0.83-13.22c0-7.43,5.78-13.22,13.01-13.22
|
||||||
|
s13.01,5.78,13.01,13.22c0,3.51-1.03,9.5-1.03,13.22v47.9c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.78,13.22-13.01,13.22
|
||||||
|
s-13.01-5.78-13.01-13.22c0-3.51,0.83-9.5,0.83-13.22V610.7z"/>
|
||||||
|
<path class="st0" d="M522.09,634.04c0-29.11,18.17-52.65,48.32-52.65c15.9,0,30.56,7.23,37.17,17.97c2.48,3.92,2.89,6.19,2.89,8.05
|
||||||
|
c0,6.4-4.96,11.77-12.18,11.77c-4.75,0-9.08-2.68-10.94-7.43c-2.89-6.4-8.47-10.12-16.93-10.12c-15.9,0-24.78,14.25-24.78,32.21
|
||||||
|
c0,18.17,9.29,32.21,25.4,32.21c8.67,0,14.87-3.1,17.76-9.5c2.06-4.34,5.99-8.05,11.36-8.05c7.43,0,11.98,5.16,11.98,11.56
|
||||||
|
c0,3.1-1.24,6.81-3.92,10.32c-6.82,9.09-19.62,16.31-37.17,16.31C540.06,686.69,522.09,663.56,522.09,634.04z"/>
|
||||||
|
<path class="st0" d="M690.17,671.82c0-3.51,0.83-9.5,0.83-13.22v-35.3c0-12.6-7.02-21.27-19-21.27c-8.26,0-15.28,3.92-19.82,10.32
|
||||||
|
v46.25c0,3.72,0.83,9.71,0.83,13.22c0,7.43-5.78,13.22-13.01,13.22s-13.01-5.78-13.01-13.22c0-3.51,1.03-9.5,1.03-13.22v-99.94
|
||||||
|
c0-3.72-1.03-9.71-1.03-13.22c0-7.43,5.99-13.22,13.01-13.22c7.23,0,13.01,5.78,13.01,13.22c0,3.51-0.83,9.5-0.83,13.22v33.66
|
||||||
|
c6.2-6.81,15.07-10.94,26.43-10.94c21.27,0,36.55,15.9,36.55,38.61v38.61c0,3.72,1.03,9.71,1.03,13.22
|
||||||
|
c0,7.43-5.99,13.22-13.01,13.22C695.95,685.04,690.17,679.26,690.17,671.82z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st1" d="M376.76,216.42c28.32,25.07,51.15,51.95,65.83,77.27c25.23-45.12,42.08-98.73,42.3-132.88
|
||||||
|
c0-0.24,0-0.46,0-0.66c0-50.53-50.41-70.2-93.82-70.2s-93.82,19.66-93.82,70.2c0,0.69,0,1.62,0,2.73
|
||||||
|
C321.44,173.62,350.14,192.84,376.76,216.42z"/>
|
||||||
|
<path class="st2" d="M222.27,354.21c17.7-19.69,44.85-41.04,75.5-59.08c32.6-19.19,65.21-32.59,93.83-38.73
|
||||||
|
c-35.11-37.94-80.89-70.53-113.31-81.29c-0.23-0.07-0.44-0.14-0.63-0.21c-48.06-15.61-82.34,26.25-95.75,67.54
|
||||||
|
c-13.42,41.29-10.29,95.31,37.77,110.92C220.33,353.58,221.21,353.86,222.27,354.21z"/>
|
||||||
|
<path class="st3" d="M600.73,241.74c-13.42-41.29-47.69-83.15-95.75-67.54c-0.66,0.21-1.54,0.5-2.6,0.84
|
||||||
|
c-2.75,26.34-12.16,59.57-26.36,92.17c-15.09,34.68-33.6,64.69-53.14,86.48c50.7,10.05,106.9,9.52,139.45-0.83
|
||||||
|
c0.23-0.07,0.44-0.14,0.63-0.21C611.02,337.05,614.15,283.03,600.73,241.74z"/>
|
||||||
|
<path class="st4" d="M348.22,394.58c-8.17-36.93-10.84-72.09-7.84-101.2c-46.93,21.67-92.08,55.14-112.33,82.64
|
||||||
|
c-0.14,0.19-0.27,0.37-0.39,0.54c-29.7,40.88-0.48,86.42,34.64,111.94s87.46,39.24,117.16-1.64c0.41-0.56,0.95-1.31,1.6-2.21
|
||||||
|
C367.81,461.72,355.9,429.3,348.22,394.58z"/>
|
||||||
|
<path class="st5" d="M554.19,373.91c-25.9,5.53-60.41,6.84-95.81,3.42c-37.65-3.64-71.91-11.96-98.67-23.82
|
||||||
|
c6.11,51.33,23.99,104.61,43.89,132.37c0.14,0.19,0.27,0.37,0.39,0.54c29.7,40.88,82.04,27.16,117.16,1.64S585.5,417,555.8,376.12
|
||||||
|
C555.39,375.56,554.85,374.81,554.19,373.91z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 99 KiB |
@@ -1,98 +1,29 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
<svg version="1.1" id="svg2781" xmlns:svg="http://www.w3.org/2000/svg"
|
<svg version="1.1" id="Flower" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 564.2 553.5"
|
viewBox="0 0 792 792" style="enable-background:new 0 0 792 792;" xml:space="preserve">
|
||||||
style="enable-background:new 0 0 564.2 553.5;" xml:space="preserve">
|
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
.st0{fill:#4081EF;stroke:#512D8C;stroke-miterlimit:10;}
|
.st0{fill:#FA2921;}
|
||||||
.st1{fill:#31A452;stroke:#512D8C;stroke-miterlimit:10;}
|
.st1{fill:#ED79B5;}
|
||||||
.st2{fill:#DE7FB3;stroke:#512D8C;stroke-miterlimit:10;}
|
.st2{fill:#FFB400;}
|
||||||
.st3{fill:#FFB800;stroke:#512D8C;stroke-miterlimit:10;}
|
.st3{fill:#1E83F7;}
|
||||||
.st4{fill:#E64132;stroke:#512D8C;stroke-miterlimit:10;}
|
.st4{fill:#18C249;}
|
||||||
.st5{fill:#F2F5FB;stroke:#512D8C;stroke-miterlimit:10;}
|
|
||||||
</style>
|
</style>
|
||||||
<path class="st0" d="M210.5,549.6c-2.2-0.2-5.5-1-9.7-2.2c-52.4-15.7-99-46.5-133.8-88.5c-8.8-10.7-17.2-22.4-19.4-27.5
|
<g id="Flower_00000077325900055813483940000000694823054982625702_">
|
||||||
c-8.1-18.1-6.3-38.7,4.8-55.4c5-7.5,13.2-15,20.5-18.7c1.2-0.6,54.1-20,55.8-20.4c0.5-0.1,0.5,0.2-0.3,2.1c-0.7,1.7-1,3.1-1.1,5.5
|
<path class="st0" d="M375.48,267.63c38.64,34.21,69.78,70.87,89.82,105.42c34.42-61.56,57.42-134.71,57.71-181.3
|
||||||
l-0.1,3.2l2.8,5.8c8.7,17.9,19.2,32.7,33.2,46.4c6.3,6.2,7.8,7.6,13.8,12.3c22.7,18.1,52,30.7,79.9,34.3c2.5,0.3,5,0.8,5.7,1
|
c0-0.33,0-0.63,0-0.91c0-68.94-68.77-95.77-128.01-95.77s-128.01,26.83-128.01,95.77c0,0.94,0,2.2,0,3.72
|
||||||
c2.8,0.9,7.7-0.8,11-3.7l1.8-1.6l-0.2,4.8c-0.1,2.7-0.6,15.4-1,28.3c-0.6,20.3-0.8,24-1.5,27.5c-3.9,20.7-18.6,37.5-38.4,44.1
|
C300.01,209.24,339.15,235.47,375.48,267.63z"/>
|
||||||
c-4.6,1.5-8,2.2-13.1,2.7C216.6,550.1,215.3,550,210.5,549.6z"/>
|
<path class="st1" d="M164.7,455.63c24.15-26.87,61.2-55.99,103.01-80.61c44.48-26.18,88.97-44.47,128.02-52.84
|
||||||
<path class="st1" d="M339.8,549.4c-4-0.4-9.4-1.6-13.2-2.9c-3.4-1.2-10-4.4-12.5-6.1c-10.9-7.4-19-17.9-23.1-30
|
c-47.91-51.76-110.37-96.24-154.6-110.91c-0.31-0.1-0.6-0.19-0.86-0.28c-65.57-21.3-112.34,35.81-130.64,92.15
|
||||||
c-2.2-6.7-2.3-7.5-3.3-36.9c-0.5-14.9-0.9-27.9-0.9-28.9l0-1.9l2.3,1.8c2.6,2,6.6,3.4,8.5,3.1c0.6-0.1,3-0.5,5.3-0.8
|
c-18.3,56.34-14.04,130.04,51.53,151.34C162.05,454.77,163.25,455.16,164.7,455.63z"/>
|
||||||
c37.7-5.3,71.2-22.2,97.4-49.1c12.2-12.5,21.4-25.5,29.9-42.4l3.5-7l0-3.6c0-3.1-0.1-3.8-1-5.7c-0.5-1.2-0.9-2.1-0.9-2.2
|
<path class="st2" d="M681.07,302.19c-18.3-56.34-65.07-113.45-130.64-92.15c-0.9,0.29-2.1,0.68-3.54,1.15
|
||||||
c0.2-0.2,55.3,20.1,56.9,20.9c2.6,1.3,6.6,4.1,9.9,7c9.2,7.7,16.1,19.4,18.8,31.8c0.7,3.1,0.8,4.8,0.8,11.3c0,8.6-0.5,11.7-2.9,18.7
|
c-3.75,35.93-16.6,81.27-35.96,125.76c-20.59,47.32-45.84,88.27-72.51,118c69.18,13.72,145.86,12.98,190.26-1.14
|
||||||
c-1.7,5-2.9,7.2-7.1,13.1c-7.6,11-15.3,20.5-25.2,31.2c-32.8,35.4-76.5,62.5-123.4,76.3C351.6,549.6,347.2,550.1,339.8,549.4z"/>
|
c0.31-0.1,0.6-0.2,0.86-0.28C695.11,432.22,699.37,358.52,681.07,302.19z"/>
|
||||||
<path class="st2" d="M255.6,438c-25.9-4.2-50.7-14.9-71.7-31c-5.2-4-8.7-7.1-14.1-12.4c-12.7-12.5-21.9-24.9-30.5-41.4
|
<path class="st3" d="M336.54,510.71c-11.15-50.39-14.8-98.36-10.7-138.08c-64.03,29.57-125.63,75.23-153.26,112.76
|
||||||
c-2.3-4.4-2.4-4.7-2.4-7.1c0-8.8,8.5-15.2,16.9-12.7c5.6,1.7,9.6,6.8,9.7,12.2c0,2.6-0.8,4.6-2.6,6.2c-1.2,1.1-3.2,1.9-4.6,1.9
|
c-0.19,0.26-0.37,0.51-0.53,0.73c-40.52,55.78-0.66,117.91,47.27,152.72c47.92,34.82,119.33,53.54,159.86-2.24
|
||||||
c-1.2,0-3.3-0.8-4.3-1.6c-2.1-1.8-2-1,0.4,3.2c19.3,33.8,52.3,59.1,90,69.1c5.7,1.5,11.5,2.7,11.8,2.4c0.1-0.1-0.4-0.8-1.3-1.6
|
c0.56-0.76,1.3-1.78,2.19-3.01C363.28,602.32,347.02,558.08,336.54,510.71z"/>
|
||||||
c-5.1-4.5-2.3-11.7,5-12.8c5.4-0.8,11.4,2.7,13.9,8c0.8,1.7,1,2.5,1,5.3s-0.1,3.5-1,5.3c-2,4.3-6.8,7.9-10.3,7.8
|
<path class="st4" d="M617.57,482.52c-35.33,7.54-82.42,9.33-130.72,4.66c-51.37-4.96-98.11-16.32-134.63-32.5
|
||||||
C260.6,438.7,257.9,438.3,255.6,438z"/>
|
c8.33,70.03,32.73,142.73,59.88,180.6c0.19,0.26,0.37,0.51,0.53,0.73c40.52,55.78,111.93,37.06,159.86,2.24
|
||||||
<path class="st0" d="M297.6,438.2c-3.4-1.3-6.4-4.3-7.8-8.1c-1.1-2.9-0.9-7.3,0.5-10.2c2.6-5.3,8.7-8.5,14.4-7.5
|
c47.92-34.82,87.79-96.95,47.27-152.72C619.2,484.77,618.46,483.75,617.57,482.52z"/>
|
||||||
c2.9,0.5,4.7,1.9,6,4.3c0.8,1.6,1,2.2,0.8,3.6c-0.3,2.2-0.9,3.3-2.7,4.8c-0.8,0.7-1.4,1.4-1.3,1.5c0.5,0.5,13.4-2.7,21.3-5.4
|
</g>
|
||||||
c33.6-11.3,62.5-35.1,80.4-66.1c2.5-4.4,2.6-5,0.5-3.2c-2.8,2.4-7,1.9-9.6-1c-4-4.6-0.7-13.8,6.1-16.9c2-0.9,2.7-1,5.5-1
|
|
||||||
c2.9,0,3.5,0.1,5.6,1.1c4.4,2.1,7.4,6.4,7.8,11c0.2,2.2,0.1,2.3-2.2,6.9c-23,45.9-67,78.1-117.2,85.9
|
|
||||||
C300.2,438.8,299.4,438.9,297.6,438.2z"/>
|
|
||||||
<path class="st1" d="M211.1,398.5c-4.7-0.9-8.7-2.7-12.9-5.9c-10.8-8.1-13.5-22.3-6.6-33.7c0.7-1.2,1.1-2.2,1-2.4
|
|
||||||
c-0.2-0.2-1.2-0.6-2.3-1.1c-7.6-3-13-10.6-13.5-19.1c-0.5-7.4,3.1-15,9-19.4c1-0.7,2.2-1.5,2.6-1.8c0.8-0.4,68.9-22.7,69.4-22.7
|
|
||||||
c0.2,0,0.7,0.7,1.2,1.5c0.5,0.8,1.6,2.3,2.4,3.3c1.2,1.4,1.5,1.9,1.2,2.3c-0.2,0.3-6.9,9.5-14.8,20.5
|
|
||||||
c-15.9,21.9-15.5,21.3-13.4,23.4c1.3,1.3,2.9,1.4,4.4,0.3c0.6-0.4,7.5-9.7,15.5-20.7c11.2-15.4,14.6-19.9,15-19.7
|
|
||||||
c0.9,0.4,5.5,1.9,6.6,2.1l1,0.2l0,35.3c0,39.7,0,38.8-2.5,44c-2.6,5.3-7.2,9.3-12.7,11.2c-3.7,1.3-6.8,1.6-10.2,1
|
|
||||||
c-5.5-0.9-9.8-3.2-13.7-7.4l-2.2-2.4l-0.6,0.9c-3,4.3-8.6,8.1-14,9.5C218.2,398.6,213.2,398.9,211.1,398.5z"/>
|
|
||||||
<path class="st3" d="M342.9,398.5c-5.5-0.9-9.9-3.2-14.3-7.6l-3.2-3.2l-0.7,1c-2.3,3.3-6.8,6.5-11.1,7.9c-3.7,1.2-9.2,1.4-12.6,0.3
|
|
||||||
c-7.1-2.1-12.7-7.4-15.2-14.3l-0.9-2.6v-37.1v-37.1l1.8-0.4c1-0.2,2.7-0.8,3.9-1.2c1.1-0.5,2.1-0.8,2.2-0.7c0.1,0.1,6.5,9,14.4,19.9
|
|
||||||
c7.8,10.9,14.7,20.1,15.2,20.5c2.2,1.9,5.4,0.4,5.4-2.6c0-1.4-1-2.9-13.8-20.5c-7.6-10.5-14.2-19.6-14.7-20.4l-0.9-1.3l1.4-1.7
|
|
||||||
c0.8-0.9,1.9-2.5,2.5-3.4l1-1.6l34.4,11.2c18.9,6.2,35.1,11.6,35.9,12.1c6.8,4,11.1,11.3,11.1,19.1c0,4.1-0.5,6.4-2.4,10.2
|
|
||||||
c-2,4.1-5.5,7.6-9.6,9.7c-1.6,0.8-3.2,1.5-3.4,1.5c-1,0-0.9,0.7,0.3,2.6c2.8,4.3,4,8.5,3.9,13.7c0,8.1-3.7,15.2-10.6,20.3
|
|
||||||
C356.4,397.6,349.5,399.5,342.9,398.5z"/>
|
|
||||||
<path class="st2" d="M53.9,341.9c-0.5-0.1-2.3-0.4-3.9-0.7c-15.6-2.6-30.4-12.6-38.8-26.2c-3.5-5.7-6.4-13.2-7.8-19.9
|
|
||||||
c-1.2-6.1-0.8-28.1,0.8-43.1c4.5-43,19-84.3,42.2-120.7c6.5-10.2,14.9-21.5,18.2-24.6c17.8-16.6,43.1-20.5,64.8-10
|
|
||||||
c4.3,2.1,8.8,5.1,12.7,8.6c2.8,2.4,5.8,6.1,20.9,25.5c9.7,12.5,17.8,22.8,17.9,23c0.2,0.2-0.9,0.4-3.2,0.4c-2.5,0-4.1,0.2-5.7,0.7
|
|
||||||
c-2.1,0.7-2.6,1.1-7.9,6.3c-8.2,8.1-14.4,15.3-20.3,23.9c-15.5,22.2-25.4,47.7-28.8,74.8c-2.2,16.9-1.6,37.5,1.6,52.3
|
|
||||||
c0.3,1.4,0.5,2.8,0.4,3c-0.1,0.2,0.2,1.3,0.8,2.4c1.1,2.4,4.3,5.7,6.5,6.8l1.5,0.8l-1.2,0.4c-0.7,0.2-13.1,3.8-27.6,8
|
|
||||||
c-16.4,4.7-27.7,7.8-29.8,8.1C64.1,342.1,56.1,342.3,53.9,341.9z"/>
|
|
||||||
<path class="st3" d="M494.7,341.7c-2.1-0.3-33.8-9.1-56.5-15.8l-2.5-0.7l1.6-0.8c3.4-1.7,7.2-6.6,7.3-9.6c0-0.7,0.4-3.3,0.8-5.8
|
|
||||||
c3.9-22.7,3.1-46.1-2.5-68.4c-6.4-25.5-18.6-49.2-35.8-69.1c-4.6-5.3-14.8-15.4-16.4-16.1c-2.4-1.1-5.1-1.6-8-1.4l-2.7,0.2l1.2-1.5
|
|
||||||
c0.7-0.8,8.5-10.8,17.5-22.3c8.9-11.5,17.2-21.8,18.5-23.1c2.6-2.7,7-6.2,10.3-8.2c19.3-11.6,43-11.1,61.6,1.2
|
|
||||||
c5.4,3.6,8.2,6.2,12.3,11.7c26.4,34.5,44,73.7,52.3,116.2c3.4,17.6,4.9,33.3,5,52.4c0,13-0.2,14.8-2.5,21.8
|
|
||||||
C547.8,328.6,521.7,345.2,494.7,341.7z"/>
|
|
||||||
<path class="st4" d="M133.9,318.5c-2-0.5-4.6-1.9-6-3.3c-2.5-2.4-3.1-3.5-3.7-7.3c-4.4-27.3-2.2-54,6.7-79.3
|
|
||||||
c5.3-15.1,13.5-30.5,23-43.1c5.8-7.8,16.6-19.5,19-20.7c4.7-2.4,11.3-1.2,15.2,2.7c5.4,5.4,5.2,13.9-0.3,19.1
|
|
||||||
c-4.3,4-9.4,4.4-12.6,0.9c-1.7-1.9-2.2-3.9-1.7-6.4c0.2-1.1,0.3-2,0.2-2.2c-0.3-0.3-3.6,3.3-8.3,9.1c-17.6,21.8-28.5,48-31.9,76.5
|
|
||||||
c-1.1,9.3-1,26.4,0.1,34.6c0.3,1.8,0.8,1.9,1.4,0.1c0.9-2.6,4-4.7,6.8-4.7c3,0,5.9,2.2,7.5,5.7c0.6,1.3,0.8,2.3,0.8,5.2
|
|
||||||
c0,3.3-0.1,3.8-1.1,5.7c-1.4,2.7-4.6,5.7-7.1,6.6C139.4,318.6,135.8,318.9,133.9,318.5z"/>
|
|
||||||
<path class="st1" d="M422.6,318.5c-3.7-0.6-7.7-3.6-9.4-7.1c-3.8-7.5,0.1-16.9,6.9-16.9c3.1,0,5.8,2,6.9,5.2
|
|
||||||
c0.4,1.2,0.5,1.3,0.7,0.7c1.3-3.7,1.7-26.4,0.6-35.7c-3.6-29.6-14.5-55.3-33-77.9c-5.5-6.7-8.4-9.4-7.1-6.6c0.7,1.4,0.5,4.3-0.3,5.9
|
|
||||||
c-0.9,1.7-3.2,3.5-5,3.8c-3.2,0.6-7.9-1.6-10.2-4.8c-6.5-8.8-0.5-21.2,10.4-21.4c4.6-0.1,5.2,0.3,11.2,6.4
|
|
||||||
c12.1,12.3,21.1,24.9,28.8,40.3c13.2,26.3,18.6,54.9,16.1,84.5c-0.5,5.6-2,15.7-2.6,17.1c-1.3,2.8-4.8,5.5-8.4,6.5
|
|
||||||
C425.9,318.9,425.1,318.9,422.6,318.5z"/>
|
|
||||||
<path class="st0" d="M178.2,307.2c-6-1.3-12.2-6.2-14.9-11.7c-3.4-7-3.1-15.1,0.9-21.6c0.7-1.2,1.2-2.3,1.1-2.4
|
|
||||||
c-0.1-0.1-1.1-0.6-2.1-1c-3.9-1.5-8.1-4.8-10.7-8.3c-4.6-6.2-6.1-14.6-3.9-22.1c2.9-10.3,9.4-16.8,19.1-19.3c2.8-0.7,9-0.8,11.7,0
|
|
||||||
c1.1,0.3,2.2,0.5,2.4,0.5c0.2,0,0.3-0.7,0.3-1.5c0-2.9,0.8-5.8,2.4-9.2c5.2-10.8,18.1-15.5,29-10.5c2.7,1.2,6.2,3.8,7.8,5.8
|
|
||||||
c0.7,0.8,10.3,14,21.5,29.4l20.3,27.9l-1.5,1.8c-0.8,1-1.9,2.6-2.5,3.5c-0.6,1-1.2,1.7-1.5,1.6c-4.5-1.7-46.7-15-47.7-15
|
|
||||||
c-1.9,0-3.1,1.3-3.1,3.2c0,1,0.2,1.7,0.8,2.3c0.6,0.6,7.8,3.1,24.5,8.5l23.7,7.7l-0.1,4.3l-0.1,4.3L223,295.9
|
|
||||||
c-18,5.9-33.9,10.9-35.2,11.2C184.7,307.8,181.2,307.8,178.2,307.2z"/>
|
|
||||||
<path class="st4" d="M372.5,306.8c-1.8-0.5-17.5-5.6-35-11.3l-31.8-10.4l1-4.3v-4.3l22.6-7.7c15-4.9,24-8,24.6-8.5
|
|
||||||
c0.7-0.6,0.9-1.1,0.9-2.2c0-2-1.2-3.3-3.1-3.3c-0.9,0-10.5,2.9-24.7,7.5c-12.8,4.1-23.4,7.5-23.6,7.5c-0.1,0-0.7-0.8-1.3-1.9
|
|
||||||
c-0.6-1-1.6-2.5-2.2-3.2c-0.7-0.7-1.2-1.5-1.2-1.6c0-0.2,9.6-13.5,21.4-29.6c18.9-26,21.6-29.6,23.6-31.1c5.7-4.4,13.1-5.8,19.7-3.9
|
|
||||||
c9,2.7,16.1,11.6,16.1,20.3c0,2.3-0.1,2.3,3.1,1.5c4.7-1.1,11.5-0.5,16,1.5c4.6,2,9,6,11.5,10.2c2.1,3.6,3.9,9.4,4.2,13.2
|
|
||||||
c0.3,5.2-1.1,10.7-4,15.3c-2.6,4.1-7.8,8.3-12.1,9.8c-0.9,0.3-1.7,0.8-1.7,1c0,0.2,0.4,1,0.9,1.7c2.4,3.6,3.6,7.7,3.5,12.7
|
|
||||||
c0,5.8-2.1,10.7-6.4,15.1c-4,4.1-8.9,6.3-14.9,6.5C376.3,307.7,375.3,307.6,372.5,306.8z"/>
|
|
||||||
<path class="st5" d="M276.2,298.9c-6.1-1.6-11.4-6.8-13.2-12.9c-0.7-2.4-0.7-7.5,0-9.9c1.7-5.8,6.6-10.8,12.3-12.5
|
|
||||||
c2.7-0.8,7.2-0.9,10-0.2c6.2,1.6,11.6,7.1,13.2,13.3c1.6,6-0.3,12.6-5,17.3C288.9,298.6,282.2,300.5,276.2,298.9z"/>
|
|
||||||
<path class="st2" d="M248.3,229.8c-13.3-18.3-21.2-29.6-22-31.1c-1.4-3-1.9-5.5-1.9-9.4c0-14.1,13.1-24.4,27.1-21.4
|
|
||||||
c1.4,0.3,2.6,0.5,2.7,0.5s0.3-1.3,0.4-2.8c0.8-10.7,8.4-19.6,18.9-22.4c3.9-1,10.6-1,14.5,0c8.9,2.3,15.9,9.3,18.2,18.2
|
|
||||||
c0.4,1.5,0.7,3.7,0.7,4.9c0,1.2,0.1,2.1,0.3,2.1s1.5-0.3,3-0.6c7.4-1.6,15.2,0.7,20.5,6c4.3,4.3,6.6,9.6,6.6,15.6
|
|
||||||
c0,4-0.6,6.5-2.4,10c-0.6,1.2-10.4,15-21.7,30.7c-17.8,24.5-20.8,28.5-21.4,28.3c-0.4-0.1-1.9-0.6-3.4-1.1c-1.5-0.5-2.9-0.9-3.3-0.9
|
|
||||||
c-0.7,0-0.7-0.8-0.3-25.5v-25.5l-1.4-0.9c-1-1.1-2.5-1.5-3.8-0.9c-2,0.8-2-0.5-1.8,27.2v25.8h-1.2c-0.5-0.2-2.4,0.3-4,0.9
|
|
||||||
s-3.1,1.1-3.2,1.1C269.2,258.5,259.8,245.6,248.3,229.8z"/>
|
|
||||||
<path class="st3" d="M210.9,164.8c-4.1-0.9-7.7-3.6-9.6-7.4c-1.4-2.8-1.7-7.3-0.5-10.3c1.7-4.5,3.9-6.1,15.6-11.2
|
|
||||||
c15.8-7,31.4-11.1,49.2-12.9c7.3-0.8,23.2-0.8,30.6,0c17.4,1.8,33.3,6,49.1,13c7.3,3.2,12.5,6.1,13.6,7.5c4.3,5.6,3.8,12.7-1.1,17.6
|
|
||||||
c-5.1,5.1-12.9,5.4-18.1,0.7c-2-1.8-3-3.5-3.4-5.6c-0.7-4,2.9-8.1,7.3-8.2c1.4,0,1.5-0.1,1.1-0.5c-0.3-0.3-2.2-1.2-4.3-2.1
|
|
||||||
c-33.2-14.5-70.5-16.4-105-5.4c-7.5,2.4-19,7.2-18.6,7.7c0.1,0.2,0.8,0.3,1.6,0.3c5.6,0,9.1,6.2,6.1,10.8
|
|
||||||
C221.6,163.3,215.9,165.9,210.9,164.8z"/>
|
|
||||||
<path class="st4" d="M174.7,123.4c-8.9-13.1-16.8-25.1-17.5-26.6c-1.6-3.3-3.6-9.2-4.4-13c-2.6-12.5-0.9-25.8,5-37.5
|
|
||||||
c4.2-8.3,11.2-16.3,18.6-21.3c5-3.4,6.1-3.9,12.8-6.3c23.1-8.2,47.2-13.1,73.4-15c7.5-0.6,28.5-0.6,36.3,0
|
|
||||||
c25.5,1.8,50.6,6.9,73,14.8c6.4,2.2,8.2,3.1,13.1,6.5c9.8,6.6,18.1,17.5,22,29.2c2.2,6.5,2.7,10,2.7,17.9c0,7.9-0.5,11.3-2.7,17.9
|
|
||||||
c-2.3,6.8-3.7,9.1-20.3,33.6l-16.1,23.8l-0.4-2.2c-0.2-1.2-0.9-3-1.4-4c-1-1.8-4.4-5.6-4.7-5.2c-0.1,0.1-1.2-0.4-2.4-1.1
|
|
||||||
c-9.1-5.2-21.9-10.5-33.2-13.9c-37-11-77.2-8.8-113,6.1c-4.9,2.1-17.7,8.4-19.2,9.5c-2.2,1.6-5.1,6.8-5.1,9c0,0.4-0.1,1-0.3,1.2
|
|
||||||
C191,147,184.7,138,174.7,123.4z"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 2.7 MiB |
|
Before Width: | Height: | Size: 406 KiB |
|
Before Width: | Height: | Size: 540 KiB |
|
Before Width: | Height: | Size: 376 KiB |
|
Before Width: | Height: | Size: 570 KiB |
|
Before Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 206 KiB |
@@ -2,8 +2,6 @@
|
|||||||
# - https://immich.app/docs/developer/setup
|
# - https://immich.app/docs/developer/setup
|
||||||
# - https://immich.app/docs/developer/troubleshooting
|
# - https://immich.app/docs/developer/troubleshooting
|
||||||
|
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
name: immich-dev
|
name: immich-dev
|
||||||
|
|
||||||
x-server-build: &server-common
|
x-server-build: &server-common
|
||||||
@@ -99,7 +97,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5
|
image: redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672
|
||||||
|
|
||||||
database:
|
database:
|
||||||
container_name: immich_postgres
|
container_name: immich_postgres
|
||||||
@@ -114,6 +112,29 @@ services:
|
|||||||
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
|
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
|
|
||||||
|
# set IMMICH_METRICS=true in .env to enable metrics
|
||||||
|
# immich-prometheus:
|
||||||
|
# container_name: immich_prometheus
|
||||||
|
# ports:
|
||||||
|
# - 9090:9090
|
||||||
|
# image: prom/prometheus
|
||||||
|
# volumes:
|
||||||
|
# - ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
|
# - prometheus-data:/prometheus
|
||||||
|
|
||||||
|
# first login uses admin/admin
|
||||||
|
# add data source for http://immich-prometheus:9090 to get started
|
||||||
|
# immich-grafana:
|
||||||
|
# container_name: immich_grafana
|
||||||
|
# command: ['./run.sh', '-disable-reporting']
|
||||||
|
# ports:
|
||||||
|
# - 3000:3000
|
||||||
|
# image: grafana/grafana:10.3.3-ubuntu
|
||||||
|
# volumes:
|
||||||
|
# - grafana-data:/var/lib/grafana
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
model-cache:
|
model-cache:
|
||||||
|
prometheus-data:
|
||||||
|
grafana-data:
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
name: immich-prod
|
name: immich-prod
|
||||||
|
|
||||||
x-server-build: &server-common
|
x-server-build: &server-common
|
||||||
@@ -56,7 +54,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5
|
image: redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
database:
|
database:
|
||||||
@@ -73,5 +71,28 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
|
|
||||||
|
# set IMMICH_METRICS=true in .env to enable metrics
|
||||||
|
immich-prometheus:
|
||||||
|
container_name: immich_prometheus
|
||||||
|
ports:
|
||||||
|
- 9090:9090
|
||||||
|
image: prom/prometheus@sha256:4f6c47e39a9064028766e8c95890ed15690c30f00c4ba14e7ce6ae1ded0295b1
|
||||||
|
volumes:
|
||||||
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
|
- prometheus-data:/prometheus
|
||||||
|
|
||||||
|
# first login uses admin/admin
|
||||||
|
# add data source for http://immich-prometheus:9090 to get started
|
||||||
|
immich-grafana:
|
||||||
|
container_name: immich_grafana
|
||||||
|
command: ['./run.sh', '-disable-reporting']
|
||||||
|
ports:
|
||||||
|
- 3000:3000
|
||||||
|
image: grafana/grafana:10.4.2-ubuntu@sha256:4f55071b556fb03f12b41423c98a185ed6695ed9ff2558e35805f0dd765fd958
|
||||||
|
volumes:
|
||||||
|
- grafana-data:/var/lib/grafana
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
model-cache:
|
model-cache:
|
||||||
|
prometheus-data:
|
||||||
|
grafana-data:
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# WARNING: Make sure to use the docker-compose.yml of the current release:
|
# WARNING: Make sure to use the docker-compose.yml of the current release:
|
||||||
#
|
#
|
||||||
@@ -60,7 +58,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: registry.hub.docker.com/library/redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5
|
image: registry.hub.docker.com/library/redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
database:
|
database:
|
||||||
@@ -71,9 +69,8 @@ services:
|
|||||||
POSTGRES_USER: ${DB_USERNAME}
|
POSTGRES_USER: ${DB_USERNAME}
|
||||||
POSTGRES_DB: ${DB_DATABASE_NAME}
|
POSTGRES_DB: ${DB_DATABASE_NAME}
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
|
||||||
model-cache:
|
model-cache:
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
# The location where your uploaded files are stored
|
# The location where your uploaded files are stored
|
||||||
UPLOAD_LOCATION=./library
|
UPLOAD_LOCATION=./library
|
||||||
|
# The location where your database files are stored
|
||||||
|
DB_DATA_LOCATION=./postgres
|
||||||
|
|
||||||
# The Immich version to use. You can pin this to a specific version like "v1.71.0"
|
# The Immich version to use. You can pin this to a specific version like "v1.71.0"
|
||||||
IMMICH_VERSION=release
|
IMMICH_VERSION=release
|
||||||
@@ -11,8 +13,5 @@ DB_PASSWORD=postgres
|
|||||||
|
|
||||||
# The values below this line do not need to be changed
|
# The values below this line do not need to be changed
|
||||||
###################################################################################
|
###################################################################################
|
||||||
DB_HOSTNAME=immich_postgres
|
|
||||||
DB_USERNAME=postgres
|
DB_USERNAME=postgres
|
||||||
DB_DATABASE_NAME=immich
|
DB_DATABASE_NAME=immich
|
||||||
|
|
||||||
REDIS_HOSTNAME=immich_redis
|
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ services:
|
|||||||
count: 1
|
count: 1
|
||||||
capabilities:
|
capabilities:
|
||||||
- gpu
|
- gpu
|
||||||
- compute
|
|
||||||
- video
|
|
||||||
|
|
||||||
openvino:
|
openvino:
|
||||||
device_cgroup_rules:
|
device_cgroup_rules:
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
evaluation_interval: 15s
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: immich_server
|
||||||
|
static_configs:
|
||||||
|
- targets: ['immich-server:8081']
|
||||||
|
|
||||||
|
- job_name: immich_microservices
|
||||||
|
static_configs:
|
||||||
|
- targets: ['immich-microservices:8081']
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
v20.12
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Website
|
# Website
|
||||||
|
|
||||||
This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator.
|
This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator.
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ Hello everyone, it is my pleasure to deliver the new release of Immich to you. T
|
|||||||
|
|
||||||
Some notable features are:
|
Some notable features are:
|
||||||
|
|
||||||
- [OAuth integration](#livephoto-ios-support-)
|
- OAuth integration
|
||||||
- [LivePhoto support on iOS](#oauth-integration-)
|
- LivePhoto support on iOS
|
||||||
- User config system
|
- User config system
|
||||||
|
|
||||||
<!--truncate-->
|
<!--truncate-->
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
title: The Immich core team goes full-time
|
||||||
|
authors: [alextran]
|
||||||
|
tags: [update, announcement, futo]
|
||||||
|
date: 2024-05-01T00:00
|
||||||
|
---
|
||||||
|
|
||||||
|
**Immich is joining [FUTO](https://futo.org/)!**
|
||||||
|
|
||||||
|
Since the beginning of this adventure, my goal has always been to create a better world for my children. Memories are priceless, and privacy should not be a luxury. However, building quality open source has its challenges. Over the past two years, it has taken significant dedication, time, and effort.
|
||||||
|
|
||||||
|
Recently, a company in Austin, Texas, called FUTO contacted the team. FUTO strives to develop quality and sustainable open software. They build software alternatives that focus on giving control to users. From their mission statement:
|
||||||
|
|
||||||
|
“Computers should belong to you, the people. We develop and fund technology to give them back.”
|
||||||
|
|
||||||
|
FUTO loved Immich and wanted to see if we’d consider working with them to take the project to the next level. In short, FUTO offered to:
|
||||||
|
|
||||||
|
- Pay the core team to work on Immich full-time
|
||||||
|
- Let us keep full autonomy about the project’s direction and leadership
|
||||||
|
- Continue to license Immich under AGPL
|
||||||
|
- Keep Immich’s development direction with no paywalled features
|
||||||
|
- Keep Immich “built for the people” (no ads, data mining/selling, or alternative motives)
|
||||||
|
- Provide us with financial, technical, legal, and administrative support
|
||||||
|
|
||||||
|
After careful deliberation, the team decided that FUTO’s vision closely aligns with our own: to build a better future by providing a polished, performant, and privacy-preserving open-source software solution for photo and video management delivered in a sustainable way.
|
||||||
|
|
||||||
|
Immich’s future has never looked brighter, and we look forward to realizing our vision for Immich as part of FUTO.
|
||||||
|
|
||||||
|
If you have more questions, we’ll host a Q&A live stream on May 9th at 3PM UTC (10AM CST). [You can ask questions here](https://www.live-ask.com/event/01HWP2SB99A1K8EXFBDKZ5Z9CF), and the stream will be live [here on our YouTube channel](https://youtube.com/live/cwz2iZwYpgg).
|
||||||
|
|
||||||
|
Cheers,
|
||||||
|
|
||||||
|
The Immich Team
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FAQs
|
||||||
|
|
||||||
|
### What is FUTO?
|
||||||
|
|
||||||
|
[https://futo.org/what-is-futo/](https://futo.org/what-is-futo/)
|
||||||
|
|
||||||
|
### Will the license change?
|
||||||
|
|
||||||
|
No. Immich will continue to be licensed under AGPL without a CLA.
|
||||||
|
|
||||||
|
### Will Immich continue to be free?
|
||||||
|
|
||||||
|
Yes. The Immich source code will remain freely available under the AGPL license.
|
||||||
|
|
||||||
|
### Is Immich getting VC funding?
|
||||||
|
|
||||||
|
No. Venture capital implies investment in a business, often with the expectation of a future payout (exit plan). Immich is neither a business that can be acquired nor comes with a money-making exit plan.
|
||||||
|
|
||||||
|
### I am currently supporting Immich through GitHub sponsors. What will happen to my donation?
|
||||||
|
|
||||||
|
Effective immediately, all donations to the Immich organization will be canceled. In the future, we will offer an optional, modest payment option instead. Thank you to everyone who donated to help us get this far!
|
||||||
|
|
||||||
|
### How is funding sustainable?
|
||||||
|
|
||||||
|
Immich and FUTO believe a sustainable future requires a model that does not rely on users-as-a-product. To this end, FUTO advocates that users pay for good, open software. In keeping with this model, we will adopt a purchase price. This means we no longer accept donations, but — _without limiting features for those who do not pay_ — we will soon allow you to purchase Immich through a modest payment. We encourage you to pay for the high-quality software you use to foster a healthy software culture where developers build great applications without hidden motives for their users.
|
||||||
|
|
||||||
|
### When does this change take effect?
|
||||||
|
|
||||||
|
This change takes effect immediately.
|
||||||
|
|
||||||
|
### What will change?
|
||||||
|
|
||||||
|
The following things will change as Immich joins FUTO:
|
||||||
|
|
||||||
|
- The brand, logo, and other Immich trademarks will be transferred to FUTO.
|
||||||
|
- We will stop all donations to the project.
|
||||||
|
- The core team can now dedicate our full attention to Immich
|
||||||
|
- Before the end of the year, we plan to have a roadmap for what it will take to get Immich to a stable release.
|
||||||
|
- Bugs will be squashed, and features will be delivered faster.
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
The admin password can be reset by running the [reset-admin-password](/docs/administration/server-commands.md) command on the immich-server.
|
The admin password can be reset by running the [reset-admin-password](/docs/administration/server-commands.md) command on the immich-server.
|
||||||
|
|
||||||
### How can I see list of all users in Immich?
|
### How can I see a list of all users in Immich?
|
||||||
|
|
||||||
You can see the list of all users by running [list-users](/docs/administration/server-commands.md) Command on the Immich-server.
|
You can see the list of all users by running [list-users](/docs/administration/server-commands.md) Command on the Immich-server.
|
||||||
|
|
||||||
@@ -24,22 +24,43 @@ You can see the list of all users by running [list-users](/docs/administration/s
|
|||||||
|
|
||||||
### I cannot log into the application after an update. What can I do?
|
### I cannot log into the application after an update. What can I do?
|
||||||
|
|
||||||
First, verify that the mobile app and server are both running the same version (major and minor).
|
Verify that the mobile app and server are both running the same version (major and minor).
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
App store updates sometimes take longer because the stores (Google play store and Apple app store)
|
App store updates sometimes take longer because the stores (Google Play Store and Apple App Store)
|
||||||
need to approve the update first which may take some time.
|
need to approve the update first, and it can take some time.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
If you still cannot login to the app, try the following:
|
If you still cannot log in to the app, try the following:
|
||||||
|
|
||||||
- Check the mobile logs
|
- Check the mobile logs
|
||||||
- Make sure login credentials are correct by logging in on the web app
|
- Make sure login credentials are correct by logging in on the web app
|
||||||
|
|
||||||
|
### Why does foreground backup stop when I navigate away from the app? Shouldn't it transfer the job to background backup?
|
||||||
|
|
||||||
|
Foreground backup and background backup are two separate mechanisms. They don't communicate or interact with each other.
|
||||||
|
|
||||||
|
Foreground backup is controlled by the user's action, while background backup is controlled by your device's operating system. When the app is put in the background, the invocation of background tasks is delegated to the device's operating system scheduler. It decides when the background task can run and how long it can run.
|
||||||
|
|
||||||
|
The behaviors differ based on your device manufacturer and operating system, but most are related to battery-saving policies.
|
||||||
|
|
||||||
|
### Why is background backup on iOS not working?
|
||||||
|
|
||||||
|
On iOS (iPhone and iPad), the operating system determines if a particular app can invoke background tasks based on multiple factors, most of which the Immich app has no control over. To increase the likelihood that the background backup task is run, follow the steps below:
|
||||||
|
|
||||||
|
- Enable Background App Refresh for Immich in the iOS settings at `Settings > General > Background App Refresh`.
|
||||||
|
- Disable Background App Refresh for apps that don't need background tasks to run. This will reduce the competition for background task invocation for Immich.
|
||||||
|
- Use the Immich app more often.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Assets
|
## Assets
|
||||||
|
|
||||||
|
### Does Immich change the file?
|
||||||
|
|
||||||
|
No, Immich does not touch the original file under any circumstances,
|
||||||
|
all edited metadata are saved in the companion sidecar file and the database.
|
||||||
|
|
||||||
### Can I add my existing photo library?
|
### Can I add my existing photo library?
|
||||||
|
|
||||||
Yes, with an [External Library](/docs/features/libraries.md).
|
Yes, with an [External Library](/docs/features/libraries.md).
|
||||||
@@ -50,11 +71,11 @@ Template changes will only apply to _new_ assets. To retroactively apply the tem
|
|||||||
|
|
||||||
### Why are only photos and not videos being uploaded to Immich?
|
### Why are only photos and not videos being uploaded to Immich?
|
||||||
|
|
||||||
This often happens when using a reverse proxy (such as nginx or Cloudflare tunnel) in front of Immich. Make sure to set your reverse proxy to allow large `POST` requests. In `nginx`, set `client_max_body_size 50000M;` or similar. Also check the disk space of your reverse proxy, in some cases proxies cache requests to disk before passing them on, and if disk space runs out the request fails.
|
This often happens when using a reverse proxy (such as Nginx or Cloudflare tunnel) in front of Immich. Make sure to set your reverse proxy to allow large `POST` requests. In `nginx`, set `client_max_body_size 50000M;` or similar. Also, check the disk space of your reverse proxy. In some cases, proxies cache requests to disk before passing them on, and if disk space runs out, the request fails.
|
||||||
|
|
||||||
### Why are some photos stored in the file system with the wrong date?
|
### Why are some photos stored in the file system with the wrong date?
|
||||||
|
|
||||||
There are a few different scenarios that can lead to this situation. The solution is to run the storage migration job again. The job is only _automatically_ run once per asset, after upload. If metadata extraction originally failed, the jobs were cleared/cancelled, etc. the job may not have run automatically the first time.
|
There are a few different scenarios that can lead to this situation. The solution is to rerun the storage migration job. The job is only automatically run once per asset after upload. If metadata extraction originally failed, the jobs were cleared/canceled, etc., the job may not have run automatically the first time.
|
||||||
|
|
||||||
### How can I hide photos from the timeline?
|
### How can I hide photos from the timeline?
|
||||||
|
|
||||||
@@ -68,23 +89,27 @@ See [Backup and Restore](/docs/administration/backup-and-restore.md).
|
|||||||
|
|
||||||
No, it currently does not. There is an [open feature request on GitHub](https://github.com/immich-app/immich/discussions/4348).
|
No, it currently does not. There is an [open feature request on GitHub](https://github.com/immich-app/immich/discussions/4348).
|
||||||
|
|
||||||
### Does Immich support filtering of NSFW images?
|
### Does Immich support the filtering of NSFW images?
|
||||||
|
|
||||||
No, it currently does not. There is an [open feature request on Github](https://github.com/immich-app/immich/discussions/2451).
|
No, it currently does not. There is an [open feature request on Github](https://github.com/immich-app/immich/discussions/2451).
|
||||||
|
|
||||||
### Why are there so many thumbnail generation jobs?
|
### Why are there so many thumbnail generation jobs?
|
||||||
|
|
||||||
There are three thubmanil jobs for each asset:
|
There are three thumbnail jobs for each asset:
|
||||||
|
|
||||||
- Blurred (thumbhash)
|
- Blurred (thumbhash)
|
||||||
- Small (webp)
|
- Preview (Webp)
|
||||||
- Large (jpeg)
|
- Thumbnail (Jpeg)
|
||||||
|
|
||||||
Also, there are additional jobs for person (face) thumbnails.
|
Also, there are additional jobs for person (face) thumbnails.
|
||||||
|
|
||||||
|
### Why do files from WhatsApp not appear with the correct date?
|
||||||
|
|
||||||
|
Files sent on WhatsApp are saved without metadata on the file. Therefore, Immich has no way of knowing the original date of the file when files are uploaded from WhatsApp, not the order of arrival on the device. [See #3527](https://github.com/immich-app/immich/issues/3527).
|
||||||
|
|
||||||
### What happens if an asset exists in more than one account?
|
### What happens if an asset exists in more than one account?
|
||||||
|
|
||||||
There are no requirements for assets to be unique across users. If multiple users upload the same image they are processed as if they were distinct assets and jobs run and thumbnails are generated accordingly.
|
There are no requirements for assets to be unique across users. If multiple users upload the same image, it is processed as if it were a distinct asset, and jobs run and thumbnails are generated accordingly.
|
||||||
|
|
||||||
### Why do HDR videos appear pale in Immich player but look normal after download?
|
### Why do HDR videos appear pale in Immich player but look normal after download?
|
||||||
|
|
||||||
@@ -96,45 +121,46 @@ Immich always keeps your original files. Alongside that, it generates a transcod
|
|||||||
|
|
||||||
### How can I delete transcoded videos without deleting the original?
|
### How can I delete transcoded videos without deleting the original?
|
||||||
|
|
||||||
The transcoded version of an asset can be deleted by setting a transcode policy that makes it unnecessary, then running a transcoding job for that asset. This can be done on a per-asset basis by starting a transcoding job for a single asset with the _Refresh encoded videos_ button in the asset viewer options, or for all assets by running transcoding jobs for all assets from the administration page.
|
The transcoded version of an asset can be deleted by setting a transcode policy that makes it unnecessary and then running a transcoding job for that asset. This can be done on a per-asset basis by starting a transcoding job for a single asset with the _Refresh encoded videos_ button in the asset viewer options or for all assets by running transcoding jobs for all assets from the administration page.
|
||||||
|
|
||||||
To update the transcode policy, navigate to Administration > Video Transcoding Settings > Transcoding Policy and select a policy from the drop-down. This policy will determine whether an existing transcode will be deleted or overwritten in the transcoding job. If a video should be transcoded according to this policy, an existing transcode is overwritten. If not, then it is deleted.
|
To update the transcode policy, navigate to Administration > Video Transcoding Settings > Transcoding Policy and select a policy from the drop-down. This policy will determine whether an existing transcode will be deleted or overwritten in the transcoding job. If a video should be transcoded according to this policy, an existing transcode is overwritten. If not, then it is deleted.
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
For example, say you have existing transcodes with the policy "Videos higher than normal resolution or not in the desired format" and switch to a narrower policy: "Videos not in the desired format". If an asset was only transcoded due to its resolution, then running a transcoding job for it will now delete the existing transcode. This is because resolution is no longer part of the transcode policy and the transcode is unnecessary as a result. Likewise, if you set the policy to "Don't transcode any videos" and run transcoding jobs for all assets, this will delete all existing transcodes as they are all unnecessary.
|
For example, say you have existing transcodes with the policy "Videos higher than normal resolution or not in the desired format" and switch to a narrower policy: "Videos not in the desired format." If an asset was only transcoded due to its resolution, running a transcoding job for it will delete the existing transcode. This is because resolution is no longer part of the transcode policy and the transcode is unnecessary. Likewise, if you set the policy to "Don't transcode any videos" and run transcoding jobs for all assets, this will delete all existing transcodes as they are unnecessary.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
### Is it possible to compress images during backup?
|
### Is it possible to compress images during backup?
|
||||||
|
|
||||||
No. Our golden rule is that the original assets should always be untouched, so we don't think this feature is a good fit for Immich.
|
No. Our design principle is that the original assets should always be untouched.
|
||||||
|
|
||||||
### How can I move all data (photos, persons, albums) from one user to another?
|
### How can I move all data (photos, persons, albums, libraries) from one user to another?
|
||||||
|
|
||||||
This is not officially supported, but can be accomplished with some database updates. You can do this on the command line (in the PostgreSQL container using the psql command), or you can add for example an [Adminer](https://www.adminer.org/) container to the `docker-compose.yml` file, so that you can use a web-interface.
|
This is not officially supported but can be accomplished with some database updates. You can do this on the command line (in the PostgreSQL container using the `psql` command), or you can add, for example, an [Adminer](https://www.adminer.org/) container to the `docker-compose.yml` file so that you can use a web interface.
|
||||||
|
|
||||||
:::warning
|
|
||||||
This is an advanced operation. If you can't do it with the steps described here, this is not for you.
|
|
||||||
:::
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Steps</summary>
|
<summary>Steps</summary>
|
||||||
|
|
||||||
1. **MAKE A BACKUP** - See [backup and restore](/docs/administration/backup-and-restore.md).
|
1. **MAKE A BACKUP** - See [backup and restore](/docs/administration/backup-and-restore.md).
|
||||||
|
|
||||||
2. Find the id of both the 'source' and the 'destination' user (it's the id column in the users table)
|
2. Find the ID of both the 'source' and the 'destination' user (it's the id column in the `users` table)
|
||||||
|
|
||||||
3. Three tables need to be updated:
|
3. Four tables need to be updated:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
// reassign albums
|
BEGIN;
|
||||||
|
-- reassign albums
|
||||||
UPDATE albums SET "ownerId" = '<destinationId>' WHERE "ownerId" = '<sourceId>';
|
UPDATE albums SET "ownerId" = '<destinationId>' WHERE "ownerId" = '<sourceId>';
|
||||||
|
|
||||||
// reassign people
|
-- reassign people
|
||||||
UPDATE person SET "ownerId" = '<destinationId>' WHERE "ownerId" = '<sourceId>';
|
UPDATE person SET "ownerId" = '<destinationId>' WHERE "ownerId" = '<sourceId>';
|
||||||
|
|
||||||
// reassign assets
|
-- reassign assets
|
||||||
UPDATE assets SET "ownerId" = '<destinationId>' WHERE "ownerId" = '<sourceId>'
|
UPDATE assets SET "ownerId" = '<destinationId>' WHERE "ownerId" = '<sourceId>'
|
||||||
AND CHECKSUM NOT IN (SELECT CHECKSUM FROM assets WHERE "ownerId" = '<destinationId>');
|
AND CHECKSUM NOT IN (SELECT CHECKSUM FROM assets WHERE "ownerId" = '<destinationId>');
|
||||||
|
|
||||||
|
-- reassign external libraries
|
||||||
|
UPDATE libraries SET "ownerId" = '<destinationId>' WHERE "ownerId" = '<sourceId>';
|
||||||
|
COMMIT;
|
||||||
```
|
```
|
||||||
|
|
||||||
4. There might be left-over assets in the 'source' user's library if they are skipped by the last query because of duplicate checksums. These are probably duplicates anyway, and can probably be removed.
|
4. There might be left-over assets in the 'source' user's library if they are skipped by the last query because of duplicate checksums. These are probably duplicates anyway, and can probably be removed.
|
||||||
@@ -159,7 +185,7 @@ No, not yet. For updates on this planned feature, follow the [GitHub discussion]
|
|||||||
|
|
||||||
### Can I add an external library while keeping the existing album structure?
|
### Can I add an external library while keeping the existing album structure?
|
||||||
|
|
||||||
We haven't put in an official mechanism to create albums from external libraries at the moment, but there are some [workarounds from the community](https://github.com/immich-app/immich/discussions/4279) to help you achieve that.
|
We haven't implemented an official mechanism for creating albums from external libraries, but there are some [workarounds from the community](https://github.com/immich-app/immich/discussions/4279) to help you achieve that.
|
||||||
|
|
||||||
### What happens to duplicates in external libraries?
|
### What happens to duplicates in external libraries?
|
||||||
|
|
||||||
@@ -171,7 +197,7 @@ Duplicate checking only exists for upload libraries, using the file hash. Furthe
|
|||||||
|
|
||||||
### How does smart search work?
|
### How does smart search work?
|
||||||
|
|
||||||
Immich uses CLIP models, for more information about CLIP and its capabilities read about it [here](https://openai.com/research/clip).
|
Immich uses CLIP models. For more information about CLIP and its capabilities, read about it [here](https://openai.com/research/clip).
|
||||||
|
|
||||||
### How does facial recognition work?
|
### How does facial recognition work?
|
||||||
|
|
||||||
@@ -189,33 +215,31 @@ However, disabling all jobs will not disable the machine learning service itself
|
|||||||
|
|
||||||
### I'm getting errors about models being corrupt or failing to download. What do I do?
|
### I'm getting errors about models being corrupt or failing to download. What do I do?
|
||||||
|
|
||||||
You can delete the model cache volume, which is where models are downloaded to. This will give the service a clean environment to download the model again. If models are failing to download entirely, you can manually download them from [Huggingface](https://huggingface.co/immich-app) and place them in the cache folder.
|
You can delete the model cache volume, where models are downloaded. This will give the service a clean environment to download the model again. If models are failing to download entirely, you can manually download them from [Huggingface][huggingface] and place them in the cache folder.
|
||||||
|
|
||||||
### Why did Immich decide to remove object detection?
|
|
||||||
|
|
||||||
The feature added keywords to images for metadata search, but wasn't used for smart search. Smart search made it unnecessary as it isn't limited to exact keywords. Combined with it causing crashes on some devices, using many dependencies and causing user confusion as to how search worked, it was better to remove the job altogether.
|
|
||||||
For more info see [here](https://github.com/immich-app/immich/pull/5903)
|
|
||||||
|
|
||||||
### Can I use a custom CLIP model?
|
### Can I use a custom CLIP model?
|
||||||
|
|
||||||
No, this is not supported. Only models listed in the [Huggingface](https://huggingface.co/immich-app) page are compatible. Feel free to make a feature request if there's a model not listed here that you think should be added.
|
No, this is not supported. Only models listed in the [Huggingface][huggingface] page are compatible. Feel free to make a feature request if there's a model not listed here that you think should be added.
|
||||||
|
|
||||||
### I want to be able to search in other languages besides English. How can I do that?
|
### I want to be able to search in other languages besides English. How can I do that?
|
||||||
|
|
||||||
You can change to a multilingual model listed [here](https://huggingface.co/collections/immich-app/multilingual-clip-654eb08c2382f591eeb8c2a7) by going to Administration > Machine Learning Settings > Smart Search and replacing the name of the model. Be sure to re-run Smart Search on all assets after this change. You can then search in over 100 languages.
|
You can change to a multilingual model listed [here](https://huggingface.co/collections/immich-app/multilingual-clip-654eb08c2382f591eeb8c2a7) by going to Administration > Machine Learning Settings > Smart Search and replacing the name of the model. Be sure to re-run Smart Search on all assets after this change. You can then search in over 100 languages.
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
Feel free to make a feature request if there's a model you want to use that isn't in [Immich Huggingface list](https://huggingface.co/immich-app).
|
Feel free to make a feature request if there's a model you want to use that isn't in [Immich Huggingface list][huggingface].
|
||||||
:::
|
:::
|
||||||
|
|
||||||
### Does Immich support Facial Recognition for videos ?
|
### Does Immich support Facial Recognition for videos?
|
||||||
|
|
||||||
Immich's machine learning feature operate on the generated thumbnail. If a face is visible in the video's thumbnail it will be picked up by facial recognition.
|
Immich's machine learning feature operates on the generated thumbnail. If a face is visible in the video's thumbnail it will be picked up by facial recognition.
|
||||||
Scanning the entire video for faces may be implemented in the future.
|
Scanning the entire video for faces may be implemented in the future.
|
||||||
|
|
||||||
### Does Immich have animal recognition?
|
### Does Immich have animal recognition?
|
||||||
|
|
||||||
No.
|
No.
|
||||||
|
:::tip
|
||||||
|
You can use [Smart Search](/docs/features/smart-search.md) for this to some extent. For example, if you have a Golden Retriever and a Chihuahua, type these words in the smart search and watch the results.
|
||||||
|
:::
|
||||||
|
|
||||||
### I'm getting a lot of "faces" that aren't faces, what can I do?
|
### I'm getting a lot of "faces" that aren't faces, what can I do?
|
||||||
|
|
||||||
@@ -224,14 +248,19 @@ to increase the bar for what the algorithm considers a "core face" for that pers
|
|||||||
|
|
||||||
### The immich_model-cache volume takes up a lot of space, what could be the problem?
|
### The immich_model-cache volume takes up a lot of space, what could be the problem?
|
||||||
|
|
||||||
If you installed several models and chose not to use some of them, it might be worth deleting the old models that are in immich_model-cache.
|
If you installed several models and chose not to use some of them, it might be worth deleting the old models that are in immich_model-cache. To do this you can mount the model cache and remove the undesired models.
|
||||||
|
|
||||||
To do this you can run:
|
<details>
|
||||||
|
<summary>Steps</summary>
|
||||||
|
|
||||||
- `docker run -it --rm -v immich_model-cache:/mnt ubuntu bash`
|
```bash
|
||||||
- `cd mnt`
|
docker run -it --rm -v immich_model-cache:/mnt-models alpine sh
|
||||||
- `ls`
|
cd /mnt-models
|
||||||
- and delete unused models with `rm -r <model_name>`.
|
ls clip/ facial-recognition/
|
||||||
|
# rm -r clip/ABC facial-recognition/DEF # delete unused models
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -248,13 +277,28 @@ The initial backup is the most intensive due to the number of jobs running. The
|
|||||||
- Lower the job concurrency for these jobs to 1.
|
- Lower the job concurrency for these jobs to 1.
|
||||||
- Under Settings > Transcoding Settings > Threads, set the number of threads to a low number like 1 or 2.
|
- Under Settings > Transcoding Settings > Threads, set the number of threads to a low number like 1 or 2.
|
||||||
- Under Settings > Machine Learning Settings > Facial Recognition > Model Name, you can change the facial recognition model to `buffalo_s` instead of `buffalo_l`. The former is a smaller and faster model, albeit not as good.
|
- Under Settings > Machine Learning Settings > Facial Recognition > Model Name, you can change the facial recognition model to `buffalo_s` instead of `buffalo_l`. The former is a smaller and faster model, albeit not as good.
|
||||||
- You _must_ re-run the Face Detection job for all images after this for facial recognition on new images to work properly.
|
- For facial recognition on new images to work properly, You must re-run the Face Detection job for all images after this.
|
||||||
- If these changes are not enough, see [below](/docs/FAQ#how-can-i-disable-machine-learning) for how you can disable machine learning.
|
- If these changes are not enough, see [below](/docs/FAQ#how-can-i-disable-machine-learning) for instructions on how to disable machine learning.
|
||||||
|
|
||||||
### Can I limit the amount of CPU and RAM usage?
|
### Can I limit the amount of CPU and RAM usage?
|
||||||
|
|
||||||
By default, a container has no resource constraints and can use as much of a given resource as the host's kernel scheduler allows.
|
By default, a container has no resource constraints and can use as much of a given resource as the host's kernel scheduler allows. To limit this, you can add the following to the `docker-compose.yml` block of any containers that you want to have limited resources.
|
||||||
You can look at the [original docker docs](https://docs.docker.com/config/containers/resource_constraints/) or use this [guide](https://www.baeldung.com/ops/docker-memory-limit) to learn how to limit this.
|
|
||||||
|
<details>
|
||||||
|
<summary>docker-compose.yml</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
# Number of CPU threads
|
||||||
|
cpus: '1.00'
|
||||||
|
# Gigabytes of memory
|
||||||
|
memory: '1G'
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
For more details, you can look at the [original docker docs](https://docs.docker.com/config/containers/resource_constraints/) or use this [guide](https://www.baeldung.com/ops/docker-memory-limit).
|
||||||
|
|
||||||
### How can I boost machine learning speed?
|
### How can I boost machine learning speed?
|
||||||
|
|
||||||
@@ -269,13 +313,17 @@ On a normal machine, 2 or 3 concurrent jobs can probably max the CPU. Beyond thi
|
|||||||
|
|
||||||
Do not exaggerate with the amount of jobs because you're probably thoroughly overloading the server.
|
Do not exaggerate with the amount of jobs because you're probably thoroughly overloading the server.
|
||||||
|
|
||||||
More detail can be found [here](https://discord.com/channels/979116623879368755/994044917355663450/1174711719994605708)
|
More details can be found [here](https://discord.com/channels/979116623879368755/994044917355663450/1174711719994605708)
|
||||||
:::
|
:::
|
||||||
|
|
||||||
### Why is Immich using so much of my CPU?
|
### Why is Immich using so much of my CPU?
|
||||||
|
|
||||||
When a large amount of assets are uploaded to Immich it makes sense that the CPU and RAM will be heavily used due to machine learning work and creating image thumbnails.
|
When a large number of assets are uploaded to Immich, it makes sense that the CPU and RAM will be heavily used for machine learning work and creating image thumbnails.
|
||||||
Once this process completes, the percentage of CPU usage will drop to around 3-5% usage
|
Once this process is completed, the percentage of CPU usage will drop to around 3-5% usage
|
||||||
|
|
||||||
|
### My server shows Server Status Offline | Version Unknown what can I do?
|
||||||
|
|
||||||
|
You need to enable Websocket on your reverse proxy.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -288,19 +336,37 @@ Immich components are typically deployed using docker. To see logs for deployed
|
|||||||
### How can I run Immich as a non-root user?
|
### How can I run Immich as a non-root user?
|
||||||
|
|
||||||
You can change the user in the container by setting the `user` argument in `docker-compose.yml` for each service.
|
You can change the user in the container by setting the `user` argument in `docker-compose.yml` for each service.
|
||||||
You may need to add an additional volume to `immich-microservices` that mounts internally to `/usr/src/app/.reverse-geocoding-dump`.
|
You may need to add mount points or docker volumes for the following internal container paths:
|
||||||
|
|
||||||
|
- `immich-machine-learning:/.config`
|
||||||
|
- `immich-machine-learning:/.cache`
|
||||||
|
- `redis:/data`
|
||||||
|
|
||||||
The non-root user/group needs read/write access to the volume mounts, including `UPLOAD_LOCATION`.
|
The non-root user/group needs read/write access to the volume mounts, including `UPLOAD_LOCATION`.
|
||||||
|
|
||||||
### How can I **purge** data from Immich?
|
For a further hardened system, you can add the following block to every container except for `immich_postgres`.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>docker-compose.yml</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
security_opt:
|
||||||
|
# Prevent escalation of privileges after the container is started
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
# Prevent access to raw network traffic
|
||||||
|
- NET_RAW
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### How can I purge data from Immich?
|
||||||
|
|
||||||
Data for Immich comes in two forms:
|
Data for Immich comes in two forms:
|
||||||
|
|
||||||
1. **Metadata** stored in a postgres database, persisted via the `pg_data` volume
|
1. **Metadata** stored in a Postgres database, stored in the `DB_DATA_LOCATION` folder (previously `pg_data` Docker volume).
|
||||||
2. **Files** (originals, thumbs, profile, etc.), stored in the `UPLOAD_LOCATION` folder, more [info](/docs/administration/backup-and-restore#asset-types-and-storage-locations).
|
2. **Files** (originals, thumbs, profile, etc.), stored in the `UPLOAD_LOCATION` folder, more [info](/docs/administration/backup-and-restore#asset-types-and-storage-locations).
|
||||||
|
|
||||||
To remove the **Metadata** you can stop Immich and delete the volume.
|
|
||||||
|
|
||||||
:::warning
|
:::warning
|
||||||
This will destroy your database and reset your instance, meaning that you start from scratch.
|
This will destroy your database and reset your instance, meaning that you start from scratch.
|
||||||
:::
|
:::
|
||||||
@@ -309,13 +375,16 @@ This will destroy your database and reset your instance, meaning that you start
|
|||||||
docker compose down -v
|
docker compose down -v
|
||||||
```
|
```
|
||||||
|
|
||||||
|
After removing the containers and volumes, there are a few directories that need to be deleted to reset Immich to a new installation. Once they are deleted, Immich can be started back up and will be a fresh installation.
|
||||||
|
|
||||||
|
- `DB_DATA_LOCATION` contains the database, media info, and settings.
|
||||||
|
- `UPLOAD_LOCATION` contains all the media uploaded to Immich.
|
||||||
|
|
||||||
:::note Portainer
|
:::note Portainer
|
||||||
If you use portainer, bring down the stack in portainer. Go into the volumes section
|
If you use portainer, bring down the stack in portainer. Go into the volumes section
|
||||||
and remove all the volumes related to immcih then restart the stack.
|
and remove all the volumes related to immich then restart the stack.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
After removing the containers and volumes, the **Files** should be removed from the `UPLOAD_LOCATION` to provide a clean start.
|
|
||||||
|
|
||||||
### Why does the machine learning service report workers crashing?
|
### Why does the machine learning service report workers crashing?
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
@@ -333,3 +402,5 @@ If your version of Immich is below 1.92.0 and the crash occurs after logs about
|
|||||||
### Why does Immich log migration errors on startup?
|
### Why does Immich log migration errors on startup?
|
||||||
|
|
||||||
Sometimes Immich logs errors such as "duplicate key value violates unique constraint" or "column (...) of relation (...) already exists". Because of Immich's container structure, this error can be seen when both immich and immich-microservices start at the same time and attempt to migrate or create the database structure. Since the database migration is run sequentially and inside of transactions, this error message does not cause harm to your installation of Immich and can safely be ignored. If needed, you can manually restart Immich by running `docker restart immich immich-microservices`.
|
Sometimes Immich logs errors such as "duplicate key value violates unique constraint" or "column (...) of relation (...) already exists". Because of Immich's container structure, this error can be seen when both immich and immich-microservices start at the same time and attempt to migrate or create the database structure. Since the database migration is run sequentially and inside of transactions, this error message does not cause harm to your installation of Immich and can safely be ignored. If needed, you can manually restart Immich by running `docker restart immich immich-microservices`.
|
||||||
|
|
||||||
|
[huggingface]: https://huggingface.co/immich-app
|
||||||
|
|||||||
@@ -15,39 +15,43 @@ Immich saves [file paths in the database](https://github.com/immich-app/immich/d
|
|||||||
Refer to the official [postgres documentation](https://www.postgresql.org/docs/current/backup.html) for details about backing up and restoring a postgres database.
|
Refer to the official [postgres documentation](https://www.postgresql.org/docs/current/backup.html) for details about backing up and restoring a postgres database.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
The recommended way to backup and restore the Immich database is to use the `pg_dumpall` command.
|
The recommended way to backup and restore the Immich database is to use the `pg_dumpall` command. When restoring, you need to delete the `DB_DATA_LOCATION` folder (if it exists) to reset the database.
|
||||||
|
|
||||||
<Tabs>
|
<Tabs>
|
||||||
<TabItem value="Linux system based Backup" label="Linux system based Backup" default>
|
<TabItem value="Linux system" label="Linux system" default>
|
||||||
|
|
||||||
```bash title='Bash'
|
```bash title='Backup'
|
||||||
docker exec -t immich_postgres pg_dumpall -c -U postgres | gzip > "/path/to/backup/dump.sql.gz"
|
docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres | gzip > "/path/to/backup/dump.sql.gz"
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash title='Restore'
|
```bash title='Restore'
|
||||||
docker compose down -v # CAUTION! Deletes all Immich data to start from scratch.
|
docker compose down -v # CAUTION! Deletes all Immich data to start from scratch.
|
||||||
|
# rm -rf DB_DATA_LOCATION # CAUTION! Deletes all Immich data to start from scratch.
|
||||||
docker compose pull # Update to latest version of Immich (if desired)
|
docker compose pull # Update to latest version of Immich (if desired)
|
||||||
docker compose create # Create Docker containers for Immich apps without running them.
|
docker compose create # Create Docker containers for Immich apps without running them.
|
||||||
docker start immich_postgres # Start Postgres server
|
docker start immich_postgres # Start Postgres server
|
||||||
sleep 10 # Wait for Postgres server to start up
|
sleep 10 # Wait for Postgres server to start up
|
||||||
gunzip < "/path/to/backup/dump.sql.gz" | docker exec -i immich_postgres psql -U postgres -d immich # Restore Backup
|
gunzip < "/path/to/backup/dump.sql.gz" \
|
||||||
|
| sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" \
|
||||||
|
| docker exec -i immich_postgres psql --username=postgres # Restore Backup
|
||||||
docker compose up -d # Start remainder of Immich apps
|
docker compose up -d # Start remainder of Immich apps
|
||||||
```
|
```
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem value="Windows system based Backup" label="Windows system based Backup">
|
<TabItem value="Windows system (PowerShell)" label="Windows system (PowerShell)">
|
||||||
|
|
||||||
```powershell title='Backup'
|
```powershell title='Backup'
|
||||||
docker exec -t immich_postgres pg_dumpall -c -U postgres > "\path\to\backup\dump.sql"
|
docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres > "\path\to\backup\dump.sql"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell title='Restore'
|
```powershell title='Restore'
|
||||||
docker compose down -v # CAUTION! Deletes all Immich data to start from scratch.
|
docker compose down -v # CAUTION! Deletes all Immich data to start from scratch.
|
||||||
|
# Remove-Item -Recurse -Force DB_DATA_LOCATION # CAUTION! Deletes all Immich data to start from scratch.
|
||||||
docker compose pull # Update to latest version of Immich (if desired)
|
docker compose pull # Update to latest version of Immich (if desired)
|
||||||
docker compose create # Create Docker containers for Immich apps without running them.
|
docker compose create # Create Docker containers for Immich apps without running them.
|
||||||
docker start immich_postgres # Start Postgres server
|
docker start immich_postgres # Start Postgres server
|
||||||
sleep 10 # Wait for Postgres server to start up
|
sleep 10 # Wait for Postgres server to start up
|
||||||
gc "C:\path\to\backup\dump.sql" | docker exec -i immich_postgres psql -U postgres -d immich # Restore Backup
|
gc "C:\path\to\backup\dump.sql" | docker exec -i immich_postgres psql --username=postgres # Restore Backup
|
||||||
docker compose up -d # Start remainder of Immich apps
|
docker compose up -d # Start remainder of Immich apps
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -56,6 +60,10 @@ docker compose up -d # Start remainder of Immich apps
|
|||||||
|
|
||||||
Note that for the database restore to proceed properly, it requires a completely fresh install (i.e. the Immich server has never run since creating the Docker containers). If the Immich app has run, Postgres conflicts may be encountered upon database restoration (relation already exists, violated foreign key constraints, multiple primary keys, etc.).
|
Note that for the database restore to proceed properly, it requires a completely fresh install (i.e. the Immich server has never run since creating the Docker containers). If the Immich app has run, Postgres conflicts may be encountered upon database restoration (relation already exists, violated foreign key constraints, multiple primary keys, etc.).
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
Some deployment methods make it difficult to start the database without also starting the server or microservices. In these cases, you may set the environmental variable `DB_SKIP_MIGRATIONS=true` before starting the services. This will prevent the server from running migrations that interfere with the restore process. Note that both the server and microservices must have this variable set to prevent the migrations from running. Be sure to remove this variable and restart the services after the database is restored.
|
||||||
|
:::
|
||||||
|
|
||||||
The database dumps can also be automated (using [this image](https://github.com/prodrigestivill/docker-postgres-backup-local)) by editing the docker compose file to match the following:
|
The database dumps can also be automated (using [this image](https://github.com/prodrigestivill/docker-postgres-backup-local)) by editing the docker compose file to match the following:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -63,15 +71,17 @@ services:
|
|||||||
...
|
...
|
||||||
backup:
|
backup:
|
||||||
container_name: immich_db_dumper
|
container_name: immich_db_dumper
|
||||||
image: prodrigestivill/postgres-backup-local
|
image: prodrigestivill/postgres-backup-local:14
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_HOST: database
|
POSTGRES_HOST: database
|
||||||
POSTGRES_DB: ${DB_DATABASE_NAME}
|
POSTGRES_CLUSTER: 'TRUE'
|
||||||
POSTGRES_USER: ${DB_USERNAME}
|
POSTGRES_USER: ${DB_USERNAME}
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
POSTGRES_DB: ${DB_DATABASE_NAME}
|
||||||
SCHEDULE: "@daily"
|
SCHEDULE: "@daily"
|
||||||
|
POSTGRES_EXTRA_OPTS: '--clean --if-exists'
|
||||||
BACKUP_DIR: /db_dumps
|
BACKUP_DIR: /db_dumps
|
||||||
volumes:
|
volumes:
|
||||||
- ./db_dumps:/db_dumps
|
- ./db_dumps:/db_dumps
|
||||||
@@ -82,17 +92,29 @@ services:
|
|||||||
Then you can restore with the same command but pointed at the latest dump.
|
Then you can restore with the same command but pointed at the latest dump.
|
||||||
|
|
||||||
```bash title='Automated Restore'
|
```bash title='Automated Restore'
|
||||||
gunzip < db_dumps/last/immich-latest.sql.gz | docker exec -i immich_postgres psql -U postgres -d immich
|
gunzip < db_dumps/last/immich-latest.sql.gz \
|
||||||
|
| sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" \
|
||||||
|
| docker exec -i immich_postgres psql --username=postgres
|
||||||
```
|
```
|
||||||
|
|
||||||
|
:::note
|
||||||
|
If you see the error `ERROR: type "earth" does not exist`, or you have problems with Reverse Geocoding after a restore, add the following `sed` fragment to your restore command.
|
||||||
|
|
||||||
|
Example: `gunzip < "/path/to/backup/dump.sql.gz" | sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" | docker exec -i immich_postgres psql --username=postgres`
|
||||||
|
:::
|
||||||
|
|
||||||
## Filesystem
|
## Filesystem
|
||||||
|
|
||||||
Immich stores two types of content in the filesystem: (1) original, unmodified content, and (2) generated content. Only the original content needs to be backed-up, which includes the following folders:
|
Immich stores two types of content in the filesystem: (1) original, unmodified assets (photos and videos), and (2) generated content. Only the original content needs to be backed-up, which is stored in the following folders:
|
||||||
|
|
||||||
1. `UPLOAD_LOCATION/library`
|
1. `UPLOAD_LOCATION/library`
|
||||||
2. `UPLOAD_LOCATION/upload`
|
2. `UPLOAD_LOCATION/upload`
|
||||||
3. `UPLOAD_LOCATION/profile`
|
3. `UPLOAD_LOCATION/profile`
|
||||||
|
|
||||||
|
:::caution
|
||||||
|
If you moved some of these folders onto a different storage device, such as `profile/`, make sure to adjust the backup path to match your setup
|
||||||
|
:::
|
||||||
|
|
||||||
### Asset Types and Storage Locations
|
### Asset Types and Storage Locations
|
||||||
|
|
||||||
Some storage locations are impacted by the Storage Template. See below for more details.
|
Some storage locations are impacted by the Storage Template. See below for more details.
|
||||||
@@ -101,7 +123,8 @@ Some storage locations are impacted by the Storage Template. See below for more
|
|||||||
<TabItem value="Storage Template Off (Default)." label="Storage Template Off (Default)." default>
|
<TabItem value="Storage Template Off (Default)." label="Storage Template Off (Default)." default>
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
`UPLOAD_LOCATION/library` folder is not used by default on new machines running version 1.92.0. These are if the system administrator activated the storage template engine, for [more info](https://github.com/immich-app/immich/releases#:~:text=the%20partner%E2%80%99s%20assets.-,Hardening%20storage%20template,-We%20have%20further).
|
The `UPLOAD_LOCATION/library` folder is not used by default on new machines running version 1.92.0. It is used only if the system administrator activated the storage template engine,
|
||||||
|
for more info read the [release notes](https://github.com/immich-app/immich/releases/tag/v1.92.0#:~:text=the%20partner%E2%80%99s%20assets.-,Hardening%20storage%20template).
|
||||||
:::
|
:::
|
||||||
|
|
||||||
**1. User-Specific Folders:**
|
**1. User-Specific Folders:**
|
||||||
@@ -113,16 +136,16 @@ Some storage locations are impacted by the Storage Template. See below for more
|
|||||||
|
|
||||||
- **Source Assets:**
|
- **Source Assets:**
|
||||||
- Original assets uploaded through the browser interface & mobile & CLI.
|
- Original assets uploaded through the browser interface & mobile & CLI.
|
||||||
- Stored in `/library/upload/<userID>`.
|
- Stored in `UPLOAD_LOCATION/upload/<userID>`.
|
||||||
- **Avatar Images:**
|
- **Avatar Images:**
|
||||||
- User profile images.
|
- User profile images.
|
||||||
- Stored in `/library/profile/<userID>`.
|
- Stored in `UPLOAD_LOCATION/profile/<userID>`.
|
||||||
- **Thumbs Images:**
|
- **Thumbs Images:**
|
||||||
- Preview images (blurred, small, large) for each asset and thumbnails for recognized faces.
|
- Preview images (small thumbnails and large previews) for each asset and thumbnails for recognized faces.
|
||||||
- Stored in `/library/thumbs/<userID>`.
|
- Stored in `UPLOAD_LOCATION/thumbs/<userID>`.
|
||||||
- **Encoded Assets:**
|
- **Encoded Assets:**
|
||||||
- By default, unless otherwise specified re-encoded video assets for wider compatibility.
|
- Videos that have been re-encoded from the original for wider compatibility. The original is not removed.
|
||||||
- Stored in `/library/encoded-video/<userID>`.
|
- Stored in `UPLOAD_LOCATION/encoded-video/<userID>`.
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem value="Storage Template On" label="Storage Template On">
|
<TabItem value="Storage Template On" label="Storage Template On">
|
||||||
@@ -130,34 +153,34 @@ Some storage locations are impacted by the Storage Template. See below for more
|
|||||||
:::note
|
:::note
|
||||||
If you choose to activate the storage template engine, it will move all assets to `UPLOAD_LOCATION/library/<userID>`.
|
If you choose to activate the storage template engine, it will move all assets to `UPLOAD_LOCATION/library/<userID>`.
|
||||||
|
|
||||||
When you turn off the storage template engine, it will leave the assets in `UPLOAD_LOCATION/library/<userID>` and will not return them to `/library/upload`.
|
When you turn off the storage template engine, it will leave the assets in `UPLOAD_LOCATION/library/<userID>` and will not return them to `UPLOAD_LOCATION/upload`.
|
||||||
**New assets** will be saved to `/library/upload`.
|
**New assets** will be saved to `UPLOAD_LOCATION/upload`.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
**1. User-Specific Folders:**
|
**1. User-Specific Folders:**
|
||||||
|
|
||||||
- Each user has a unique string representing them.
|
- Each user has a unique string representing them.
|
||||||
- The main user is "Admin" (but only for `UPLOAD_LOCATION/library`)
|
- The administrator can set a Storage Label for a user, which will be used instead of `<userID>` for the `library/` folder.
|
||||||
- Other users have different string identifiers.
|
- The Admin has a default storage label of `admin`.
|
||||||
- You can find your user ID in Account Account Settings -> Account -> User ID.
|
- You can find your user ID and Storage Label in Account Account Settings -> Account -> User ID.
|
||||||
|
|
||||||
**2. Asset Types and Storage Locations:**
|
**2. Asset Types and Storage Locations:**
|
||||||
|
|
||||||
- **Source Assets:**
|
- **Source Assets:**
|
||||||
- Original assets uploaded through the browser interface & mobile & CLI.
|
- Original assets uploaded through the browser interface, mobile, and CLI.
|
||||||
- Stored in `UPLOAD_LOCATION/library/<userID>`.
|
- Stored in `UPLOAD_LOCATION/library/<userID>`.
|
||||||
- **Avatar Images:**
|
- **Avatar Images:**
|
||||||
- User profile images.
|
- User profile images.
|
||||||
- Stored in `/library/profile/<userID>`.
|
- Stored in `UPLOAD_LOCATION/profile/<userID>`.
|
||||||
- **Thumbs Images:**
|
- **Thumbs Images:**
|
||||||
- Preview images (blurred, small, large) for each asset and thumbnails for recognized faces.
|
- Preview images (blurred, small, large) for each asset and thumbnails for recognized faces.
|
||||||
- Stored in `/library/thumbs/<userID>`.
|
- Stored in `UPLOCAD_LOCATION/thumbs/<userID>`.
|
||||||
- **Encoded Assets:**
|
- **Encoded Assets:**
|
||||||
- By default, unless otherwise specified re-encoded video assets for wider compatibility .
|
- Videos that have been re-encoded from the original for wider compatibility. The original is not removed.
|
||||||
- Stored in `/library/encoded-video/<userID>`.
|
- Stored in `UPLOAD_LOCATION/encoded-video/<userID>`.
|
||||||
- **Files in Upload Queue (Mobile):**
|
- **Files in Upload Queue (Mobile):**
|
||||||
- Files uploaded through mobile apps.
|
- Files uploaded through mobile apps.
|
||||||
- Temporarily located in `/library/upload/<userID>`.
|
- Temporarily located in `UPLOAD_LOCATION/upload/<userID>`.
|
||||||
- Transferred to `UPLOAD_LOCATION/library/<userID>` upon successful upload.
|
- Transferred to `UPLOAD_LOCATION/library/<userID>` upon successful upload.
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 36 KiB |
@@ -1,9 +1,13 @@
|
|||||||
# Jobs
|
# Jobs
|
||||||
|
|
||||||
Several Immich functionalities are implemented as jobs, which run in the background. To view the status of a job navigate to the Administration Screen, and then the `Jobs` page.
|
The `immich-server` responds to API requests for data and files for the web and mobile app. To do this quickly and reliably, it offloads most other work to `immich-microservices` in the form of _jobs_. Simply put, a job is a request to process data in the background. Jobs are picked up automatically by microservices containers.
|
||||||
|
|
||||||

|
When a new asset is uploaded it kicks off a series of jobs, which include metadata extraction, thumbnail generation, machine learning tasks, and storage template migration, if enabled. To view the status of a job navigate to the Administration -> Jobs page.
|
||||||
|
|
||||||
|
Additionally, some jobs run on a schedule, which is every night at midnight. This schedule, with the exception of [External Libraries](/docs/features/libraries) scanning, cannot be changed.
|
||||||
|
|
||||||
:::info
|
:::info
|
||||||
Storage Migration job can be run after changing the [Storage Template](/docs/administration/storage-template.mdx), in order to apply the change to the existing library.
|
Storage Migration job can be run after changing the [Storage Template](/docs/administration/storage-template.mdx), in order to apply the change to the existing library.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
<img src={require('./img/admin-jobs.png').default} width="80%" title="Admin jobs" />
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Unable to set `app.immich:/` as a valid redirect URI? See [Mobile Redirect URI](
|
|||||||
Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an identity layer built on top of OAuth2. OIDC is supported by most identity providers, including:
|
Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an identity layer built on top of OAuth2. OIDC is supported by most identity providers, including:
|
||||||
|
|
||||||
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
|
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
|
||||||
- [Authelia](https://www.authelia.com/configuration/identity-providers/open-id-connect/)
|
- [Authelia](https://www.authelia.com/configuration/identity-providers/openid-connect/clients/)
|
||||||
- [Okta](https://www.okta.com/openid-connect/)
|
- [Okta](https://www.okta.com/openid-connect/)
|
||||||
- [Google](https://developers.google.com/identity/openid-connect/openid-connect)
|
- [Google](https://developers.google.com/identity/openid-connect/openid-connect)
|
||||||
|
|
||||||
@@ -52,8 +52,8 @@ Before enabling OAuth in Immich, a new client application needs to be configured
|
|||||||
|
|
||||||
Hostname
|
Hostname
|
||||||
|
|
||||||
- `https://immich.example.com/auth/login`)
|
- `https://immich.example.com/auth/login`
|
||||||
- `https://immich.example.com/user-settings`)
|
- `https://immich.example.com/user-settings`
|
||||||
|
|
||||||
## Enable OAuth
|
## Enable OAuth
|
||||||
|
|
||||||
@@ -67,14 +67,20 @@ Once you have a new OAuth client application configured, Immich can be configure
|
|||||||
| Client Secret | string | (required) | Required. Client Secret (previous step) |
|
| Client Secret | string | (required) | Required. Client Secret (previous step) |
|
||||||
| Scope | string | openid email profile | Full list of scopes to send with the request (space delimited) |
|
| Scope | string | openid email profile | Full list of scopes to send with the request (space delimited) |
|
||||||
| Signing Algorithm | string | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) |
|
| Signing Algorithm | string | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) |
|
||||||
| Storage Label Claim | string | preferred_username | Claim mapping for the user's storage label |
|
| Storage Label Claim | string | preferred_username | Claim mapping for the user's storage label**¹** |
|
||||||
| Storage Quota Claim | string | immich_quota | Claim mapping for the user's storage |
|
| Storage Quota Claim | string | immich_quota | Claim mapping for the user's storage**¹** |
|
||||||
| Default Storage Quota (GiB) | number | 0 | Default quota for user without storage quota claim (Enter 0 for unlimited quota) |
|
| Default Storage Quota (GiB) | number | 0 | Default quota for user without storage quota claim (Enter 0 for unlimited quota) |
|
||||||
| Button Text | string | Login with OAuth | Text for the OAuth button on the web |
|
| Button Text | string | Login with OAuth | Text for the OAuth button on the web |
|
||||||
| Auto Register | boolean | true | When true, will automatically register a user the first time they sign in |
|
| Auto Register | boolean | true | When true, will automatically register a user the first time they sign in |
|
||||||
| [Auto Launch](#auto-launch) | boolean | false | When true, will skip the login page and automatically start the OAuth login process |
|
| [Auto Launch](#auto-launch) | boolean | false | When true, will skip the login page and automatically start the OAuth login process |
|
||||||
| [Mobile Redirect URI Override](#mobile-redirect-uri) | URL | (empty) | Http(s) alternative mobile redirect URI |
|
| [Mobile Redirect URI Override](#mobile-redirect-uri) | URL | (empty) | Http(s) alternative mobile redirect URI |
|
||||||
|
|
||||||
|
:::note Claim Options [1]
|
||||||
|
|
||||||
|
Claim is only used on user creation and not synchronized after that.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
:::info
|
:::info
|
||||||
The Issuer URL should look something like the following, and return a valid json document.
|
The Issuer URL should look something like the following, and return a valid json document.
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
# Password Login
|
|
||||||
|
|
||||||
An overview of password login and related settings for Immich.
|
|
||||||
|
|
||||||
## Enable/Disable
|
|
||||||
|
|
||||||
Immich supports password login, which is enabled by default. The preferred way to disable it is via the [Administration Page](#administration-page), although it can also be changed via a [Server Command](#server-command) as well.
|
|
||||||
|
|
||||||
### Administration Page
|
|
||||||
|
|
||||||
To toggle the password login setting via the web, navigate to the "Administration", expand "Password Authentication", toggle the "Enabled" switch, and press "Save".
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### Server Command
|
|
||||||
|
|
||||||
There are two [Server Commands](/docs/administration/server-commands.md) for password login:
|
|
||||||
|
|
||||||
1. `enable-password-login`
|
|
||||||
2. `disable-password-login`
|
|
||||||
|
|
||||||
See [Server Commands](/docs/administration/server-commands.md) for more details about how to run them.
|
|
||||||
|
|
||||||
## Password Reset
|
|
||||||
|
|
||||||
### Admin
|
|
||||||
|
|
||||||
To reset the administrator password, use the `reset-admin-password` [Server Command](/docs/administration/server-commands.md).
|
|
||||||
|
|
||||||
### User
|
|
||||||
|
|
||||||
Immich does not currently support self-service password reset. However, the administration can reset passwords for other users. See [User Management: Password Reset](/docs/administration/user-management.mdx#password-reset) for more information about how to do this.
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Pre-existing Postgres
|
||||||
|
|
||||||
|
While not officially recommended, it is possible to run Immich using a pre-existing Postgres server. To use this setup, you should have a baseline level of familiarity with Postgres and the Linux command line. If you do not have these, we recommend using the default setup with a dedicated Postgres container.
|
||||||
|
|
||||||
|
By default, Immich expects superuser permission on the Postgres database and requires certain extensions to be installed. This guide outlines the steps required to prepare a pre-existing Postgres server to be used by Immich.
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
Running with a pre-existing Postgres server can unlock powerful administrative features, including logical replication, data page checksums, and streaming write-ahead log backups using programs like pgBackRest or Barman.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
You must install pgvecto.rs into your instance of Postgres using their [instructions][vectors-install]. After installation, add `shared_preload_libraries = 'vectors.so'` to your `postgresql.conf`. If you already have some `shared_preload_libraries` set, you can separate each extension with a comma. For example, `shared_preload_libraries = 'pg_stat_statements, vectors.so'`.
|
||||||
|
|
||||||
|
:::note
|
||||||
|
Immich is known to work with Postgres versions 14, 15, and 16. Earlier versions are unsupported.
|
||||||
|
|
||||||
|
Make sure the installed version of pgvecto.rs is compatible with your version of Immich. For example, if your Immich version uses the dedicated database image `tensorchord/pgvecto-rs:pg14-v0.2.1`, you must install pgvecto.rs `>= 0.2.1, < 0.3.0`.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Specifying the connection URL
|
||||||
|
|
||||||
|
You can connect to your pre-existing Postgres server by setting the `DB_URL` environment variable in the `.env` file.
|
||||||
|
|
||||||
|
```
|
||||||
|
DB_URL='postgresql://immichdbusername:immichdbpassword@postgreshost:postgresport/immichdatabasename'
|
||||||
|
|
||||||
|
# require a SSL connection to Postgres
|
||||||
|
# DB_URL='postgresql://immichdbusername:immichdbpassword@postgreshost:postgresport/immichdatabasename?sslmode=require'
|
||||||
|
|
||||||
|
# require a SSL connection, but don't enforce checking the certificate name
|
||||||
|
# DB_URL='postgresql://immichdbusername:immichdbpassword@postgreshost:postgresport/immichdatabasename?sslmode=require&sslmode=no-verify'
|
||||||
|
```
|
||||||
|
|
||||||
|
## With superuser permission
|
||||||
|
|
||||||
|
Typically Immich expects superuser permission in the database, which you can grant by running `ALTER USER <immichdbusername> WITH SUPERUSER;` at the `psql` console. If you prefer not to grant superuser permissions, follow the instructions in the next section.
|
||||||
|
|
||||||
|
## Without superuser permission
|
||||||
|
|
||||||
|
:::caution
|
||||||
|
This method is recommended for **advanced users only** and often requires manual intervention when updating Immich.
|
||||||
|
:::
|
||||||
|
|
||||||
|
Immich can run without superuser permissions by following the below instructions at the `psql` prompt to prepare the database.
|
||||||
|
|
||||||
|
```sql title="Set up Postgres for Immich"
|
||||||
|
CREATE DATABASE <immichdatabasename>;
|
||||||
|
\c <immichdatabasename>
|
||||||
|
BEGIN;
|
||||||
|
ALTER DATABASE <immichdatabasename> OWNER TO <immichdbusername>;
|
||||||
|
CREATE EXTENSION vectors;
|
||||||
|
CREATE EXTENSION earthdistance CASCADE;
|
||||||
|
ALTER DATABASE <immichdatabasename> SET search_path TO "$user", public, vectors;
|
||||||
|
ALTER SCHEMA vectors OWNER TO <immichdbusername>;
|
||||||
|
COMMIT;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updating pgvecto.rs
|
||||||
|
|
||||||
|
When installing a new version of pgvecto.rs, you will need to manually update the extension by connecting to the Immich database and running `ALTER EXTENSION vectors UPDATE;`.
|
||||||
|
|
||||||
|
### Common errors
|
||||||
|
|
||||||
|
#### Permission denied for view
|
||||||
|
|
||||||
|
If you get the error `driverError: error: permission denied for view pg_vector_index_stat`, you can fix this by connecting to the Immich database and running `GRANT SELECT ON TABLE pg_vector_index_stat TO <immichdbusername>;`.
|
||||||
|
|
||||||
|
[vectors-install]: https://docs.pgvecto.rs/getting-started/installation.html
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Repair Page
|
||||||
|
|
||||||
|
The repair page is designed to give information to the system administrator about files that are not tracked, or offline paths.
|
||||||
|
|
||||||
|
## Natural State
|
||||||
|
|
||||||
|
In this situation, everything is in its place and there is no problem that the system administrator should be aware of.
|
||||||
|
|
||||||
|
<img src={require('./img/repair-page.png').default} title="server statistic" />
|
||||||
|
|
||||||
|
## Any Other Situation
|
||||||
|
|
||||||
|
:::note RAM Usage
|
||||||
|
Several users report a situation where the page fails to load. In order to solve this problem you should try to allocate more RAM to Immich, if the problem continues, you should stop using the reverse proxy while loading the page.
|
||||||
|
:::
|
||||||
|
|
||||||
|
In any other situation, there are 3 different options that can appear:
|
||||||
|
|
||||||
|
- MATCHES - These files are matched by their checksums.
|
||||||
|
|
||||||
|
- OFFLINE PATHS - These files are the result of manually deleting files in the upload library or a failed file move in the past (losing track of a file).
|
||||||
|
|
||||||
|
- UNTRACKED FILES - These files are not tracked by the application. They can be the result of failed moves, interrupted uploads, or left behind due to a bug.
|
||||||
|
|
||||||
|
In addition, you can download the information from a page, mark everything (in order to check hashing) and correct the problem if a match is found in the hashing.
|
||||||
|
|
||||||
|
<img src={require('./img/repair-page-1.png').default} title="server statistic" />
|
||||||
@@ -1,29 +1,41 @@
|
|||||||
# Reverse Proxy
|
# Reverse Proxy
|
||||||
|
|
||||||
Users can deploy a custom reverse proxy that forwards requests to Immich. This way, the reverse proxy can handle TLS termination, load balancing, or other advanced features. All reverse proxies between Immich and the user must forward all headers and set the `Host`, `X-Forwarded-Host`, `X-Forwarded-Proto` and `X-Forwarded-For` headers to their appropriate values. Additionally, your reverse proxy should allow for big enough uploads. By following these practices, you ensure that all custom reverse proxies are fully compatible with Immich.
|
Users can deploy a custom reverse proxy that forwards requests to Immich. This way, the reverse proxy can handle TLS termination, load balancing, or other advanced features. All reverse proxies between Immich and the user must forward all headers and set the `Host`, `X-Real-IP`, `X-Forwarded-Proto` and `X-Forwarded-For` headers to their appropriate values. Additionally, your reverse proxy should allow for big enough uploads. By following these practices, you ensure that all custom reverse proxies are fully compatible with Immich.
|
||||||
|
|
||||||
|
:::note
|
||||||
|
The Repair page can take a long time to load. To avoid server timeouts or errors, we recommend specifying a timeout of at least 10 minutes on your proxy server.
|
||||||
|
:::
|
||||||
|
|
||||||
### Nginx example config
|
### Nginx example config
|
||||||
|
|
||||||
Below is an example config for nginx. Make sure to include `client_max_body_size 50000M;` also in a `http` block in `/etc/nginx/nginx.conf`.
|
Below is an example config for nginx. Make sure to set `public_url` to the front-facing URL of your instance, and `backend_url` to the path of the Immich server.
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
server_name <snip>
|
server_name <public_url>;
|
||||||
|
|
||||||
|
# allow large file uploads
|
||||||
client_max_body_size 50000M;
|
client_max_body_size 50000M;
|
||||||
|
|
||||||
location / {
|
# Set headers
|
||||||
proxy_pass http://<snip>:2283;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
# http://nginx.org/en/docs/http/websocket.html
|
# enable websockets: http://nginx.org/en/docs/http/websocket.html
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_redirect off;
|
proxy_redirect off;
|
||||||
|
|
||||||
|
# set timeout
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
proxy_send_timeout 600s;
|
||||||
|
send_timeout 600s;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://<backend_url>:2283;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -42,15 +54,13 @@ immich.example.org {
|
|||||||
|
|
||||||
Below is an example config for Apache2 site configuration.
|
Below is an example config for Apache2 site configuration.
|
||||||
|
|
||||||
```
|
```ApacheConf
|
||||||
<VirtualHost *:80>
|
<VirtualHost *:80>
|
||||||
ServerName <snip>
|
ServerName <snip>
|
||||||
ProxyRequests Off
|
ProxyRequests Off
|
||||||
|
# set timeout in seconds
|
||||||
ProxyPass / http://127.0.0.1:2283/ timeout=600 upgrade=websocket
|
ProxyPass / http://127.0.0.1:2283/ timeout=600 upgrade=websocket
|
||||||
ProxyPassReverse / http://127.0.0.1:2283/
|
ProxyPassReverse / http://127.0.0.1:2283/
|
||||||
ProxyPreserveHost On
|
ProxyPreserveHost On
|
||||||
|
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
```
|
```
|
||||||
|
|
||||||
**timeout:** is measured in seconds, and it is particularly useful when long operations are triggered (i.e. Repair), so the server doesn't return an error.
|
|
||||||
|
|||||||