mirror of
https://github.com/immich-app/immich.git
synced 2026-05-21 23:26:31 -04:00
Compare commits
657 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54d7b0feb9 | |||
| 05334569e3 | |||
| c72ad193fa | |||
| 6f8fb9e42b | |||
| 746599319b | |||
| 62578cfad5 | |||
| 4d886ba0ad | |||
| 3005ff0cc4 | |||
| 9ea4a03f21 | |||
| 69143a53b7 | |||
| 86255f8c31 | |||
| b3b651aec0 | |||
| 5e4b64670c | |||
| dd7a51a2d9 | |||
| 646b8249ca | |||
| 352c129d92 | |||
| 3ac91797e8 | |||
| a7d634bacd | |||
| 11cf9ffd85 | |||
| 220891d533 | |||
| d4808fdc4d | |||
| 7fa967a98e | |||
| 9cffcc9f4e | |||
| 40925f0a06 | |||
| 0544d22902 | |||
| 3d075f2bf8 | |||
| 7384799f19 | |||
| 4a7f06e8fd | |||
| 8f662fc459 | |||
| 24b1dae9f2 | |||
| 3a3469a5f9 | |||
| 7993619ed2 | |||
| 4d1f6f869b | |||
| 3eb03f7934 | |||
| 03ed3daa31 | |||
| 02581e81a7 | |||
| 3ab3d5cf43 | |||
| 0ef04d9baa | |||
| df016f9228 | |||
| 17779c1e74 | |||
| 01d6a244d8 | |||
| 21d6755f39 | |||
| e91c017dd0 | |||
| 43687cd8b4 | |||
| 06729ee5a5 | |||
| b0c9743d9a | |||
| 37cc028868 | |||
| 84a2b7a3c8 | |||
| 89b3433346 | |||
| 3ff0d47ee3 | |||
| aeaf846482 | |||
| b031548791 | |||
| fcea617313 | |||
| 024f20ea26 | |||
| 0a4ed6fd71 | |||
| b6e2ce1f35 | |||
| e323e778cd | |||
| 6a87797649 | |||
| f4a4649bbc | |||
| 6ca54ee722 | |||
| 8e3035f783 | |||
| 79801595db | |||
| 3e1c8aacb1 | |||
| 91ac56cef2 | |||
| 58beac8fe0 | |||
| f632d320f5 | |||
| 2ddaf6a611 | |||
| 1932c60e1c | |||
| dc6f8e746e | |||
| ad7aedb843 | |||
| 571e6a8560 | |||
| 4791313def | |||
| f88fdae048 | |||
| bcef7aa6b6 | |||
| ce292bdce9 | |||
| 4eee023648 | |||
| 8f4b0fce49 | |||
| c6b3127b35 | |||
| 4d6a50c2cb | |||
| 15f3947ae6 | |||
| e142e3aca7 | |||
| 38438c8d9a | |||
| a278c10c75 | |||
| 2276443c56 | |||
| bb44773e57 | |||
| 14d9e90a03 | |||
| 03e042213c | |||
| db589455f4 | |||
| fb0a54d548 | |||
| 7013cc0904 | |||
| dcaf7b4a65 | |||
| 12f7b2a005 | |||
| 7837d40f57 | |||
| b4f719653f | |||
| f370b4bac6 | |||
| d788169bf3 | |||
| eea820fa2f | |||
| 271f1cb868 | |||
| 8c8dc9d32f | |||
| fd18e55f7c | |||
| faab9e620d | |||
| 5ba3efafd8 | |||
| 8b3c9bf9c3 | |||
| 41f285aa3e | |||
| fdac6c8bc4 | |||
| d7f05d2510 | |||
| 3100bd5eed | |||
| 8a024e2b50 | |||
| 25a6a38b30 | |||
| 7c6750941e | |||
| 832ed4d015 | |||
| 238895cad9 | |||
| e2ec04e86c | |||
| 6050526360 | |||
| bfd76570c5 | |||
| 37e6a49652 | |||
| 36caeb34ec | |||
| 87713c7f2f | |||
| 2039c129f2 | |||
| 52b00b0bad | |||
| 21af184045 | |||
| 1fcc2b704b | |||
| 7de73dc176 | |||
| fe2bf0c6dd | |||
| d4a97f2d25 | |||
| bd58db4fcc | |||
| 7f43c6a3a3 | |||
| 87175ee56c | |||
| 13587bf13c | |||
| f09769a2f3 | |||
| bfdff12ee0 | |||
| eb6dca6a31 | |||
| c2e3739a58 | |||
| f6bd514cdc | |||
| d93ab7707e | |||
| 6bb47c802f | |||
| 90a69e2ba6 | |||
| 6580394cfe | |||
| 42ff3b705d | |||
| 0f00053bb1 | |||
| c5c59ed040 | |||
| 576b1eb999 | |||
| 24189702da | |||
| ad0d01005e | |||
| 3e6d053f93 | |||
| 1bb3fd985f | |||
| f72aa54a1f | |||
| dafe9d7966 | |||
| 7acda0572d | |||
| 98bc9f6a6e | |||
| 63a3b405c3 | |||
| 0058df798d | |||
| 97100a4362 | |||
| af39384efb | |||
| 01712cf0a7 | |||
| 2015f95ff5 | |||
| d4f29ab6ac | |||
| 3decc864b5 | |||
| eca0e60db8 | |||
| 8cff5883b5 | |||
| 3d320d9751 | |||
| b9e0e65bdb | |||
| 88e5e8d6ea | |||
| ee107c98d5 | |||
| affe0ac5ee | |||
| f1d8ab8aae | |||
| c0898b96ca | |||
| 5e9bda7fab | |||
| b60e9c6771 | |||
| b554664791 | |||
| 97c62136b7 | |||
| c1051c7ed2 | |||
| 65bd0a9320 | |||
| bf32864644 | |||
| 7ef7ecec5b | |||
| bc4abd18e4 | |||
| b74cfd4424 | |||
| 7dc84f56c0 | |||
| 92634f923b | |||
| 96b6165bd3 | |||
| 2624f3884f | |||
| f9b7ce9407 | |||
| 013ea37a0d | |||
| b2b4385271 | |||
| 081c75bb21 | |||
| da337578fb | |||
| acf4109171 | |||
| 66601a1fdc | |||
| 02ff077367 | |||
| 94bb6c1a5e | |||
| fe9e5afcf4 | |||
| 5e89efba64 | |||
| 5a457d72c9 | |||
| 45ccdb37fb | |||
| 9263e2f2e1 | |||
| a3ee615c5b | |||
| 39cfad7136 | |||
| 350056dd1a | |||
| f0835d06f8 | |||
| 03b70cf029 | |||
| 4bfb8b36c2 | |||
| dfacde5af8 | |||
| 317afe9e3b | |||
| 1fb5f13237 | |||
| 793a7054fb | |||
| 3a874dd441 | |||
| 3dc7dc93d8 | |||
| 70397dc5a6 | |||
| a16d233a0c | |||
| bb0872afef | |||
| b9ca68f6e4 | |||
| 837305da7e | |||
| e20fb44142 | |||
| c2786978cd | |||
| 312bb91a4f | |||
| c1934b904c | |||
| 47752d158a | |||
| 6267322b9c | |||
| 93c3cd49f3 | |||
| f52825ab08 | |||
| d74dc74f92 | |||
| 539a39ae49 | |||
| f68cd424a7 | |||
| 20c0cc7e73 | |||
| be1b9a5f67 | |||
| d9011c0829 | |||
| f909648bce | |||
| c78b1d8ab4 | |||
| 94a34436a3 | |||
| 0eef15a3ab | |||
| 6982896549 | |||
| 2c812a2561 | |||
| 0b1188e42e | |||
| be20cd2bf9 | |||
| b8591cb591 | |||
| 384d3a0984 | |||
| 03af669856 | |||
| b0e4850d76 | |||
| 36ebcaf00c | |||
| 7a86f2b7b9 | |||
| 55f2b3b6a0 | |||
| fd5e8d6521 | |||
| 6798d5df32 | |||
| 9d33853544 | |||
| a46e46452c | |||
| dbf30b77bf | |||
| 8afca348ff | |||
| 2070f775d6 | |||
| a456a05052 | |||
| b7eff33f90 | |||
| 18c0228f1b | |||
| 2f8be45fe0 | |||
| 41968fdcac | |||
| 79c392ceba | |||
| 8fbeb64c59 | |||
| 7d181f0686 | |||
| 2172dde7dc | |||
| fce220b1d7 | |||
| 2a47c35eb7 | |||
| 6aadb7b5bd | |||
| 88bce52042 | |||
| d046f16860 | |||
| 88815a0345 | |||
| 57212f29bf | |||
| 95fa8fbdab | |||
| 687b7cad6f | |||
| ac2ebcee37 | |||
| 3356e81c85 | |||
| 9c642bd6fc | |||
| 9da0cb3cf4 | |||
| 4ff6cca4da | |||
| 2b7ae4981f | |||
| e63df4121a | |||
| 03b4ab2935 | |||
| facd3bd331 | |||
| 20ddf2e7d2 | |||
| 7f0025b3fc | |||
| 60f4dedb29 | |||
| d5d2ebd9bf | |||
| 37abbeba52 | |||
| 50557002b7 | |||
| 4aa31d38bf | |||
| 3d8df74b43 | |||
| 2ff9f95527 | |||
| a69eecf3bc | |||
| 4ffa26c969 | |||
| ac06514db5 | |||
| 792cb9148b | |||
| 8ee5d3039a | |||
| d410131312 | |||
| 5334a6254a | |||
| 79fccdbee0 | |||
| 6dd6053222 | |||
| 8454cb2631 | |||
| 603fc7401f | |||
| ed70e0febf | |||
| 5f5e3344d5 | |||
| 6da2d3d587 | |||
| 41d2d84b21 | |||
| 6ba17bb86f | |||
| e1a84d3ab6 | |||
| 7d8f843be6 | |||
| 3753b7a4d1 | |||
| 84a1fb27ca | |||
| 81780b0cc0 | |||
| 5e81a5a054 | |||
| e4e2f586b5 | |||
| a001adf14a | |||
| 136814540a | |||
| fed5cc1ae1 | |||
| 641ab51b80 | |||
| 3b47ca1c37 | |||
| 8fb2c7755d | |||
| 1ba0989e15 | |||
| daed3f0966 | |||
| 46d612ad8c | |||
| 513dead2c2 | |||
| ca006c1569 | |||
| 4e8e8304fd | |||
| d377d2e145 | |||
| 9c9feddf7d | |||
| bfcf34d8b5 | |||
| 95e57a24cb | |||
| eada662981 | |||
| 352f6ecc28 | |||
| bee49cef02 | |||
| 6d0c6a4008 | |||
| 8a975e5ea9 | |||
| d39e7da10d | |||
| bc400d68ac | |||
| d7f038ec60 | |||
| 26957f37ce | |||
| 3254d31cd2 | |||
| 7b269d1638 | |||
| b5bed02300 | |||
| 5553910236 | |||
| 8d67c1f820 | |||
| ed0ec30917 | |||
| 2b0f6c9202 | |||
| 55ab8c65b6 | |||
| 781d568f29 | |||
| 6a361dae72 | |||
| 64766c8c06 | |||
| 6a63e814a5 | |||
| 6441c3b77c | |||
| b03a649e74 | |||
| 2903b2653b | |||
| 9ba9a22c40 | |||
| f1882c2926 | |||
| 4278789083 | |||
| 921c8a8de3 | |||
| afec61addc | |||
| a1a03efbcd | |||
| 1d0e5cf18d | |||
| de9ec95db1 | |||
| 7f784952eb | |||
| 3d6c7ba353 | |||
| 3be97db118 | |||
| 8f3a99ffbc | |||
| e6d114af10 | |||
| 4e28811f09 | |||
| 4987032e62 | |||
| 572bad8ede | |||
| 95c1f0efeb | |||
| fbe631fe91 | |||
| 2143a0c935 | |||
| 136bd1e2eb | |||
| 564065a3ed | |||
| 9bcce59719 | |||
| cd86a83c33 | |||
| f29c06799f | |||
| 6fcf651d76 | |||
| 196307bca5 | |||
| 776b9cbad5 | |||
| 960be0c27a | |||
| 123119ca0d | |||
| 1772f720bf | |||
| bcc29903de | |||
| 767caf9bfe | |||
| 649d14822a | |||
| 207672c481 | |||
| 4fcd9c2e0d | |||
| a2687d674e | |||
| fb1bc7f9e2 | |||
| 18e8d30b1c | |||
| 95ef60628c | |||
| a19b7148e5 | |||
| 8e414e42f3 | |||
| db0f86c749 | |||
| adb6b39eec | |||
| c8ae99e7d7 | |||
| 37823bcd51 | |||
| b465f2b58f | |||
| 2166f07b1f | |||
| c9e251c78c | |||
| da4b88fc14 | |||
| d1e2e8ab4e | |||
| 2a619d3c10 | |||
| c29493e3a0 | |||
| 4ef777d145 | |||
| 0b40f4fd76 | |||
| ecba4e2a62 | |||
| 4eb531197e | |||
| 505a07a825 | |||
| 548dbe8ad6 | |||
| 0c184940f4 | |||
| be180fd9da | |||
| 859f58174e | |||
| a6c7e76008 | |||
| 0ff94213e6 | |||
| 6b1dd6f680 | |||
| 7d4286bbc5 | |||
| 18201a26d9 | |||
| a2e3635ac9 | |||
| ce346bf956 | |||
| a1a2939868 | |||
| e8309585d6 | |||
| 17d4941089 | |||
| b09ebb11e9 | |||
| 181b028b09 | |||
| eb20b715e4 | |||
| a277c6311f | |||
| 5889c42eb6 | |||
| 14cce0cba3 | |||
| 9b80ffd9c6 | |||
| 306a3b8c7f | |||
| be0fc403d8 | |||
| c13fd9e4b5 | |||
| 8724848fce | |||
| 2d950db940 | |||
| 4b9ebc2cff | |||
| e2d26ebdea | |||
| 8c6adf7157 | |||
| 48fdd39d30 | |||
| 22bf7c2005 | |||
| 47b45453c8 | |||
| 448c069fb6 | |||
| 958f270f0d | |||
| 9f699fdfc3 | |||
| 00da7b88a1 | |||
| 144a57ddff | |||
| 1bd2d474d7 | |||
| b33874ef12 | |||
| dbaf4b548b | |||
| 7d58d5be12 | |||
| 42fe86d24c | |||
| eeb55c279b | |||
| 5c159d70a7 | |||
| 44ae0fa7ed | |||
| f782782662 | |||
| 4436cab827 | |||
| 74789ad1c4 | |||
| 7877097b3f | |||
| fb84c1cf61 | |||
| 940a1d4ab8 | |||
| fae25dbe65 | |||
| 8dd0d7f34c | |||
| 9b78f2c0ba | |||
| 67cedfef17 | |||
| c9c2322b9d | |||
| 389356149a | |||
| 4812a2e2d8 | |||
| 8f01d06927 | |||
| a2ff075e9a | |||
| d8b39906f9 | |||
| b36911a16b | |||
| b074ee202e | |||
| 78bb6cf926 | |||
| c980f5fc19 | |||
| a26d9e05ba | |||
| c862163204 | |||
| 5fb8f9bf1a | |||
| b9b5dba037 | |||
| 8bfa75087c | |||
| 95280edd6c | |||
| a9666d2cef | |||
| 4af9edc20b | |||
| c975fe5bc7 | |||
| 12a4d8e2ee | |||
| ce9b32a61a | |||
| 4ddc288cd1 | |||
| 94b15b8678 | |||
| ff9ae24219 | |||
| b456f78771 | |||
| 1506776891 | |||
| 0e93aa74cf | |||
| e95ad9d2eb | |||
| b98a227bbd | |||
| 2dd785e3e2 | |||
| 7e754125cd | |||
| e2eb03d3a4 | |||
| bf065a834f | |||
| db79173b5b | |||
| 33666ccd21 | |||
| be93b9040c | |||
| 00dae6ac38 | |||
| 5a8fd40dc5 | |||
| 813d684aaa | |||
| 644f705be1 | |||
| f3e4bcc733 | |||
| 9a0c17fdb8 | |||
| b7c4497dfd | |||
| 9c227aeaf5 | |||
| e939fde6f1 | |||
| 019beaed0b | |||
| 0e4d6d4eac | |||
| 79f978ddeb | |||
| f2445ecab1 | |||
| 86311e3cfe | |||
| 29000461c2 | |||
| b30373b24f | |||
| bc2439883a | |||
| 044257531e | |||
| f413f5c692 | |||
| 52307ed09f | |||
| 77020e742a | |||
| 38b135ff36 | |||
| cda4a2a5fc | |||
| 88002cf7fe | |||
| 694ea151f5 | |||
| b092c8b601 | |||
| 48e6e17829 | |||
| 0519833d75 | |||
| 34caed3b2b | |||
| 677cb660f5 | |||
| 9b0b2bfcf2 | |||
| ac6938a629 | |||
| 16749ff8ba | |||
| bba4a00eb1 | |||
| 9dafc8e8e9 | |||
| 4e44fb9cf7 | |||
| 82db581cc5 | |||
| b66c97b785 | |||
| ff936f901d | |||
| 48fe111daa | |||
| 0581b49750 | |||
| 2c6d4f3fe1 | |||
| 55513cd59f | |||
| 10fa928abe | |||
| e322d44f95 | |||
| c2a279e49e | |||
| 226b9390db | |||
| 754f072ef9 | |||
| c91d8745b4 | |||
| f3b7cd6198 | |||
| 990aff441b | |||
| 001d7d083f | |||
| 3fd24e2083 | |||
| 6bb8f4fcc4 | |||
| d4605b21d9 | |||
| 3bd37ebbfb | |||
| 5c3777ab46 | |||
| 6c531e0a5a | |||
| 471c27cd33 | |||
| 4773788a88 | |||
| d49d995611 | |||
| 0ac3d6a83a | |||
| 9996ee12d0 | |||
| 0a79dd1228 | |||
| e45308b949 | |||
| c403e03a42 | |||
| e7db3b220d | |||
| 28d5c169c0 | |||
| 0f2fe656db | |||
| 34ce68095d | |||
| 8764a1894b | |||
| 27f69b39b2 | |||
| 9fc6fbc373 | |||
| 9fc32b6f7a | |||
| 4571940a4e | |||
| 1ceb6d2e21 | |||
| 1a4c5d73ac | |||
| 22b43bf4d9 | |||
| 45eff1c663 | |||
| 56b8e1b8a9 | |||
| f79c8cf1c1 | |||
| 8e50d25f45 | |||
| 8222781d1f | |||
| 08c4594cde | |||
| d325231df2 | |||
| f2726606e0 | |||
| 0edbca24e4 | |||
| 4791d9c0c3 | |||
| a47b232235 | |||
| df0c86920d | |||
| 422111d26e | |||
| 7a83baaf27 | |||
| aaf34fa7d4 | |||
| 4a384bca86 | |||
| dd72ec2621 | |||
| e73686bd76 | |||
| 6e9a425592 | |||
| 6012d22d98 | |||
| abfcffb423 | |||
| ec7246b86f | |||
| 9597f8c37f | |||
| 7b0deb1fd3 | |||
| 5ab05e57fa | |||
| ba3f114625 | |||
| 9b642633c1 | |||
| a05c8c6087 | |||
| 35a521c6ec | |||
| 09fabb36b6 | |||
| c259fee309 | |||
| 78ba9cbc63 | |||
| 33d75462c9 | |||
| e9451f10d6 | |||
| 480b7e8d65 | |||
| 228ac63ab9 | |||
| 7e9da945f6 | |||
| dd03c9c0a9 | |||
| 16e4a2b92a | |||
| 5caa7e1902 | |||
| 8279e1078a | |||
| 011ecbb43d | |||
| 2725c96cb1 | |||
| 3c476b1987 | |||
| 5989c9b4aa | |||
| 13c4260a1f | |||
| 54bc9ddd69 | |||
| f94e0fbc39 | |||
| 5532f669eb | |||
| e4c24bdec8 | |||
| 56f14162f6 | |||
| 8abbbc49cf | |||
| 4eb08eee18 | |||
| 0560f98c2d | |||
| 49ad411d50 | |||
| 2478cc40f4 | |||
| 44eeb1e088 | |||
| a868ae3ad0 | |||
| acac0d4f37 | |||
| 8c40a28fef | |||
| b2081eda1e | |||
| 9670c853c6 | |||
| cc2dacb308 | |||
| 15fc6b18f3 | |||
| a284e38890 | |||
| 05010c3a84 | |||
| 4da3d68a67 | |||
| 20c639e52a | |||
| 6deb97d5bc | |||
| b282d83e95 | |||
| 5bc08f8654 | |||
| f54924d46a | |||
| dffe4d1d5c | |||
| 7f47cdd645 | |||
| 625b30c50a | |||
| 8619d14eca | |||
| 062546c168 | |||
| ea668d6b22 | |||
| f06af2c600 | |||
| 9dd2633e0c | |||
| 13a514c189 | |||
| b0c9120bb6 | |||
| bc4265416d | |||
| d4434f2276 |
@@ -75,7 +75,7 @@
|
||||
{
|
||||
"label": "Build Immich CLI",
|
||||
"type": "shell",
|
||||
"command": "pnpm --filter cli build:dev"
|
||||
"command": "pnpm --filter @immich/cli build:dev"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ services:
|
||||
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- pnpm_store_server:/buildcache/pnpm-store
|
||||
- ../plugins:/build/corePlugin
|
||||
- ../packages/plugin-core:/build/plugins/immich-plugin-core
|
||||
immich-web:
|
||||
env_file: !reset []
|
||||
immich-machine-learning:
|
||||
|
||||
+1
-3
@@ -30,9 +30,7 @@ machine-learning/
|
||||
misc/
|
||||
mobile/
|
||||
|
||||
open-api/typescript-sdk/build/
|
||||
!open-api/typescript-sdk/package.json
|
||||
!open-api/typescript-sdk/package-lock.json
|
||||
packages/sdk/build/
|
||||
|
||||
server/upload/
|
||||
server/src/queries
|
||||
|
||||
+8
-2
@@ -6,6 +6,12 @@ mobile/openapi/**/*.dart linguist-generated=true
|
||||
mobile/lib/**/*.g.dart -diff -merge
|
||||
mobile/lib/**/*.g.dart linguist-generated=true
|
||||
|
||||
mobile/android/**/*.g.kt -diff -merge
|
||||
mobile/android/**/*.g.kt linguist-generated=true
|
||||
|
||||
mobile/ios/**/*.g.swift -diff -merge
|
||||
mobile/ios/**/*.g.swift linguist-generated=true
|
||||
|
||||
mobile/lib/**/*.drift.dart -diff -merge
|
||||
mobile/lib/**/*.drift.dart linguist-generated=true
|
||||
|
||||
@@ -18,7 +24,7 @@ mobile/lib/infrastructure/repositories/db.repository.steps.dart linguist-generat
|
||||
mobile/test/drift/main/generated/** -diff -merge
|
||||
mobile/test/drift/main/generated/** linguist-generated=true
|
||||
|
||||
open-api/typescript-sdk/fetch-client.ts -diff -merge
|
||||
open-api/typescript-sdk/fetch-client.ts linguist-generated=true
|
||||
packages/sdk/fetch-client.ts -diff -merge
|
||||
packages/sdk/fetch-client.ts linguist-generated=true
|
||||
|
||||
*.sh text eol=lf
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
24.13.1
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
cli:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- cli/src/**
|
||||
- packages/cli/src/**
|
||||
|
||||
documentation:
|
||||
- changed-files:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"scripts": {
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write ."
|
||||
"format": "prettier --cache --check .",
|
||||
"format:fix": "prettier --cache --write --list-different ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.7.4"
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
name: Auto-close PRs
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
||||
types: [opened, edited, labeled]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
parse_template:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.action != 'labeled' && github.event.pull_request.head.repo.fork == true }}
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
uses_template: ${{ steps.check.outputs.uses_template }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
sparse-checkout: .github/pull_request_template.md
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check required sections
|
||||
id: check
|
||||
env:
|
||||
BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
OK=true
|
||||
while IFS= read -r header; do
|
||||
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
|
||||
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
|
||||
echo "uses_template=$OK" | tee --append "$GITHUB_OUTPUT"
|
||||
|
||||
close_template:
|
||||
runs-on: ubuntu-latest
|
||||
needs: parse_template
|
||||
if: >-
|
||||
${{
|
||||
needs.parse_template.outputs.uses_template == 'false'
|
||||
&& github.event.pull_request.state != 'closed'
|
||||
&& !contains(github.event.pull_request.labels.*.name, 'auto-closed:template')
|
||||
}}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Comment and close
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f body="This PR has been automatically closed as the description doesn't follow [our template](https://github.com/immich-app/immich/blob/main/.github/pull_request_template.md). After you edit it to match the template, the PR will automatically be reopened." \
|
||||
-f query='
|
||||
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
||||
addComment(input: {
|
||||
subjectId: $prId,
|
||||
body: $body
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
closePullRequest(input: {
|
||||
pullRequestId: $prId
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
|
||||
- name: Add label
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --add-label "auto-closed:template"
|
||||
|
||||
close_llm:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.action == 'labeled' && github.event.label.name == 'auto-closed:llm' }}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Comment and close
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our [CONTRIBUTING.md](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md#use-of-generative-ai), we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \
|
||||
-f query='
|
||||
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
||||
addComment(input: {
|
||||
subjectId: $prId,
|
||||
body: $body
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
closePullRequest(input: {
|
||||
pullRequestId: $prId
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
|
||||
reopen:
|
||||
runs-on: ubuntu-latest
|
||||
needs: parse_template
|
||||
if: >-
|
||||
${{
|
||||
needs.parse_template.outputs.uses_template == 'true'
|
||||
&& github.event.pull_request.state == 'closed'
|
||||
&& contains(github.event.pull_request.labels.*.name, 'auto-closed:template')
|
||||
}}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Remove template label
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --remove-label "auto-closed:template" || true
|
||||
|
||||
- name: Check for remaining auto-closed labels
|
||||
id: check_labels
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
REMAINING=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json labels \
|
||||
--jq '[.labels[].name | select(startswith("auto-closed:"))] | length')
|
||||
echo "remaining=$REMAINING" | tee --append "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Reopen PR
|
||||
if: ${{ steps.check_labels.outputs.remaining == '0' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f query='
|
||||
mutation ReopenPR($prId: ID!) {
|
||||
reopenPullRequest(input: {
|
||||
pullRequestId: $prId
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
@@ -51,14 +51,14 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||
uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
@@ -73,24 +73,30 @@ jobs:
|
||||
needs: pre-job
|
||||
permissions:
|
||||
contents: read
|
||||
# Skip when PR from a fork
|
||||
if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
|
||||
pull-requests: write
|
||||
if: ${{ github.actor != 'dependabot[bot]' && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
|
||||
runs-on: mich
|
||||
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
||||
with:
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Create the Keystore
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
env:
|
||||
KEY_JKS: ${{ secrets.KEY_JKS }}
|
||||
working-directory: ./mobile
|
||||
@@ -103,25 +109,17 @@ jobs:
|
||||
|
||||
- name: Restore Gradle Cache
|
||||
id: cache-gradle-restore
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
~/.android/sdk
|
||||
mobile/android/.gradle
|
||||
mobile/.dart_tool
|
||||
key: build-mobile-gradle-${{ runner.os }}-main
|
||||
|
||||
- name: Setup Flutter SDK
|
||||
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version-file: ./mobile/pubspec.yaml
|
||||
cache: true
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3.2.2
|
||||
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
|
||||
with:
|
||||
packages: ''
|
||||
|
||||
@@ -130,11 +128,10 @@ jobs:
|
||||
run: flutter pub get
|
||||
|
||||
- name: Generate translation file
|
||||
run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart
|
||||
working-directory: ./mobile
|
||||
run: mise //mobile:codegen:translation
|
||||
|
||||
- name: Generate platform APIs
|
||||
run: make pigeon
|
||||
run: mise //mobile:codegen:pigeon
|
||||
working-directory: ./mobile
|
||||
|
||||
- name: Build Android App Bundle
|
||||
@@ -144,23 +141,46 @@ jobs:
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||
IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
if [[ $IS_MAIN == 'true' ]]; then
|
||||
flutter build apk --release
|
||||
flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64
|
||||
else
|
||||
flutter build apk --debug --split-per-abi --target-platform android-arm64
|
||||
flutter build apk --release
|
||||
fi
|
||||
|
||||
- name: Publish Android Artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
id: upload-apk
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: release-apk-signed
|
||||
path: mobile/build/app/outputs/flutter-apk/*.apk
|
||||
|
||||
- name: Comment APK download link on PR
|
||||
if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork }}
|
||||
uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
|
||||
env:
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
APK_URL: ${{ steps.upload-apk.outputs.artifact-url }}
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
message-id: 'mobile-android-apk'
|
||||
message: |
|
||||
📱 **Android release APK (universal)** — `${{ env.HEAD_SHA }}`
|
||||
|
||||
Download: ${{ env.APK_URL }}
|
||||
|
||||
<details>
|
||||
<summary>QR code</summary>
|
||||
<img src="https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=${{ env.APK_URL }}" alt="QR code" />
|
||||
</details>
|
||||
|
||||
Installs as a separate app (applicationId `app.alextran.immich.pr${{ github.event.pull_request.number }}`), so it coexists with the Play Store version and any other PR builds.
|
||||
|
||||
- name: Save Gradle Cache
|
||||
id: cache-gradle-save
|
||||
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
if: github.ref == 'refs/heads/main'
|
||||
with:
|
||||
path: |
|
||||
@@ -168,7 +188,6 @@ jobs:
|
||||
~/.gradle/wrapper
|
||||
~/.android/sdk
|
||||
mobile/android/.gradle
|
||||
mobile/.dart_tool
|
||||
key: ${{ steps.cache-gradle-restore.outputs.cache-primary-key }}
|
||||
|
||||
build-sign-ios:
|
||||
@@ -181,36 +200,38 @@ jobs:
|
||||
runs-on: macos-15
|
||||
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Select Xcode 26
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter SDK
|
||||
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version-file: ./mobile/pubspec.yaml
|
||||
cache: true
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Install Flutter dependencies
|
||||
working-directory: ./mobile
|
||||
run: flutter pub get
|
||||
|
||||
- name: Generate translation files
|
||||
run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart
|
||||
working-directory: ./mobile
|
||||
run: mise //mobile:codegen:translation
|
||||
|
||||
- name: Generate platform APIs
|
||||
run: make pigeon
|
||||
working-directory: ./mobile
|
||||
run: mise //mobile:codegen:pigeon
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
|
||||
with:
|
||||
ruby-version: '3.3'
|
||||
bundler-cache: true
|
||||
@@ -291,7 +312,7 @@ jobs:
|
||||
security delete-keychain build.keychain || true
|
||||
|
||||
- name: Upload IPA artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ios-release-ipa
|
||||
path: mobile/ios/Runner.ipa
|
||||
|
||||
@@ -19,9 +19,9 @@ jobs:
|
||||
actions: write
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check out code
|
||||
|
||||
@@ -24,8 +24,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for breaking API changes
|
||||
# sha is pinning to a commit instead of a tag since the action does not tag versions
|
||||
uses: oasdiff/oasdiff-action/breaking@ccb863950ce437a50f8f1a40d2a1112117e06ce4
|
||||
uses: oasdiff/oasdiff-action/breaking@26ccb332c67a45ca649de9faf60552ef1b8260d9 # v0.0.46
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||
revision: open-api/immich-openapi-specs.json
|
||||
|
||||
+21
-31
@@ -3,11 +3,11 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'cli/**'
|
||||
- 'packages/cli/**'
|
||||
- '.github/workflows/cli.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'cli/**'
|
||||
- 'packages/cli/**'
|
||||
- '.github/workflows/cli.yml'
|
||||
release:
|
||||
types: [published]
|
||||
@@ -28,38 +28,28 @@ jobs:
|
||||
packages: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./cli
|
||||
working-directory: ./packages/cli
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
||||
with:
|
||||
node-version-file: './cli/.nvmrc'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup typescript-sdk
|
||||
run: pnpm install && pnpm run build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build
|
||||
- run: pnpm publish --provenance --no-git-checks
|
||||
- name: Publish
|
||||
if: ${{ github.event_name == 'release' }}
|
||||
run: mise run ci-publish
|
||||
|
||||
docker:
|
||||
name: Docker
|
||||
@@ -71,9 +61,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
@@ -83,13 +73,13 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -99,12 +89,12 @@ jobs:
|
||||
- name: Get package version
|
||||
id: package-version
|
||||
run: |
|
||||
version=$(jq -r '.version' cli/package.json)
|
||||
version=$(jq -r '.version' packages/cli/package.json)
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Generate docker image tags
|
||||
id: metadata
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
flavor: |
|
||||
latest=false
|
||||
@@ -115,9 +105,9 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
file: cli/Dockerfile
|
||||
file: packages/cli/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name == 'release' }}
|
||||
cache-from: type=gha
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
needs: [get_body, should_run]
|
||||
if: ${{ needs.should_run.outputs.should_run == 'true' }}
|
||||
container:
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:4f9860d04c88f7f87861f8ee84bfeedaec15ed7ca5ca87bc7db44b036f81645f
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:0a8b8867773a0f8368061f47578603f438349f8f1f28b0e16105f481e5c794e0
|
||||
outputs:
|
||||
checked: ${{ steps.get_checkbox.outputs.checked }}
|
||||
steps:
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
name: Close LLM-generated PRs
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
comment_and_close:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.label.name == 'llm-generated' }}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Comment and close
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our [CONTRIBUTING.md](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md#use-of-generative-ai), we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \
|
||||
-f query='
|
||||
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
||||
addComment(input: {
|
||||
subjectId: $prId,
|
||||
body: $body
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
|
||||
closePullRequest(input: {
|
||||
pullRequestId: $prId
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
@@ -44,9 +44,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout repository
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -83,6 +83,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
@@ -23,14 +23,14 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||
uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
suffix: ['']
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -131,8 +131,8 @@ jobs:
|
||||
- device: rocm
|
||||
suffixes: '-rocm'
|
||||
platforms: linux/amd64
|
||||
runner-mapping: '{"linux/amd64": "pokedex-giant"}'
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
|
||||
runner-mapping: '{"linux/amd64": "pokedex-large"}'
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@5813c7c4f7016c748ae7ac5d5f684846649d4d20 # multi-runner-build-workflow-v2.4.0
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
name: Build and Push Server
|
||||
needs: pre-job
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@5813c7c4f7016c748ae7ac5d5f684846649d4d20 # multi-runner-build-workflow-v2.4.0
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
steps:
|
||||
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
|
||||
- uses: immich-app/devtools/actions/success-check@81113db03f6d743efee81e0058c0b43f6cd6f36d # success-check-action-v0.0.6
|
||||
with:
|
||||
needs: ${{ toJSON(needs) }}
|
||||
|
||||
@@ -189,6 +189,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
steps:
|
||||
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
|
||||
- uses: immich-app/devtools/actions/success-check@81113db03f6d743efee81e0058c0b43f6cd6f36d # success-check-action-v0.0.6
|
||||
with:
|
||||
needs: ${{ toJSON(needs) }}
|
||||
|
||||
@@ -21,14 +21,14 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||
uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
@@ -54,9 +54,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -64,17 +64,11 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
||||
with:
|
||||
node-version-file: './docs/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Run install
|
||||
run: pnpm install
|
||||
@@ -86,7 +80,7 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
- name: Upload build output
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: docs-build-output
|
||||
path: docs/build/
|
||||
|
||||
@@ -20,16 +20,16 @@ jobs:
|
||||
artifact: ${{ steps.get-artifact.outputs.result }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- if: ${{ github.event.workflow_run.conclusion != 'success' }}
|
||||
run: echo 'The triggering workflow did not succeed' && exit 1
|
||||
- name: Get artifact
|
||||
id: get-artifact
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
script: |
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
return { found: true, id: matchArtifact.id };
|
||||
- name: Determine deploy parameters
|
||||
id: parameters
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
with:
|
||||
@@ -119,9 +119,9 @@ jobs:
|
||||
if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -131,11 +131,13 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2
|
||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
||||
with:
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Load parameters
|
||||
id: parameters
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
PARAM_JSON: ${{ needs.checks.outputs.parameters }}
|
||||
with:
|
||||
@@ -147,7 +149,7 @@ jobs:
|
||||
core.setOutput("shouldDeploy", parameters.shouldDeploy);
|
||||
|
||||
- name: Download artifact
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
ARTIFACT_JSON: ${{ needs.checks.outputs.artifact }}
|
||||
with:
|
||||
@@ -211,7 +213,7 @@ jobs:
|
||||
run: 'mise run //deployment:tf apply'
|
||||
|
||||
- name: Comment
|
||||
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
|
||||
uses: actions-cool/maintain-one-comment@909842216bc8e8658364c572ec52100f4c2cc50a # v3.3.0
|
||||
if: ${{ steps.parameters.outputs.event == 'pr' }}
|
||||
with:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
@@ -17,9 +17,9 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -29,7 +29,9 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2
|
||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
||||
with:
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Destroy Docs Subdomain
|
||||
env:
|
||||
@@ -42,7 +44,7 @@ jobs:
|
||||
run: 'mise run //deployment:tf destroy -- -refresh=false'
|
||||
|
||||
- name: Comment
|
||||
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
|
||||
uses: actions-cool/maintain-one-comment@909842216bc8e8658364c572ec52100f4c2cc50a # v3.3.0
|
||||
with:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
number: ${{ github.event.number }}
|
||||
|
||||
@@ -14,41 +14,35 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: 'Checkout'
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
persist-credentials: true
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Fix formatting
|
||||
run: pnpm --recursive install && pnpm run --recursive --if-present --parallel format:fix
|
||||
|
||||
- name: Commit and push
|
||||
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
|
||||
uses: EndBug/add-and-commit@290ea2c423ad77ca9c62ae0f5b224379612c0321 # v10.0.0
|
||||
with:
|
||||
default_author: github_actions
|
||||
message: 'chore: fix formatting'
|
||||
|
||||
- name: Remove label
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
if: always()
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
secrets:
|
||||
PUSH_O_MATIC_APP_ID:
|
||||
PUSH_O_MATIC_APP_CLIENT_ID:
|
||||
required: true
|
||||
PUSH_O_MATIC_APP_KEY:
|
||||
required: true
|
||||
@@ -31,9 +31,9 @@ jobs:
|
||||
- name: Generate a token
|
||||
id: generate_token
|
||||
if: ${{ inputs.skip != true }}
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Find translation PR
|
||||
|
||||
@@ -14,13 +14,13 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Require PR to have a changelog label
|
||||
uses: mheap/github-action-required-labels@8afbe8ae6ab7647d0c9f0cfa7c2f939650d22509 # v5.5.1
|
||||
uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5.5.2
|
||||
with:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
mode: exactly
|
||||
|
||||
@@ -12,11 +12,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
|
||||
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
permissions:
|
||||
pull-requests: write
|
||||
secrets:
|
||||
PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
PUSH_O_MATIC_APP_CLIENT_ID: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
|
||||
|
||||
@@ -48,32 +48,27 @@ jobs:
|
||||
version: ${{ steps.output.outputs.version }}
|
||||
permissions: {} # No job-level permissions are needed because it uses the app-token
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
persist-credentials: true
|
||||
ref: main
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
# TODO move to mise
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
|
||||
- name: Bump version
|
||||
env:
|
||||
@@ -86,7 +81,7 @@ jobs:
|
||||
|
||||
- name: Commit and tag
|
||||
id: push-tag
|
||||
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
|
||||
uses: EndBug/add-and-commit@290ea2c423ad77ca9c62ae0f5b224379612c0321 # v10.0.0
|
||||
with:
|
||||
default_author: github_actions
|
||||
message: 'chore: version ${{ steps.output.outputs.version }}'
|
||||
@@ -124,9 +119,9 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
@@ -136,13 +131,13 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download APK
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-apk-signed
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Create draft release
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
|
||||
with:
|
||||
draft: true
|
||||
tag_name: ${{ needs.bump_version.outputs.version }}
|
||||
@@ -151,6 +146,7 @@ jobs:
|
||||
body_path: misc/release/notes.tmpl
|
||||
files: |
|
||||
docker/docker-compose.yml
|
||||
docker/docker-compose.rootless.yml
|
||||
docker/example.env
|
||||
docker/hwaccel.ml.yml
|
||||
docker/hwaccel.transcoding.yml
|
||||
|
||||
@@ -14,12 +14,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
|
||||
- uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
message-id: 'preview-status'
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
script: |
|
||||
@@ -48,14 +48,14 @@ jobs:
|
||||
name: 'preview'
|
||||
})
|
||||
|
||||
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
|
||||
- uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
|
||||
if: ${{ github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
message-id: 'preview-status'
|
||||
message: 'PRs from forks cannot have preview environments.'
|
||||
|
||||
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
|
||||
- uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
name: Manage release PR
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
persist-credentials: true
|
||||
ref: main
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
|
||||
- name: Determine release type
|
||||
id: bump-type
|
||||
uses: ietf-tools/semver-action@c90370b2958652d71c06a3484129a4d423a6d8a8 # v1.11.0
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Bump versions
|
||||
env:
|
||||
TYPE: ${{ steps.bump-type.outputs.bump }}
|
||||
run: |
|
||||
if [ "$TYPE" == "none" ]; then
|
||||
exit 1 # TODO: Is there a cleaner way to abort the workflow?
|
||||
fi
|
||||
misc/release/pump-version.sh -s $TYPE -m true
|
||||
|
||||
- name: Manage Outline release document
|
||||
id: outline
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
|
||||
NEXT_VERSION: ${{ steps.bump-type.outputs.next }}
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
const outlineKey = process.env.OUTLINE_API_KEY;
|
||||
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9'
|
||||
const collectionId = 'e2910656-714c-4871-8721-447d9353bd73';
|
||||
const baseUrl = 'https://outline.immich.cloud';
|
||||
|
||||
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${outlineKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ parentDocumentId })
|
||||
});
|
||||
|
||||
if (!listResponse.ok) {
|
||||
throw new Error(`Outline list failed: ${listResponse.statusText}`);
|
||||
}
|
||||
|
||||
const listData = await listResponse.json();
|
||||
const allDocuments = listData.data || [];
|
||||
|
||||
const document = allDocuments.find(doc => doc.title === 'next');
|
||||
|
||||
let documentId;
|
||||
let documentUrl;
|
||||
let documentText;
|
||||
|
||||
if (!document) {
|
||||
// Create new document
|
||||
console.log('No existing document found. Creating new one...');
|
||||
const notesTmpl = fs.readFileSync('misc/release/notes.tmpl', 'utf8');
|
||||
const createResponse = await fetch(`${baseUrl}/api/documents.create`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${outlineKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: 'next',
|
||||
text: notesTmpl,
|
||||
collectionId: collectionId,
|
||||
parentDocumentId: parentDocumentId,
|
||||
publish: true
|
||||
})
|
||||
});
|
||||
|
||||
if (!createResponse.ok) {
|
||||
throw new Error(`Failed to create document: ${createResponse.statusText}`);
|
||||
}
|
||||
|
||||
const createData = await createResponse.json();
|
||||
documentId = createData.data.id;
|
||||
const urlId = createData.data.urlId;
|
||||
documentUrl = `${baseUrl}/doc/next-${urlId}`;
|
||||
documentText = createData.data.text || '';
|
||||
console.log(`Created new document: ${documentUrl}`);
|
||||
} else {
|
||||
documentId = document.id;
|
||||
const docPath = document.url;
|
||||
documentUrl = `${baseUrl}${docPath}`;
|
||||
documentText = document.text || '';
|
||||
console.log(`Found existing document: ${documentUrl}`);
|
||||
}
|
||||
|
||||
// Generate GitHub release notes
|
||||
console.log('Generating GitHub release notes...');
|
||||
const releaseNotesResponse = await github.rest.repos.generateReleaseNotes({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: `${process.env.NEXT_VERSION}`,
|
||||
});
|
||||
|
||||
// Combine the content
|
||||
const changelog = `
|
||||
# ${process.env.NEXT_VERSION}
|
||||
|
||||
${documentText}
|
||||
|
||||
${releaseNotesResponse.data.body}
|
||||
|
||||
---
|
||||
|
||||
`
|
||||
|
||||
const existingChangelog = fs.existsSync('CHANGELOG.md') ? fs.readFileSync('CHANGELOG.md', 'utf8') : '';
|
||||
fs.writeFileSync('CHANGELOG.md', changelog + existingChangelog, 'utf8');
|
||||
|
||||
core.setOutput('document_url', documentUrl);
|
||||
|
||||
- name: Create PR
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
||||
title: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
||||
body: 'Release notes: ${{ steps.outline.outputs.document_url }}'
|
||||
labels: 'changelog:skip'
|
||||
branch: 'release/next'
|
||||
draft: true
|
||||
@@ -1,149 +0,0 @@
|
||||
name: release.yml
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
paths:
|
||||
- CHANGELOG.md
|
||||
|
||||
jobs:
|
||||
# Maybe double check PR source branch?
|
||||
|
||||
merge_translations:
|
||||
uses: ./.github/workflows/merge-translations.yml
|
||||
permissions:
|
||||
pull-requests: write
|
||||
secrets:
|
||||
PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
|
||||
|
||||
build_mobile:
|
||||
uses: ./.github/workflows/build-mobile.yml
|
||||
needs: merge_translations
|
||||
permissions:
|
||||
contents: read
|
||||
secrets:
|
||||
KEY_JKS: ${{ secrets.KEY_JKS }}
|
||||
ALIAS: ${{ secrets.ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||
# iOS secrets
|
||||
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
|
||||
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
|
||||
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
|
||||
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}misc/release/notes.tmpl
|
||||
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
|
||||
with:
|
||||
ref: main
|
||||
environment: production
|
||||
|
||||
prepare_release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build_mobile
|
||||
permissions:
|
||||
actions: read # To download the app artifact
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
persist-credentials: false
|
||||
ref: main
|
||||
|
||||
- name: Extract changelog
|
||||
id: changelog
|
||||
run: |
|
||||
CHANGELOG_PATH=$RUNNER_TEMP/changelog.md
|
||||
sed -n '1,/^---$/p' CHANGELOG.md | head -n -1 > $CHANGELOG_PATH
|
||||
echo "path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT
|
||||
VERSION=$(sed -n 's/^# //p' $CHANGELOG_PATH)
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download APK
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: release-apk-signed
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Create draft release
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.result }}
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
body_path: ${{ steps.changelog.outputs.path }}
|
||||
draft: true
|
||||
files: |
|
||||
docker/docker-compose.yml
|
||||
docker/docker-compose.rootless.yml
|
||||
docker/example.env
|
||||
docker/hwaccel.ml.yml
|
||||
docker/hwaccel.transcoding.yml
|
||||
docker/prometheus.yml
|
||||
*.apk
|
||||
|
||||
- name: Rename Outline document
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
continue-on-error: true
|
||||
env:
|
||||
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
|
||||
VERSION: ${{ steps.changelog.outputs.version }}
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
const outlineKey = process.env.OUTLINE_API_KEY;
|
||||
const version = process.env.VERSION;
|
||||
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9';
|
||||
const baseUrl = 'https://outline.immich.cloud';
|
||||
|
||||
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${outlineKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ parentDocumentId })
|
||||
});
|
||||
|
||||
if (!listResponse.ok) {
|
||||
throw new Error(`Outline list failed: ${listResponse.statusText}`);
|
||||
}
|
||||
|
||||
const listData = await listResponse.json();
|
||||
const allDocuments = listData.data || [];
|
||||
const document = allDocuments.find(doc => doc.title === 'next');
|
||||
|
||||
if (document) {
|
||||
console.log(`Found document 'next', renaming to '${version}'...`);
|
||||
|
||||
const updateResponse = await fetch(`${baseUrl}/api/documents.update`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${outlineKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: document.id,
|
||||
title: version
|
||||
})
|
||||
});
|
||||
|
||||
if (!updateResponse.ok) {
|
||||
throw new Error(`Failed to rename document: ${updateResponse.statusText}`);
|
||||
}
|
||||
} else {
|
||||
console.log('No document titled "next" found to rename');
|
||||
}
|
||||
+13
-18
@@ -14,34 +14,29 @@ jobs:
|
||||
contents: read
|
||||
id-token: write
|
||||
packages: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
||||
with:
|
||||
node-version-file: './open-api/typescript-sdk/.nvmrc'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Install deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: pnpm --filter @immich/sdk install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
run: pnpm --filter @immich/sdk build
|
||||
|
||||
- name: Publish
|
||||
run: pnpm publish --provenance --no-git-checks
|
||||
run: pnpm --filter @immich/sdk publish --provenance --no-git-checks
|
||||
|
||||
@@ -20,14 +20,14 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||
uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
@@ -49,9 +49,9 @@ jobs:
|
||||
working-directory: ./mobile
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -60,38 +60,30 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup Flutter SDK
|
||||
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version-file: ./mobile/pubspec.yaml
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: dart pub get
|
||||
run: flutter pub get
|
||||
|
||||
- name: Install dependencies for UI package
|
||||
run: dart pub get
|
||||
run: flutter pub get
|
||||
working-directory: ./mobile/packages/ui
|
||||
|
||||
- name: Install dependencies for UI Showcase
|
||||
run: dart pub get
|
||||
run: flutter pub get
|
||||
working-directory: ./mobile/packages/ui/showcase
|
||||
|
||||
- name: Install DCM
|
||||
uses: CQLabs/setup-dcm@8697ae0790c0852e964a6ef1d768d62a6675481a # v2.0.1
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
version: auto
|
||||
working-directory: ./mobile
|
||||
|
||||
- name: Generate translation file
|
||||
run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart
|
||||
- name: Generate translation files
|
||||
run: mise //mobile:codegen:translation
|
||||
|
||||
- name: Run Build Runner
|
||||
run: make build
|
||||
run: mise //mobile:codegen:dart
|
||||
|
||||
- name: Generate platform API
|
||||
run: make pigeon
|
||||
run: mise //mobile:codegen:pigeon
|
||||
|
||||
- name: Find file changes
|
||||
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
||||
@@ -107,20 +99,16 @@ jobs:
|
||||
env:
|
||||
CHANGED_FILES: ${{ steps.verify-changed-files.outputs.changed_files }}
|
||||
run: |
|
||||
echo "ERROR: Generated files not up to date! Run 'make build' and 'make pigeon' inside the mobile directory"
|
||||
echo "ERROR: Generated files not up to date! Run 'mise //mobile:codegen:dart' and 'mise //mobile:codegen:pigeon'"
|
||||
echo "Changed files: ${CHANGED_FILES}"
|
||||
exit 1
|
||||
|
||||
- name: Run dart analyze
|
||||
run: dart analyze --fatal-infos
|
||||
- name: Run analyze
|
||||
run: mise //mobile:analyze
|
||||
|
||||
- name: Run dart format
|
||||
run: make format
|
||||
- name: Run format
|
||||
run: mise //mobile:format
|
||||
|
||||
# TODO: Re-enable after upgrading custom_lint
|
||||
# - name: Run dart custom_lint
|
||||
# run: dart run custom_lint
|
||||
|
||||
# TODO: Use https://github.com/CQLabs/dcm-action
|
||||
- name: Run DCM
|
||||
run: dcm analyze lib --fatal-style --fatal-warnings
|
||||
|
||||
+221
-259
@@ -17,14 +17,14 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||
uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
@@ -33,14 +33,18 @@ jobs:
|
||||
web:
|
||||
- 'web/**'
|
||||
- 'i18n/**'
|
||||
- 'open-api/typescript-sdk/**'
|
||||
- 'packages/sdk/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
server:
|
||||
- 'server/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
cli:
|
||||
- 'cli/**'
|
||||
- 'open-api/typescript-sdk/**'
|
||||
- 'packages/cli/**'
|
||||
- 'packages/sdk/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
e2e:
|
||||
- 'e2e/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
mobile:
|
||||
- 'mobile/**'
|
||||
machine-learning:
|
||||
@@ -58,14 +62,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./server
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -74,28 +75,14 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Run package manager install
|
||||
run: pnpm install
|
||||
- name: Run linter
|
||||
run: pnpm lint
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run formatter
|
||||
run: pnpm format
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run tsc
|
||||
run: pnpm check
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run small tests & coverage
|
||||
run: pnpm test
|
||||
if: ${{ !cancelled() }}
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Run ci-unit
|
||||
run: mise run //server:ci-unit
|
||||
|
||||
cli-unit-tests:
|
||||
name: Unit Test CLI
|
||||
needs: pre-job
|
||||
@@ -105,12 +92,12 @@ jobs:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./cli
|
||||
working-directory: ./packages/cli
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -118,31 +105,15 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
||||
with:
|
||||
node-version-file: './cli/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Setup typescript-sdk
|
||||
run: pnpm install && pnpm run build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
- name: Install deps
|
||||
run: pnpm install
|
||||
- name: Run linter
|
||||
run: pnpm lint
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run formatter
|
||||
run: pnpm format
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run tsc
|
||||
run: pnpm check
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run unit tests & coverage
|
||||
run: pnpm test
|
||||
if: ${{ !cancelled() }}
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Run ci-unit
|
||||
run: mise run ci-unit
|
||||
|
||||
cli-unit-tests-win:
|
||||
name: Unit Test CLI (Windows)
|
||||
needs: pre-job
|
||||
@@ -152,12 +123,12 @@ jobs:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./cli
|
||||
working-directory: ./packages/cli
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -165,26 +136,28 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
||||
with:
|
||||
node-version-file: './cli/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Setup typescript-sdk
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
- name: Install deps
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Run setup @immich/sdk
|
||||
run: mise run //:sdk:install && mise run //:sdk:build
|
||||
|
||||
- name: Run pnpm install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Skip linter & formatter in Windows test.
|
||||
|
||||
- name: Run tsc
|
||||
run: pnpm check
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run unit tests & coverage
|
||||
run: pnpm test
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
web-lint:
|
||||
name: Lint Web
|
||||
needs: pre-job
|
||||
@@ -197,9 +170,9 @@ jobs:
|
||||
working-directory: ./web
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -207,28 +180,22 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
||||
with:
|
||||
node-version-file: './web/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Run setup typescript-sdk
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Run setup @immich/sdk
|
||||
run: mise run //:sdk:install && mise run //:sdk:build
|
||||
|
||||
- name: Run pnpm install
|
||||
run: pnpm rebuild && pnpm install --frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run linter
|
||||
run: pnpm lint
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run formatter
|
||||
run: pnpm format
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run svelte checks
|
||||
run: pnpm check:svelte
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
web-unit-tests:
|
||||
name: Test Web
|
||||
needs: pre-job
|
||||
@@ -241,9 +208,9 @@ jobs:
|
||||
working-directory: ./web
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -251,25 +218,15 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
||||
with:
|
||||
node-version-file: './web/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Run setup typescript-sdk
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
- name: Run npm install
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Run tsc
|
||||
run: pnpm check:typescript
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run unit tests & coverage
|
||||
run: pnpm test
|
||||
if: ${{ !cancelled() }}
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Run ci-unit
|
||||
run: mise run ci-unit
|
||||
|
||||
i18n-tests:
|
||||
name: Test i18n
|
||||
needs: pre-job
|
||||
@@ -279,9 +236,9 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -289,24 +246,25 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
||||
with:
|
||||
node-version-file: './web/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm --filter=immich-i18n install --frozen-lockfile
|
||||
run: pnpm -w install --frozen-lockfile
|
||||
|
||||
- name: Format
|
||||
run: pnpm --filter=immich-i18n format:fix
|
||||
run: pnpm format:fix
|
||||
|
||||
- name: Find file changes
|
||||
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
||||
id: verify-changed-files
|
||||
with:
|
||||
files: |
|
||||
i18n/**
|
||||
|
||||
- name: Verify files have not changed
|
||||
if: steps.verify-changed-files.outputs.files_changed == 'true'
|
||||
env:
|
||||
@@ -315,6 +273,7 @@ jobs:
|
||||
echo "ERROR: i18n files not up to date!"
|
||||
echo "Changed files: ${CHANGED_FILES}"
|
||||
exit 1
|
||||
|
||||
e2e-tests-lint:
|
||||
name: End-to-End Lint
|
||||
needs: pre-job
|
||||
@@ -327,9 +286,9 @@ jobs:
|
||||
working-directory: ./e2e
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -337,30 +296,16 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Run setup typescript-sdk
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run linter
|
||||
run: pnpm lint
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run formatter
|
||||
run: pnpm format
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run tsc
|
||||
run: pnpm check
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Run ci-unit
|
||||
run: mise run ci-unit
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
server-medium-tests:
|
||||
name: Medium Tests (Server)
|
||||
needs: pre-job
|
||||
@@ -373,9 +318,9 @@ jobs:
|
||||
working-directory: ./server
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -384,19 +329,16 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Run pnpm install
|
||||
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
|
||||
- name: Run medium tests
|
||||
run: pnpm test:medium
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Run ci-medium
|
||||
run: mise run ci-medium
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
e2e-tests-server-cli:
|
||||
name: End-to-End Tests (Server & CLI)
|
||||
needs: pre-job
|
||||
@@ -412,9 +354,9 @@ jobs:
|
||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -423,52 +365,57 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Run setup typescript-sdk
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Setup packages
|
||||
run: pnpm --filter @immich/sdk --filter @immich/cli install --frozen-lockfile && pnpm --filter @immich/sdk --filter @immich/cli build
|
||||
|
||||
- name: Run setup web
|
||||
run: pnpm install --frozen-lockfile && pnpm exec svelte-kit sync
|
||||
working-directory: ./web
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run setup cli
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./cli
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Start Docker Compose
|
||||
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run e2e tests (api & cli)
|
||||
env:
|
||||
VITEST_DISABLE_DOCKER_SETUP: true
|
||||
run: pnpm test
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run e2e tests (maintenance)
|
||||
env:
|
||||
VITEST_DISABLE_DOCKER_SETUP: true
|
||||
run: pnpm test:maintenance
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Capture Docker logs
|
||||
if: always()
|
||||
run: docker compose logs --no-color > docker-compose-logs.txt
|
||||
working-directory: ./e2e
|
||||
|
||||
- name: Archive Docker logs
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-server-docker-logs-${{ matrix.runner }}
|
||||
path: e2e/docker-compose-logs.txt
|
||||
|
||||
e2e-tests-web:
|
||||
name: End-to-End Tests (Web)
|
||||
needs: pre-job
|
||||
@@ -484,9 +431,9 @@ jobs:
|
||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -495,70 +442,84 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Run setup typescript-sdk
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
|
||||
- name: Run setup @immich/sdk
|
||||
run: pnpm --filter @immich/sdk install --frozen-lockfile && pnpm --filter @immich/sdk build
|
||||
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install chromium --only-shell
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Docker build
|
||||
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run e2e tests (web)
|
||||
env:
|
||||
PLAYWRIGHT_DISABLE_WEBSERVER: true
|
||||
run: pnpm test:web
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Archive e2e test (web) results
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: e2e-web-test-results-${{ matrix.runner }}
|
||||
path: e2e/playwright-report/
|
||||
|
||||
- name: Run ui tests (web)
|
||||
env:
|
||||
PLAYWRIGHT_DISABLE_WEBSERVER: true
|
||||
run: pnpm test:web:ui
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Archive ui test (web) results
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: e2e-ui-test-results-${{ matrix.runner }}
|
||||
path: e2e/playwright-report/
|
||||
|
||||
- name: Run maintenance tests
|
||||
env:
|
||||
PLAYWRIGHT_DISABLE_WEBSERVER: true
|
||||
run: pnpm test:web:maintenance
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Archive maintenance tests (web) results
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: e2e-maintenance-isolated-test-results-${{ matrix.runner }}
|
||||
path: e2e/playwright-report/
|
||||
|
||||
- name: Capture Docker logs
|
||||
if: always()
|
||||
run: docker compose logs --no-color > docker-compose-logs.txt
|
||||
working-directory: ./e2e
|
||||
|
||||
- name: Archive Docker logs
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-web-docker-logs-${{ matrix.runner }}
|
||||
path: e2e/docker-compose-logs.txt
|
||||
|
||||
success-check-e2e:
|
||||
name: End-to-End Tests Success
|
||||
needs: [e2e-tests-server-cli, e2e-tests-web]
|
||||
@@ -566,7 +527,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
steps:
|
||||
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
|
||||
- uses: immich-app/devtools/actions/success-check@81113db03f6d743efee81e0058c0b43f6cd6f36d # success-check-action-v0.0.6
|
||||
with:
|
||||
needs: ${{ toJSON(needs) }}
|
||||
mobile-unit-tests:
|
||||
@@ -578,26 +539,31 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup Flutter SDK
|
||||
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version-file: ./mobile/pubspec.yaml
|
||||
- name: Generate translation file
|
||||
run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
working-directory: ./mobile
|
||||
|
||||
- name: Generate translation files
|
||||
run: mise //mobile:codegen:translation
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./mobile
|
||||
run: flutter test -j 1
|
||||
run: mise //mobile:test
|
||||
|
||||
ml-unit-tests:
|
||||
name: Unit Test ML
|
||||
needs: pre-job
|
||||
@@ -610,34 +576,24 @@ jobs:
|
||||
working-directory: ./machine-learning
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
||||
with:
|
||||
python-version: 3.11
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv sync --extra cpu
|
||||
- name: Lint with ruff
|
||||
run: |
|
||||
uv run ruff check --output-format=github immich_ml
|
||||
- name: Format with ruff
|
||||
run: |
|
||||
uv run ruff format --check immich_ml
|
||||
- name: Run mypy type checking
|
||||
run: |
|
||||
uv run mypy --strict immich_ml/
|
||||
- name: Run tests and coverage
|
||||
run: |
|
||||
uv run pytest --cov=immich_ml --cov-report term-missing
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Run ci-unit
|
||||
run: mise run ci-unit
|
||||
|
||||
github-files-formatting:
|
||||
name: .github Files Formatting
|
||||
needs: pre-job
|
||||
@@ -650,9 +606,9 @@ jobs:
|
||||
working-directory: ./.github
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -660,19 +616,19 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
||||
with:
|
||||
node-version-file: './.github/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Run pnpm install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run formatter
|
||||
run: pnpm format
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
shellcheck:
|
||||
name: ShellCheck
|
||||
runs-on: ubuntu-latest
|
||||
@@ -680,9 +636,9 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -701,9 +657,9 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -711,29 +667,27 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Install server dependencies
|
||||
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile
|
||||
- name: Build the app
|
||||
run: pnpm --filter immich build
|
||||
- name: Run API generation
|
||||
run: ./bin/generate-open-api.sh
|
||||
run: mise //:open-api
|
||||
working-directory: open-api
|
||||
|
||||
- name: Find file changes
|
||||
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
||||
id: verify-changed-files
|
||||
with:
|
||||
files: |
|
||||
mobile/openapi
|
||||
open-api/typescript-sdk
|
||||
packages/sdk
|
||||
open-api/immich-openapi-specs.json
|
||||
|
||||
- name: Verify files have not changed
|
||||
if: steps.verify-changed-files.outputs.files_changed == 'true'
|
||||
env:
|
||||
@@ -742,6 +696,7 @@ jobs:
|
||||
echo "ERROR: Generated files not up to date!"
|
||||
echo "Changed files: ${CHANGED_FILES}"
|
||||
exit 1
|
||||
|
||||
sql-schema-up-to-date:
|
||||
name: SQL Schema Checks
|
||||
runs-on: ubuntu-latest
|
||||
@@ -758,14 +713,11 @@ jobs:
|
||||
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./server
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -773,31 +725,38 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Install server dependencies
|
||||
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build plugins
|
||||
run: mise //:plugins
|
||||
|
||||
- name: Build the app
|
||||
run: pnpm build
|
||||
run: mise //server:build
|
||||
|
||||
- name: Run existing migrations
|
||||
run: pnpm migrations:run
|
||||
run: pnpm --filter immich migrations:run
|
||||
|
||||
- name: Test npm run schema:reset command works
|
||||
run: pnpm schema:reset
|
||||
run: pnpm --filter immich schema:reset
|
||||
|
||||
- name: Generate new migrations
|
||||
continue-on-error: true
|
||||
run: pnpm migrations:generate src/TestMigration
|
||||
run: pnpm --filter migrations:generate src/TestMigration
|
||||
|
||||
- name: Find file changes
|
||||
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
||||
id: verify-changed-files
|
||||
with:
|
||||
files: |
|
||||
server/src
|
||||
|
||||
- name: Verify migration files have not changed
|
||||
if: steps.verify-changed-files.outputs.files_changed == 'true'
|
||||
env:
|
||||
@@ -805,18 +764,21 @@ jobs:
|
||||
run: |
|
||||
echo "ERROR: Generated migration files not up to date!"
|
||||
echo "Changed files: ${CHANGED_FILES}"
|
||||
cat ./src/*-TestMigration.ts
|
||||
cat ./server/src/*-TestMigration.ts
|
||||
exit 1
|
||||
|
||||
- name: Run SQL generation
|
||||
run: pnpm sync:sql
|
||||
run: mise //:sql
|
||||
env:
|
||||
DB_URL: postgres://postgres:postgres@localhost:5432/immich
|
||||
|
||||
- name: Find file changes
|
||||
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
||||
id: verify-changed-sql-files
|
||||
with:
|
||||
files: |
|
||||
server/src/queries
|
||||
|
||||
- name: Verify SQL files have not changed
|
||||
if: steps.verify-changed-sql-files.outputs.files_changed == 'true'
|
||||
env:
|
||||
|
||||
@@ -1,406 +0,0 @@
|
||||
name: Visual Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled, synchronize]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
visual-diff:
|
||||
name: Visual Diff Screenshots
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
(github.event.action == 'labeled' && github.event.label.name == 'visual-review') ||
|
||||
(github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'visual-review'))
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./e2e
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Determine changed web files
|
||||
id: changed-files
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
script: |
|
||||
const files = [];
|
||||
const perPage = 100;
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const { data } = await github.rest.pulls.listFiles({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
per_page: perPage,
|
||||
page,
|
||||
});
|
||||
files.push(...data);
|
||||
if (data.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
const webPrefixes = ['web/', 'i18n/', 'open-api/typescript-sdk/'];
|
||||
const webFiles = files
|
||||
.map(f => f.filename)
|
||||
.filter(f => webPrefixes.some(p => f.startsWith(p)));
|
||||
|
||||
console.log(`Total PR files: ${files.length}`);
|
||||
console.log(`Web-related files: ${webFiles.length}`);
|
||||
for (const f of webFiles) {
|
||||
console.log(` ${f}`);
|
||||
}
|
||||
|
||||
core.setOutput('files', webFiles.join('\n'));
|
||||
core.setOutput('has_changes', webFiles.length > 0 ? 'true' : 'false');
|
||||
|
||||
- name: Setup pnpm
|
||||
if: steps.changed-files.outputs.has_changes == 'true'
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
- name: Setup Node
|
||||
if: steps.changed-files.outputs.has_changes == 'true'
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
|
||||
- name: Install e2e dependencies
|
||||
if: steps.changed-files.outputs.has_changes == 'true'
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright
|
||||
if: steps.changed-files.outputs.has_changes == 'true'
|
||||
run: pnpm exec playwright install chromium --only-shell
|
||||
|
||||
- name: Analyze affected routes
|
||||
if: steps.changed-files.outputs.has_changes == 'true'
|
||||
id: routes
|
||||
env:
|
||||
CHANGED_FILES: ${{ steps.changed-files.outputs.files }}
|
||||
run: |
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED_FILES"
|
||||
echo "---"
|
||||
|
||||
ROUTES=$(echo "$CHANGED_FILES" | xargs pnpm exec tsx src/screenshots/analyze-deps.ts 2>&1 | tee /dev/stderr | grep "^ /" | sed 's/^ //' || true)
|
||||
|
||||
echo "routes<<EOF" >> "$GITHUB_OUTPUT"
|
||||
echo "$ROUTES" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ -z "$ROUTES" ]; then
|
||||
echo "has_routes=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_routes=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Build the scenario filter JSON array
|
||||
SCENARIO_NAMES=$(pnpm exec tsx -e "
|
||||
import { getScenariosForRoutes } from './src/screenshots/page-map.ts';
|
||||
const routes = process.argv.slice(1);
|
||||
const scenarios = getScenariosForRoutes(routes);
|
||||
console.log(JSON.stringify(scenarios.map(s => s.name)));
|
||||
" $ROUTES)
|
||||
echo "scenarios=$SCENARIO_NAMES" >> "$GITHUB_OUTPUT"
|
||||
echo "Scenarios: $SCENARIO_NAMES"
|
||||
fi
|
||||
|
||||
- name: Post initial comment
|
||||
if: steps.changed-files.outputs.has_changes == 'true' && steps.routes.outputs.has_routes == 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
AFFECTED_ROUTES: ${{ steps.routes.outputs.routes }}
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
script: |
|
||||
const routes = process.env.AFFECTED_ROUTES || '';
|
||||
const body = `## Visual Review\n\nGenerating screenshots for affected pages...\n\nAffected routes:\n\`\`\`\n${routes}\n\`\`\``;
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.data.find(c => c.body && c.body.includes('## Visual Review'));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
# === Screenshot PR version ===
|
||||
- name: Build SDK (PR)
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
|
||||
- name: Build web (PR)
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./web
|
||||
|
||||
- name: Take screenshots (PR)
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
env:
|
||||
PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS: '1'
|
||||
SCREENSHOT_OUTPUT_DIR: ${{ github.workspace }}/screenshots/pr
|
||||
SCREENSHOT_SCENARIOS: ${{ steps.routes.outputs.scenarios }}
|
||||
SCREENSHOT_BASE_URL: http://127.0.0.1:4173
|
||||
run: |
|
||||
# Start the preview server in background
|
||||
cd ../web && pnpm preview --port 4173 --host 127.0.0.1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Wait for server to be ready
|
||||
for i in $(seq 1 30); do
|
||||
if curl -s http://127.0.0.1:4173 > /dev/null 2>&1; then
|
||||
echo "Server ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Run screenshot tests
|
||||
pnpm exec playwright test --config playwright.screenshot.config.ts || true
|
||||
|
||||
# Stop the preview server and all children (pnpm spawns vite as child)
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
sleep 1
|
||||
# Ensure port is fully released — kill any lingering vite process
|
||||
fuser -k 4173/tcp 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# === Screenshot base version ===
|
||||
# Disable pnpm's verifyDepsBeforeRun for all base steps since the base
|
||||
# checkout changes package.json files, making them mismatch the lockfile.
|
||||
- name: Checkout base web directory
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
# Restore web directory from base branch
|
||||
git checkout "$BASE_SHA" -- web/ open-api/typescript-sdk/ i18n/ || true
|
||||
# Clear SvelteKit build cache to avoid stale artifacts from the PR build
|
||||
rm -rf web/.svelte-kit web/build
|
||||
working-directory: .
|
||||
|
||||
- name: Build SDK (base)
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
continue-on-error: true
|
||||
id: base-sdk
|
||||
env:
|
||||
PNPM_VERIFY_DEPS_BEFORE_RUN: 'false'
|
||||
run: pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
|
||||
- name: Build web (base)
|
||||
if: steps.routes.outputs.has_routes == 'true' && steps.base-sdk.outcome == 'success'
|
||||
continue-on-error: true
|
||||
id: base-web
|
||||
env:
|
||||
PNPM_VERIFY_DEPS_BEFORE_RUN: 'false'
|
||||
run: pnpm build
|
||||
working-directory: ./web
|
||||
|
||||
- name: Take screenshots (base)
|
||||
if: steps.routes.outputs.has_routes == 'true' && steps.base-web.outcome == 'success'
|
||||
env:
|
||||
PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS: '1'
|
||||
SCREENSHOT_OUTPUT_DIR: ${{ github.workspace }}/screenshots/base
|
||||
SCREENSHOT_SCENARIOS: ${{ steps.routes.outputs.scenarios }}
|
||||
SCREENSHOT_BASE_URL: http://127.0.0.1:4173
|
||||
PNPM_VERIFY_DEPS_BEFORE_RUN: 'false'
|
||||
run: |
|
||||
# Kill any process still on port 4173 from the PR step
|
||||
fuser -k 4173/tcp 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# Start the preview server in background
|
||||
cd ../web && pnpm preview --port 4173 --host 127.0.0.1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Wait for server to be ready
|
||||
for i in $(seq 1 30); do
|
||||
if curl -s http://127.0.0.1:4173 > /dev/null 2>&1; then
|
||||
echo "Server ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Run screenshot tests
|
||||
pnpm exec playwright test --config playwright.screenshot.config.ts || true
|
||||
|
||||
# Stop the preview server
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
fuser -k 4173/tcp 2>/dev/null || true
|
||||
|
||||
- name: Restore PR source
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
env:
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
git checkout "$HEAD_SHA" -- web/ open-api/typescript-sdk/ i18n/ || true
|
||||
working-directory: .
|
||||
|
||||
# === Compare and report ===
|
||||
- name: Compare screenshots
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
env:
|
||||
WORKSPACE_DIR: ${{ github.workspace }}
|
||||
run: |
|
||||
# Ensure directories exist even if base screenshots were skipped
|
||||
mkdir -p "$WORKSPACE_DIR/screenshots/base" "$WORKSPACE_DIR/screenshots/pr" "$WORKSPACE_DIR/screenshots/diff"
|
||||
pnpm exec tsx src/screenshots/compare.ts \
|
||||
"$WORKSPACE_DIR/screenshots/base" \
|
||||
"$WORKSPACE_DIR/screenshots/pr" \
|
||||
"$WORKSPACE_DIR/screenshots/diff"
|
||||
|
||||
- name: Upload screenshot artifacts
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: visual-review-screenshots
|
||||
path: screenshots/
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload HTML report
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
id: html-report
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
path: screenshots/diff/visual-review.html
|
||||
archive: false
|
||||
retention-days: 14
|
||||
|
||||
- name: Post comparison results
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
REPORT_URL: ${{ steps.html-report.outputs.artifact-url }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const reportPath = path.join(process.env.GITHUB_WORKSPACE, 'screenshots', 'diff', 'report.md');
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = fs.readFileSync(reportPath, 'utf8');
|
||||
} catch {
|
||||
body = '## Visual Review\n\nScreenshot comparison failed. Check the workflow artifacts for details.';
|
||||
}
|
||||
|
||||
// Append links to the HTML report artifact and workflow run
|
||||
const reportUrl = process.env.REPORT_URL;
|
||||
const runUrl = process.env.RUN_URL;
|
||||
body += '\n---\n';
|
||||
if (reportUrl) {
|
||||
body += `[View full visual comparison](${reportUrl}) | `;
|
||||
}
|
||||
body += `[Download all screenshots](${runUrl}#artifacts)\n`;
|
||||
|
||||
// Find and update existing comment or create new one
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const botComment = comments.data.find(c =>
|
||||
c.body && c.body.includes('## Visual Review')
|
||||
);
|
||||
|
||||
if (botComment) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
- name: No web changes
|
||||
if: steps.changed-files.outputs.has_changes != 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
script: |
|
||||
const body = '## Visual Review\n\nNo web-related file changes detected in this PR. Visual review not needed.';
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.data.find(c => c.body && c.body.includes('## Visual Review'));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
comment_id: existing.id, body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
issue_number: context.issue.number, body,
|
||||
});
|
||||
}
|
||||
|
||||
- name: No affected routes
|
||||
if: steps.changed-files.outputs.has_changes == 'true' && steps.routes.outputs.has_routes != 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
script: |
|
||||
const body = '## Visual Review\n\nChanged files don\'t affect any pages with screenshot scenarios configured.\nTo add coverage, define new scenarios in `e2e/src/screenshots/page-map.ts`.';
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.data.find(c => c.body && c.body.includes('## Visual Review'));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
comment_id: existing.id, body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
issue_number: context.issue.number, body,
|
||||
});
|
||||
}
|
||||
@@ -24,19 +24,19 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||
uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
i18n:
|
||||
- modified: 'i18n/!(en|package)**\.json'
|
||||
- modified: 'i18n/!(en)**\.json'
|
||||
skip-force-logic: 'true'
|
||||
|
||||
enforce-lock:
|
||||
@@ -47,9 +47,9 @@ jobs:
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Bot review status
|
||||
@@ -68,6 +68,6 @@ jobs:
|
||||
permissions: {}
|
||||
if: always()
|
||||
steps:
|
||||
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
|
||||
- uses: immich-app/devtools/actions/success-check@81113db03f6d743efee81e0058c0b43f6cd6f36d # success-check-action-v0.0.6
|
||||
with:
|
||||
needs: ${{ toJSON(needs) }}
|
||||
|
||||
+3
-2
@@ -20,12 +20,13 @@ mobile/openapi/doc
|
||||
mobile/openapi/.openapi-generator/FILES
|
||||
mobile/ios/build
|
||||
|
||||
open-api/typescript-sdk/build
|
||||
packages/**/build
|
||||
mobile/android/fastlane/report.xml
|
||||
mobile/ios/fastlane/report.xml
|
||||
|
||||
screenshots-output
|
||||
vite.config.js.timestamp-*
|
||||
.pnpm-store
|
||||
.devcontainer/library
|
||||
.devcontainer/.env*
|
||||
*.tsbuildinfo
|
||||
*.tsbuildInfo
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
[submodule "mobile/.isar"]
|
||||
path = mobile/.isar
|
||||
url = https://github.com/isar/isar
|
||||
[submodule "e2e/test-assets"]
|
||||
path = e2e/test-assets
|
||||
url = https://github.com/immich-app/test-assets
|
||||
|
||||
Vendored
+8
-1
@@ -5,6 +5,13 @@
|
||||
"dbaeumer.vscode-eslint",
|
||||
"dart-code.flutter",
|
||||
"dart-code.dart-code",
|
||||
"dcmdev.dcm-vscode-extension"
|
||||
"dcmdev.dcm-vscode-extension",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"ms-playwright.playwright",
|
||||
"vitest.explorer",
|
||||
"editorconfig.editorconfig",
|
||||
"foxundermoon.shell-format",
|
||||
"timonwong.shellcheck",
|
||||
"bluebrown.yamlfmt"
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+6
-4
@@ -23,15 +23,17 @@
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Immich CLI",
|
||||
"program": "${workspaceFolder}/cli/dist/index.js",
|
||||
"program": "${workspaceFolder}/packages/cli/dist/index.js",
|
||||
"args": ["upload", "--help"],
|
||||
"runtimeArgs": ["--enable-source-maps"],
|
||||
"console": "integratedTerminal",
|
||||
"resolveSourceMapLocations": ["${workspaceFolder}/cli/dist/**/*.js.map"],
|
||||
"resolveSourceMapLocations": [
|
||||
"${workspaceFolder}/packages/cli/dist/**/*.js.map"
|
||||
],
|
||||
"sourceMaps": true,
|
||||
"outFiles": ["${workspaceFolder}/cli/dist/**/*.js"],
|
||||
"outFiles": ["${workspaceFolder}/packages/cli/dist/**/*.js"],
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"preLaunchTask": "Build Immich CLI"
|
||||
"preLaunchTask": "Build @immich/cli"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+38
-24
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[dart]": {
|
||||
"editor.defaultFormatter": "Dart-Code.dart-code",
|
||||
@@ -14,51 +13,66 @@
|
||||
"editor.wordBasedSuggestions": "off"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "explicit",
|
||||
"source.removeUnusedImports": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[svelte]": {
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "explicit",
|
||||
"source.removeUnusedImports": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "svelte.svelte-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2
|
||||
"tailwindCSS.lint.suggestCanonicalClasses": "ignore"
|
||||
},
|
||||
"svelte.plugin.svelte.compilerWarnings": {
|
||||
"state_referenced_locally": "ignore"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "explicit",
|
||||
"source.removeUnusedImports": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"cSpell.words": ["immich"],
|
||||
"css.lint.unknownAtRules": "ignore",
|
||||
"editor.bracketPairColorization.enabled": true,
|
||||
"editor.formatOnSave": true,
|
||||
"eslint.useFlatConfig": true,
|
||||
"eslint.validate": ["javascript", "typescript", "svelte"],
|
||||
"eslint.workingDirectories": [
|
||||
{ "directory": "cli", "changeProcessCWD": true },
|
||||
{ "directory": "e2e", "changeProcessCWD": true },
|
||||
{ "directory": "server", "changeProcessCWD": true },
|
||||
{ "directory": "web", "changeProcessCWD": true }
|
||||
],
|
||||
"files.watcherExclude": {
|
||||
"**/.jj/**": true,
|
||||
"**/.git/**": true,
|
||||
"**/node_modules/**": true,
|
||||
"**/build/**": true,
|
||||
"**/dist/**": true,
|
||||
"**/.svelte-kit/**": true
|
||||
},
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.patterns": {
|
||||
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
||||
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
|
||||
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/node_modules": true,
|
||||
"**/build": true,
|
||||
"**/dist": true,
|
||||
"**/.svelte-kit": true,
|
||||
"**/open-api/typescript-sdk/src": true
|
||||
},
|
||||
"svelte.enable-ts-plugin": true,
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative"
|
||||
"tailwindCSS.experimental.configFile": {
|
||||
"web/src/app.css": "web/src/**"
|
||||
},
|
||||
"js/ts.preferences.importModuleSpecifier": "non-relative",
|
||||
"vitest.maximumConfigs": 10
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ Please try to keep pull requests as focused as possible. A PR should do exactly
|
||||
|
||||
If you are looking for something to work on, there are discussions and issues with a `good-first-issue` label on them. These are always a good starting point. If none of them sound interesting or fit your skill set, feel free to reach out on our Discord. We're happy to help you find something to work on!
|
||||
|
||||
We usually do not assign issues to new contributors, since it happens often that a PR is never even opened. Again, reach out on Discord if you fear putting a lot of time into fixing an issue, but ending up with a duplicate PR.
|
||||
|
||||
## Use of generative AI
|
||||
|
||||
We ask you not to open PRs generated with an LLM. We find that code generated like this tends to need a large amount of back-and-forth, which is a very inefficient use of our time. If we want LLM-generated code, it's much faster for us to use an LLM ourselves than to go through an intermediary via a pull request.
|
||||
|
||||
@@ -37,105 +37,24 @@ prod-scale:
|
||||
|
||||
.PHONY: open-api
|
||||
open-api:
|
||||
cd ./open-api && bash ./bin/generate-open-api.sh
|
||||
|
||||
open-api-dart:
|
||||
cd ./open-api && bash ./bin/generate-open-api.sh dart
|
||||
|
||||
open-api-typescript:
|
||||
cd ./open-api && bash ./bin/generate-open-api.sh typescript
|
||||
@printf "This command has been removed. Please use:\n\n mise open-api # or mise //:open-api from another directory\n\n"\n\n >&2 && exit 1
|
||||
|
||||
sql:
|
||||
pnpm --filter immich run sync:sql
|
||||
@printf "This command has been removed. Please use:\n\n mise sql # or mise //:sql from another directory\n\n"\n\n >&2 && exit 1
|
||||
|
||||
attach-server:
|
||||
docker exec -it docker_immich-server_1 sh
|
||||
|
||||
renovate:
|
||||
LOG_LEVEL=debug pnpm exec renovate --platform=local --repository-cache=reset
|
||||
|
||||
# Directories that need to be created for volumes or build output
|
||||
VOLUME_DIRS = \
|
||||
./.pnpm-store \
|
||||
./web/.svelte-kit \
|
||||
./web/node_modules \
|
||||
./web/coverage \
|
||||
./e2e/node_modules \
|
||||
./docs/node_modules \
|
||||
./server/node_modules \
|
||||
./open-api/typescript-sdk/node_modules \
|
||||
./.github/node_modules \
|
||||
./node_modules \
|
||||
./cli/node_modules
|
||||
|
||||
# Include .env file if it exists
|
||||
-include docker/.env
|
||||
|
||||
MODULES = e2e server web cli sdk docs .github
|
||||
|
||||
# directory to package name mapping function
|
||||
# cli = @immich/cli
|
||||
# docs = documentation
|
||||
# e2e = immich-e2e
|
||||
# open-api/typescript-sdk = @immich/sdk
|
||||
# server = immich
|
||||
# web = immich-web
|
||||
map-package = $(subst sdk,@immich/sdk,$(subst cli,@immich/cli,$(subst docs,documentation,$(subst e2e,immich-e2e,$(subst server,immich,$(subst web,immich-web,$1))))))
|
||||
|
||||
audit-%:
|
||||
pnpm --filter $(call map-package,$*) audit fix
|
||||
install-%:
|
||||
pnpm --filter $(call map-package,$*) install $(if $(FROZEN),--frozen-lockfile) $(if $(OFFLINE),--offline)
|
||||
build-cli: build-sdk
|
||||
build-web: build-sdk
|
||||
build-%: install-%
|
||||
pnpm --filter $(call map-package,$*) run build
|
||||
format-%:
|
||||
pnpm --filter $(call map-package,$*) run format:fix
|
||||
lint-%:
|
||||
pnpm --filter $(call map-package,$*) run lint:fix
|
||||
check-%:
|
||||
pnpm --filter $(call map-package,$*) run check
|
||||
check-web:
|
||||
pnpm --filter immich-web run check:typescript
|
||||
pnpm --filter immich-web run check:svelte
|
||||
test-%:
|
||||
pnpm --filter $(call map-package,$*) run test
|
||||
test-e2e:
|
||||
docker compose -f ./e2e/docker-compose.yml build
|
||||
pnpm --filter immich-e2e run test
|
||||
pnpm --filter immich-e2e run test:web
|
||||
test-medium:
|
||||
docker run \
|
||||
--rm \
|
||||
-v ./server/src:/usr/src/app/src \
|
||||
-v ./server/test:/usr/src/app/test \
|
||||
-v ./server/vitest.config.medium.mjs:/usr/src/app/vitest.config.medium.mjs \
|
||||
-v ./server/tsconfig.json:/usr/src/app/tsconfig.json \
|
||||
-e NODE_ENV=development \
|
||||
immich-server:latest \
|
||||
-c "pnpm test:medium -- --run"
|
||||
test-medium-dev:
|
||||
docker exec -it immich_server /bin/sh -c "pnpm run test:medium"
|
||||
|
||||
install-all:
|
||||
pnpm -r --filter '!documentation' install
|
||||
|
||||
build-all: $(foreach M,$(filter-out e2e docs .github,$(MODULES)),build-$M) ;
|
||||
|
||||
check-all:
|
||||
pnpm -r --filter '!documentation' run "/^(check|check\:svelte|check\:typescript)$/"
|
||||
lint-all:
|
||||
pnpm -r --filter '!documentation' run lint:fix
|
||||
format-all:
|
||||
pnpm -r --filter '!documentation' run format:fix
|
||||
audit-all:
|
||||
pnpm -r --filter '!documentation' audit fix
|
||||
hygiene-all: audit-all
|
||||
pnpm -r --filter '!documentation' run "/(format:fix|check|check:svelte|check:typescript|sql)/"
|
||||
|
||||
test-all:
|
||||
pnpm -r --filter '!documentation' run "/^test/"
|
||||
|
||||
clean:
|
||||
find . -name "node_modules" -type d -prune -exec rm -rf {} +
|
||||
@@ -146,7 +65,3 @@ clean:
|
||||
find . -name ".pnpm-store" -type d -prune -exec rm -rf '{}' +
|
||||
command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml down -v --remove-orphans || true
|
||||
command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml down -v --remove-orphans || true
|
||||
|
||||
|
||||
setup-server-dev: install-server
|
||||
setup-web-dev: install-sdk build-sdk install-web
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
24.13.1
|
||||
@@ -1,14 +0,0 @@
|
||||
FROM node:24.1.0-alpine3.20@sha256:8fe019e0d57dbdce5f5c27c0b63d2775cf34b00e3755a7dea969802d7e0c2b25 AS core
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
COPY package* pnpm* .pnpmfile.cjs ./
|
||||
COPY ./cli ./cli/
|
||||
COPY ./open-api/typescript-sdk ./open-api/typescript-sdk/
|
||||
RUN corepack enable pnpm && \
|
||||
pnpm install --filter @immich/sdk --filter @immich/cli --frozen-lockfile && \
|
||||
pnpm --filter @immich/sdk build && \
|
||||
pnpm --filter @immich/cli build
|
||||
|
||||
WORKDIR /import
|
||||
|
||||
ENTRYPOINT ["node", "/usr/src/app/cli/dist"]
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
[tools]
|
||||
terragrunt = "0.98.0"
|
||||
opentofu = "1.11.4"
|
||||
terragrunt = "1.0.3"
|
||||
opentofu = "1.11.6"
|
||||
|
||||
[tasks."tg:fmt"]
|
||||
run = "terragrunt hclfmt"
|
||||
|
||||
+30
-30
@@ -2,37 +2,37 @@
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||
version = "4.52.5"
|
||||
constraints = "4.52.5"
|
||||
version = "4.52.7"
|
||||
constraints = "4.52.7"
|
||||
hashes = [
|
||||
"h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=",
|
||||
"h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=",
|
||||
"h1:4vZVOpKeEQZsF2VrARRZFeL37Ed/gD4rRMtfnvWQres=",
|
||||
"h1:BZOsTF83QPKXTAaYqxPKzdl1KRjk/L2qbPpFjM0w28A=",
|
||||
"h1:CDuC+HXLvc1z6wkCRsSDcc/+QENIHEtssYshiWg3opA=",
|
||||
"h1:DE+YFzLnqSe79pI2R4idRGx5QzLdrA7RXvngTkGfZ30=",
|
||||
"h1:DfaJwH3Ml4yrRbdAY4AcDVy0QTQk5T3A622TXzS/u2E=",
|
||||
"h1:EIDXP0W3kgIv2pecrFmqtK/DnlqkyckzBzhxKaXU+4A=",
|
||||
"h1:EV4kYyaOnwGA0bh/3hU6Ezqnt1PFDxopH7i85e48IzY=",
|
||||
"h1:M0iXabfzamU+MPDi0G9XACpbacFKMakmM+Z9HZ8HrsM=",
|
||||
"h1:YWmCbGF/KbsrUzcYVBLscwLizidbp95TDQa0N2qpmVo=",
|
||||
"h1:cxPcCB5gbrpUO1+IXkQYs1YTY50/0IlApCzGea0cwuQ=",
|
||||
"h1:g6DldikTV2HXUu9uoeNY5FuLufgaYWF4ufgZg7wq62s=",
|
||||
"h1:oi/Hrx9pwoQ+Z52CBC+rrowVH387EIj0qvnxQgDeI+0=",
|
||||
"zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b",
|
||||
"zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a",
|
||||
"zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7",
|
||||
"zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238",
|
||||
"zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b",
|
||||
"zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072",
|
||||
"zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661",
|
||||
"zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c",
|
||||
"h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=",
|
||||
"h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=",
|
||||
"h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=",
|
||||
"h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=",
|
||||
"h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=",
|
||||
"h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=",
|
||||
"h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=",
|
||||
"h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=",
|
||||
"h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=",
|
||||
"h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=",
|
||||
"h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=",
|
||||
"h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=",
|
||||
"h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=",
|
||||
"h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=",
|
||||
"zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
|
||||
"zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
|
||||
"zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
|
||||
"zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
|
||||
"zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
|
||||
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
||||
"zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54",
|
||||
"zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852",
|
||||
"zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0",
|
||||
"zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5",
|
||||
"zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036",
|
||||
"zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803",
|
||||
"zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
|
||||
"zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
|
||||
"zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
|
||||
"zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
|
||||
"zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
|
||||
"zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
|
||||
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
|
||||
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
|
||||
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ terraform {
|
||||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "4.52.5"
|
||||
version = "4.52.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-30
@@ -2,37 +2,37 @@
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||
version = "4.52.5"
|
||||
constraints = "4.52.5"
|
||||
version = "4.52.7"
|
||||
constraints = "4.52.7"
|
||||
hashes = [
|
||||
"h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=",
|
||||
"h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=",
|
||||
"h1:4vZVOpKeEQZsF2VrARRZFeL37Ed/gD4rRMtfnvWQres=",
|
||||
"h1:BZOsTF83QPKXTAaYqxPKzdl1KRjk/L2qbPpFjM0w28A=",
|
||||
"h1:CDuC+HXLvc1z6wkCRsSDcc/+QENIHEtssYshiWg3opA=",
|
||||
"h1:DE+YFzLnqSe79pI2R4idRGx5QzLdrA7RXvngTkGfZ30=",
|
||||
"h1:DfaJwH3Ml4yrRbdAY4AcDVy0QTQk5T3A622TXzS/u2E=",
|
||||
"h1:EIDXP0W3kgIv2pecrFmqtK/DnlqkyckzBzhxKaXU+4A=",
|
||||
"h1:EV4kYyaOnwGA0bh/3hU6Ezqnt1PFDxopH7i85e48IzY=",
|
||||
"h1:M0iXabfzamU+MPDi0G9XACpbacFKMakmM+Z9HZ8HrsM=",
|
||||
"h1:YWmCbGF/KbsrUzcYVBLscwLizidbp95TDQa0N2qpmVo=",
|
||||
"h1:cxPcCB5gbrpUO1+IXkQYs1YTY50/0IlApCzGea0cwuQ=",
|
||||
"h1:g6DldikTV2HXUu9uoeNY5FuLufgaYWF4ufgZg7wq62s=",
|
||||
"h1:oi/Hrx9pwoQ+Z52CBC+rrowVH387EIj0qvnxQgDeI+0=",
|
||||
"zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b",
|
||||
"zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a",
|
||||
"zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7",
|
||||
"zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238",
|
||||
"zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b",
|
||||
"zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072",
|
||||
"zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661",
|
||||
"zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c",
|
||||
"h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=",
|
||||
"h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=",
|
||||
"h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=",
|
||||
"h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=",
|
||||
"h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=",
|
||||
"h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=",
|
||||
"h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=",
|
||||
"h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=",
|
||||
"h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=",
|
||||
"h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=",
|
||||
"h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=",
|
||||
"h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=",
|
||||
"h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=",
|
||||
"h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=",
|
||||
"zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
|
||||
"zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
|
||||
"zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
|
||||
"zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
|
||||
"zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
|
||||
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
||||
"zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54",
|
||||
"zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852",
|
||||
"zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0",
|
||||
"zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5",
|
||||
"zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036",
|
||||
"zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803",
|
||||
"zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
|
||||
"zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
|
||||
"zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
|
||||
"zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
|
||||
"zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
|
||||
"zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
|
||||
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
|
||||
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
|
||||
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ terraform {
|
||||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "4.52.5"
|
||||
version = "4.52.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,14 +20,15 @@ services:
|
||||
- /tmp
|
||||
volumes:
|
||||
- ..:/usr/src/app
|
||||
# - ../../ui:/usr/src/ui
|
||||
- pnpm_cache:/buildcache/pnpm_cache
|
||||
- server_node_modules:/usr/src/app/server/node_modules
|
||||
- web_node_modules:/usr/src/app/web/node_modules
|
||||
- github_node_modules:/usr/src/app/.github/node_modules
|
||||
- cli_node_modules:/usr/src/app/cli/node_modules
|
||||
- cli_node_modules:/usr/src/app/packages/cli/node_modules
|
||||
- docs_node_modules:/usr/src/app/docs/node_modules
|
||||
- e2e_node_modules:/usr/src/app/e2e/node_modules
|
||||
- sdk_node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||
- sdk_node_modules:/usr/src/app/packages/sdk/node_modules
|
||||
- app_node_modules:/usr/src/app/node_modules
|
||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||
- coverage:/usr/src/app/web/coverage
|
||||
@@ -73,7 +74,7 @@ services:
|
||||
- ${UPLOAD_LOCATION}/photos:/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- pnpm_store_server:/buildcache/pnpm-store
|
||||
- ../plugins:/build/corePlugin
|
||||
- ../packages/plugin-core:/build/plugins/immich-plugin-core
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
@@ -90,6 +91,7 @@ services:
|
||||
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues
|
||||
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app
|
||||
IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides
|
||||
IMMICH_HELMET_FILE: 'true'
|
||||
ports:
|
||||
- 9230:9230
|
||||
- 9231:9231
|
||||
@@ -155,7 +157,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
@@ -85,7 +85,7 @@ services:
|
||||
container_name: immich_prometheus
|
||||
ports:
|
||||
- 9090:9090
|
||||
image: prom/prometheus@sha256:1f0f50f06acaceb0f5670d2c8a658a599affe7b0d8e78b898c1035653849a702
|
||||
image: prom/prometheus@sha256:e4254400b85610324913f0dc4acf92603d9984e7519414c5a12811aa6146acc3
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- prometheus-data:/prometheus
|
||||
@@ -97,7 +97,7 @@ services:
|
||||
command: ['./run.sh', '-disable-reporting']
|
||||
ports:
|
||||
- 3000:3000
|
||||
image: grafana/grafana:12.3.2-ubuntu@sha256:6cca4b429a1dc0d37d401dee54825c12d40056c3c6f3f56e3f0d6318ce77749b
|
||||
image: grafana/grafana:12.4.3-ubuntu@sha256:ca3f764fdc48cebdf22dd206f33ecb0795a9a7210eacd1b5c02204aebd78b223
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193
|
||||
user: '1000:1000'
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
@@ -95,6 +95,3 @@ services:
|
||||
restart: always
|
||||
healthcheck:
|
||||
disable: false
|
||||
|
||||
volumes:
|
||||
model-cache:
|
||||
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
24.13.1
|
||||
@@ -210,7 +210,7 @@ The provided restore process ensures your database is never in a broken state by
|
||||
|
||||
## Filesystem
|
||||
|
||||
Immich stores two types of content in the filesystem: (a) original, unmodified assets (photos and videos), and (b) generated content. We recommend backing up the entire contents of `UPLOAD_LOCATION`, but only the original content is critical, which is stored in the following folders:
|
||||
Immich does not handle filesystem backups for you. You have to arrange these yourself! Immich stores two types of content in the filesystem: (a) original, unmodified assets (photos and videos), and (b) generated content. We recommend backing up the entire contents of `UPLOAD_LOCATION`, but only the original content is critical, which is stored in the following folders:
|
||||
|
||||
1. `UPLOAD_LOCATION/library`
|
||||
2. `UPLOAD_LOCATION/upload`
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -67,7 +67,8 @@ graph TD
|
||||
C --> D["Thumbnail Generation (Large, small, blurred and person)"]
|
||||
D --> E[Smart Search]
|
||||
D --> F[Face Detection]
|
||||
D --> G[Video Transcoding]
|
||||
E --> H[Duplicate Detection]
|
||||
F --> I[Facial Recognition]
|
||||
D --> G[OCR]
|
||||
D --> H[Video Transcoding]
|
||||
E --> I[Duplicate Detection]
|
||||
F --> J[Facial Recognition]
|
||||
```
|
||||
|
||||
@@ -14,6 +14,7 @@ Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an i
|
||||
- [Authelia](https://www.authelia.com/integration/openid-connect/immich/)
|
||||
- [Okta](https://www.okta.com/openid-connect/)
|
||||
- [Google](https://developers.google.com/identity/openid-connect/openid-connect)
|
||||
- [Keycloak](https://www.keycloak.org)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -49,6 +50,10 @@ Before enabling OAuth in Immich, a new client application needs to be configured
|
||||
- `https://immich.example.com/auth/login`
|
||||
- `https://immich.example.com/user-settings`
|
||||
|
||||
3. Configure Backchannel logout URL
|
||||
|
||||
If the authentication server supports it, the **Backchannel logout URL** can be specified, and it is of the form: `http://DOMAIN:PORT/api/oauth/backchannel-logout`.
|
||||
|
||||
## Enable OAuth
|
||||
|
||||
Once you have a new OAuth client application configured, Immich can be configured using the Administration Settings page, available on the web (Administration -> Settings).
|
||||
@@ -62,6 +67,8 @@ Once you have a new OAuth client application configured, Immich can be configure
|
||||
| `scope` | string | openid email profile | Full list of scopes to send with the request (space delimited) |
|
||||
| `id_token_signed_response_alg` | string | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) |
|
||||
| `userinfo_signed_response_alg` | string | none | The algorithm used to sign the userinfo response (examples: RS256, HS256) |
|
||||
| `prompt` | string | (empty) | Prompt parameter for authorization url (examples: select_account, login, consent) |
|
||||
| `end_session_endpoint` | URL | (empty) | Http(s) alternative end session endpoint (logout URI) |
|
||||
| Request timeout | string | 30,000 (30 seconds) | Number of milliseconds to wait for http requests to complete before giving up |
|
||||
| Storage Label Claim | string | preferred_username | Claim mapping for the user's storage label**¹** |
|
||||
| Role Claim | string | immich_role | Claim mapping for the user's role. (should return "user" or "admin")**¹** |
|
||||
@@ -180,6 +187,7 @@ Configuration of OAuth in Immich System Settings
|
||||
| Scope | openid email profile immich_scope |
|
||||
| ID Token Signed Response Algorithm | RS256 |
|
||||
| Userinfo Signed Response Algorithm | RS256 |
|
||||
| End Session Endpoint | https://auth.example.com/logout?rd=https://immich.example.com/ |
|
||||
| Storage Label Claim | uid |
|
||||
| Storage Quota Claim | immich_quota |
|
||||
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
|
||||
@@ -253,4 +261,40 @@ Configuration of OAuth in Immich System Settings
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Keycloak Example</summary>
|
||||
|
||||
### Keycloak Example
|
||||
|
||||
Here's an example of OAuth configured for Keycloak:
|
||||
|
||||
Create your immich client on your Keycloak Realm.
|
||||
|
||||
<img src={require('./img/keycloak-general-settings.webp').default} width='100%' title="Keycloak Client general Settings" />
|
||||
<img src={require('./img/keycloak-access-settings.webp').default} width='100%' title="Keycloak Client Access Settings" />
|
||||
<img src={require('./img/keycloak-capability-config.webp').default} width='100%' title="Keycloak Client Capability Configuration" />
|
||||
|
||||
Configuration of OAuth in Immich System Settings
|
||||
|
||||
| Setting | Value |
|
||||
| ---------------------------- | ----------------------------------------------------- |
|
||||
| Issuer URL | `https://<KEYCLOAK_DOMAIN>/realms/<YOUR_REALM>` |
|
||||
| Client ID | immich |
|
||||
| Client Secret | can be optained from Clients -> immich -> Credentials |
|
||||
| Scope | openid email profile |
|
||||
| Signing Algorithm | RS256 |
|
||||
| Storage Label Claim | preferred_username |
|
||||
| Role Claim | immich_role |
|
||||
| Storage Quota Claim | immich_quota |
|
||||
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
|
||||
| Button Text | Sign in with Keycloak (recommended) |
|
||||
| Auto Register | Enabled (optional) |
|
||||
| Auto Launch | Enabled (optional) |
|
||||
| Mobile Redirect URI Override | Disabled |
|
||||
| Mobile Redirect URI | |
|
||||
|
||||
Role Claim can be managed via Client Role. Remember to create a mapper with claim name `immich_role`.
|
||||
|
||||
</details>
|
||||
|
||||
[oidc]: https://openid.net/connect/
|
||||
|
||||
@@ -81,7 +81,7 @@ VectorChord is the successor extension to pgvecto.rs, allowing for higher perfor
|
||||
|
||||
### Migrating from pgvecto.rs
|
||||
|
||||
Support for pgvecto.rs will be dropped in a later release, hence we recommend all users currently using pgvecto.rs to migrate to VectorChord at their convenience. There are two primary approaches to do so.
|
||||
Support for pgvecto.rs has been dropped as of 3.0, hence all users currently using pgvecto.rs should migrate to VectorChord. There are two primary approaches to do so.
|
||||
|
||||
The easiest option is to have both extensions installed during the migration:
|
||||
|
||||
|
||||
@@ -13,8 +13,11 @@ The `immich-server` docker image comes preinstalled with an administrative CLI (
|
||||
| `enable-oauth-login` | Enable OAuth login |
|
||||
| `disable-oauth-login` | Disable OAuth login |
|
||||
| `list-users` | List Immich users |
|
||||
| `grant-admin` | Grant admin privileges to a user (by email) |
|
||||
| `revoke-admin` | Revoke admin privileges from a user (by email) |
|
||||
| `version` | Print Immich version |
|
||||
| `change-media-location` | Change database file paths to align with a new media location |
|
||||
| `schema-check` | Verify database migrations and check for schema drift |
|
||||
|
||||
## How to run a command
|
||||
|
||||
@@ -102,6 +105,22 @@ immich-admin list-users
|
||||
]
|
||||
```
|
||||
|
||||
Grant Admin
|
||||
|
||||
```
|
||||
immich-admin grant-admin
|
||||
? Please enter the user email: user@example.com
|
||||
Admin access has been granted to user@example.com
|
||||
```
|
||||
|
||||
Revoke Admin
|
||||
|
||||
```
|
||||
immich-admin revoke-admin
|
||||
? Please enter the user email: user@example.com
|
||||
Admin access has been revoked from user@example.com
|
||||
```
|
||||
|
||||
Print Immich Version
|
||||
|
||||
```
|
||||
@@ -126,3 +145,12 @@ immich-admin change-media-location
|
||||
Database file paths updated successfully! 🎉
|
||||
...
|
||||
```
|
||||
|
||||
Schema Check
|
||||
|
||||
```
|
||||
immich-admin schema-check
|
||||
Migrations are up to date
|
||||
|
||||
No schema drift detected
|
||||
```
|
||||
|
||||
@@ -230,7 +230,7 @@ The default value is `ultrafast`.
|
||||
|
||||
### Audio codec (`ffmpeg.targetAudioCodec`) {#ffmpeg.targetAudioCodec}
|
||||
|
||||
Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `libopus`.
|
||||
Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `opus`.
|
||||
|
||||
The default value is `aac`.
|
||||
|
||||
|
||||
+1
-1
@@ -10,4 +10,4 @@ OpenAPI is used to generate the client (Typescript, Dart) SDK. `openapi-generato
|
||||
make open-api
|
||||
```
|
||||
|
||||
You can find the generated client SDK in the `open-api/typescript-sdk/client` for Typescript SDK and `mobile/openapi` for Dart SDK.
|
||||
You can find the generated client SDK in the `packages/sdk/client` for Typescript SDK and `mobile/openapi` for Dart SDK.
|
||||
|
||||
@@ -205,7 +205,7 @@ When the Dev Container starts, it automatically:
|
||||
1. **Runs post-create script** (`container-server-post-create.sh`):
|
||||
- Adjusts file permissions for the `node` user
|
||||
- Installs dependencies: `pnpm install` in all packages
|
||||
- Builds TypeScript SDK: `pnpm run build` in `open-api/typescript-sdk`
|
||||
- Builds TypeScript SDK: `pnpm --filter @immich/sdk build`
|
||||
|
||||
2. **Starts development servers** via VS Code tasks:
|
||||
- `Immich API Server (Nest)` - API server with hot-reloading on port 2283
|
||||
@@ -243,8 +243,8 @@ To connect the mobile app to your Dev Container:
|
||||
|
||||
- **Server code** (`/server`): Changes trigger automatic restart
|
||||
- **Web code** (`/web`): Changes trigger hot module replacement
|
||||
- **Database migrations**: Run `pnpm run sync:sql` in the server directory
|
||||
- **API changes**: Regenerate TypeScript SDK with `make open-api`
|
||||
- **Database migrations**: Run `mise //:sql`
|
||||
- **API changes**: Regenerate TypeScript SDK with `mise //:open-api`
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -252,20 +252,11 @@ To connect the mobile app to your Dev Container:
|
||||
|
||||
The Dev Container supports multiple ways to run tests:
|
||||
|
||||
#### Using Make Commands (Recommended)
|
||||
#### Using Mise Commands (Recommended)
|
||||
|
||||
```bash
|
||||
# Run tests for specific components
|
||||
make test-server # Server unit tests
|
||||
make test-web # Web unit tests
|
||||
make test-e2e # End-to-end tests
|
||||
make test-cli # CLI tests
|
||||
|
||||
# Run all tests
|
||||
make test-all # Runs tests for all components
|
||||
|
||||
# Medium tests (integration tests)
|
||||
make test-medium-dev # End-to-end tests
|
||||
mise run checklist # in `server/`, `web/`, `packages/cli`
|
||||
```
|
||||
|
||||
#### Using PNPM Directly
|
||||
@@ -289,48 +280,16 @@ pnpm run test # Run API tests
|
||||
pnpm run test:web # Run web UI tests
|
||||
```
|
||||
|
||||
### Code Quality Commands
|
||||
|
||||
```bash
|
||||
# Linting
|
||||
make lint-server # Lint server code
|
||||
make lint-web # Lint web code
|
||||
make lint-all # Lint all components
|
||||
|
||||
# Formatting
|
||||
make format-server # Format server code
|
||||
make format-web # Format web code
|
||||
make format-all # Format all code
|
||||
|
||||
# Type checking
|
||||
make check-server # Type check server
|
||||
make check-web # Type check web
|
||||
make check-all # Check all components
|
||||
|
||||
# Complete hygiene check
|
||||
make hygiene-all # Run lint, format, check, SQL sync, and audit
|
||||
```
|
||||
|
||||
### Additional Make Commands
|
||||
|
||||
```bash
|
||||
# Build commands
|
||||
make build-server # Build server
|
||||
make build-web # Build web app
|
||||
make build-all # Build everything
|
||||
|
||||
# API generation
|
||||
make open-api # Generate OpenAPI specs
|
||||
make open-api-typescript # Generate TypeScript SDK
|
||||
make open-api-dart # Generate Dart SDK
|
||||
|
||||
# Database
|
||||
make sql # Sync database schema
|
||||
|
||||
# Dependencies
|
||||
make install-server # Install server dependencies
|
||||
make install-web # Install web dependencies
|
||||
make install-all # Install all dependencies
|
||||
mise sql # Sync database schema
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
@@ -10,7 +10,8 @@ Our [GitHub Repository](https://github.com/immich-app/immich) is a [monorepo](ht
|
||||
| :------------------ | :------------------------------------------------------------------- |
|
||||
| `.github/` | Github templates and action workflows |
|
||||
| `.vscode/` | VSCode debug launch profiles |
|
||||
| `cli/` | Source code for the work-in-progress CLI rewrite |
|
||||
| `packages/cli` | Source code for the CLI |
|
||||
| `packages/sdk` | Source code for the generated OpenAPI SDK |
|
||||
| `docker/` | Docker compose resources for dev, test, production |
|
||||
| `design/` | Screenshots and logos for the README |
|
||||
| `docs/` | Source code for the [https://immich.app](https://immich.app) website |
|
||||
|
||||
@@ -34,21 +34,23 @@ Run all web checks with `pnpm run check:all`
|
||||
Run all server checks with `pnpm run check:all`
|
||||
:::
|
||||
|
||||
:::info Auto Fix
|
||||
:::tip Auto Fix
|
||||
You can use `pnpm run __:fix` to potentially correct some issues automatically for `pnpm run format` and `lint`.
|
||||
:::
|
||||
|
||||
## Mobile Checks
|
||||
## Mobile Checklist
|
||||
|
||||
The following commands must be executed from within the mobile app directory of the codebase.
|
||||
- [ ] `mise //mobile:codegen` (auto-generate files using build_runner)
|
||||
- [ ] `mise //mobile:lint` (static analysis via Dart Analyzer and DCM)
|
||||
- [ ] `mise //mobile:format` (formatting via Dart Formatter)
|
||||
- [ ] `mise //mobile:test` (unit tests)
|
||||
|
||||
- [ ] `make build` (auto-generate files using build_runner)
|
||||
- [ ] `make analyze` (static analysis via Dart Analyzer and DCM)
|
||||
- [ ] `make format` (formatting via Dart Formatter)
|
||||
- [ ] `make test` (unit tests)
|
||||
:::tip
|
||||
Run all these commands at once with `mise //mobile:checklist`
|
||||
:::
|
||||
|
||||
:::info Auto Fix
|
||||
You can use `dart fix --apply` and `dcm fix lib` to potentially correct some issues automatically for `make analyze`.
|
||||
:::tip Auto Fix
|
||||
You can use `mise //mobile:lint-fix` to potentially correct some issues automatically for `mise //mobile:lint`.
|
||||
:::
|
||||
|
||||
## OpenAPI
|
||||
|
||||
@@ -58,7 +58,7 @@ You can access the web from `http://your-machine-ip:3000` or `http://localhost:3
|
||||
|
||||
If you only want to do web development connected to an existing, remote backend, follow these steps:
|
||||
|
||||
1. Build the Immich SDK - `cd open-api/typescript-sdk && pnpm i && pnpm run build && cd -`
|
||||
1. Build the Immich SDK - `pnpm --filter @immich/sdk install && pnpm --filter @immich/sdk build`
|
||||
2. Enter the web directory - `cd web/`
|
||||
3. Install web dependencies - `pnpm i`
|
||||
4. Start the web development server
|
||||
@@ -80,9 +80,9 @@ To see local changes to `@immich/ui` in Immich, do the following:
|
||||
|
||||
1. Install `@immich/ui` as a sibling to `immich/`, for example `/home/user/immich` and `/home/user/ui`
|
||||
2. Build the `@immich/ui` project via `pnpm run build`
|
||||
3. Uncomment the corresponding volume in web service of the `docker/docker-compose.dev.yaml` file (`../../ui:/usr/ui`)
|
||||
4. Uncomment the corresponding alias in the `web/vite.config.js` file (`'@immich/ui': path.resolve(\_\_dirname, '../../ui')`)
|
||||
5. Uncomment the import statement in `web/src/app.css` file `@import '/usr/ui/dist/theme/default.css';` and comment out `@import '@immich/ui/theme/default.css';`
|
||||
3. Uncomment the corresponding volume in web service of the `docker/docker-compose.dev.yml` file (`../../ui:/usr/src/ui`)
|
||||
4. Uncomment the corresponding alias in the `web/vite.config.ts` file (`'@immich/ui': path.resolve(\_\_dirname, '../../ui/packages/ui')`)
|
||||
5. Uncomment the import statement in `web/src/app.css` file `@import '../../../ui/packages/ui/dist/theme/default.css';` and comment out `@import '@immich/ui/theme/default.css';`
|
||||
6. Start up the stack via `make dev`
|
||||
7. After making changes in `@immich/ui`, rebuild it (`pnpm run build`)
|
||||
|
||||
|
||||
@@ -17,15 +17,14 @@ make e2e
|
||||
|
||||
Before you can run the tests, you need to run the following commands _once_:
|
||||
|
||||
- `pnpm install` (in `e2e/`)
|
||||
- `pnpm run build` (in `cli/`)
|
||||
- `make open-api` (in the project root `/`)
|
||||
- `pnpm install`
|
||||
- `pnpm --filter @immich/sdk --filter @immich/cli build`
|
||||
- `mise //:open-api`
|
||||
|
||||
Once the test environment is running, the e2e tests can be run via:
|
||||
|
||||
```bash
|
||||
cd e2e/
|
||||
pnpm test
|
||||
mise //e2e:test
|
||||
```
|
||||
|
||||
The tests check various things including:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Duplicates Utility
|
||||
|
||||
Immich comes with a duplicates utility to help you detect assets that look visually similar. The duplicate detection feature relies on machine learning and is enabled by default. For more information about when the duplicate detection job runs, see [Jobs and Workers](/administration/jobs-workers). Once an asset has been processed and added to a duplicate group, it becomes available to review in the "Review duplicates" utility, which can be found [here](https://my.immich.app/utilities/duplicates).
|
||||
|
||||
## Reviewing duplicates
|
||||
|
||||
The review duplicates page allows the user to individually select which assets should be kept and which ones should be trashed. When more than one asset is kept, there is an option to automatically put the kept assets into a stack.
|
||||
|
||||
### Automatic preselection
|
||||
|
||||
When using "Deduplicate All" or viewing suggestions, Immich automatically preselects which assets to keep based on:
|
||||
|
||||
1. **Image size in bytes** — larger files are preferred as they typically have higher quality.
|
||||
2. **Count of EXIF data** — assets with more metadata are preferred.
|
||||
|
||||
### Synchronizing metadata
|
||||
|
||||
When resolving duplicates, metadata from trashed assets is automatically synchronized to the kept assets. The following metadata is synchronized:
|
||||
|
||||
| Name | Description |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Album | The kept assets will be added to _every_ album that the other assets in the group belong to. |
|
||||
| Favorite | If any of the assets in the group have been added to favorites, every kept asset will also be added to favorites. |
|
||||
| Rating | If one or more assets in the duplicate group have a rating, the highest rating is selected and synchronized to the kept assets. |
|
||||
| Description | Descriptions from each asset are combined together and synchronized to all the kept assets. |
|
||||
| Visibility | The most restrictive visibility is applied to the kept assets. |
|
||||
| Location | Latitude and longitude are copied if all assets with geolocation data in the group share the same coordinates. |
|
||||
| Tag | Tags from all assets in the group are merged and applied to every kept asset. |
|
||||
@@ -50,6 +50,8 @@ Some basic examples:
|
||||
- `**/Raw/**` will exclude all files in any directory named `Raw`
|
||||
- `**/*.{tif,jpg}` will exclude all files with the extension `.tif` or `.jpg`
|
||||
|
||||
Note that `*` is a wildcard matching zero or more characters (i.e., withinin a filename or single directory name). `**` matches zero or more subdirectories, recursively. It also includes any/all files within a subdirectory, i.e., when used at the end of a pattern. For example, `**/exclude_me/**` will exclude all files in any directory named `exclude_me`, as well as all files in any subdirectories of `exclude_me`, recursively.
|
||||
|
||||
Special characters such as @ should be escaped, for instance:
|
||||
|
||||
- `**/\@eaDir/**` will exclude all files in any directory named `@eaDir`
|
||||
|
||||
@@ -47,6 +47,7 @@ You do not need to redo any machine learning jobs after enabling hardware accele
|
||||
|
||||
#### ROCm
|
||||
|
||||
- On Linux, The [AMDGPU driver module](https://rocm.docs.amd.com/projects/install-on-linux/en/latest/how-to/docker.html) needs to be installed on the server and, if secure boot is used, the signing key of DKMS [needs to be enrolled in UEFI BIOS](https://wiki.debian.org/SecureBoot)
|
||||
- The GPU must be supported by ROCm. If it isn't officially supported, you can attempt to use the `HSA_OVERRIDE_GFX_VERSION` environmental variable: `HSA_OVERRIDE_GFX_VERSION=<a supported version, e.g. 10.3.0>`. If this doesn't work, you might need to also set `HSA_USE_SVM=0`.
|
||||
- The ROCm image is quite large and requires at least 35GiB of free disk space. However, pulling later updates to the service through Docker will generally only amount to a few hundred megabytes as the rest will be cached.
|
||||
- This backend is new and may experience some issues. For example, GPU power consumption can be higher than usual after running inference, even if the machine learning service is idle. In this case, it will only go back to normal after being idle for 5 minutes (configurable with the [MACHINE_LEARNING_MODEL_TTL](/install/environment-variables) setting).
|
||||
|
||||
@@ -18,6 +18,7 @@ You can search the following types of content:
|
||||
| People | Faces that are recognized in your photos/videos. |
|
||||
| Contextual | Content of the photos and videos. |
|
||||
| File name or extension | Full or partial file's name, or file's extension |
|
||||
| Full path or folder | Full or partial folder names from the original path. |
|
||||
| Description | Description added to assets. |
|
||||
| Optical Character Recognition (OCR) | Text in images |
|
||||
| Locations | Cities, states, and countries from reverse geocoding. |
|
||||
@@ -26,10 +27,16 @@ You can search the following types of content:
|
||||
| Time frame | Start and end date of a specific time bucket |
|
||||
| Media type | Image or video or both |
|
||||
| Display options | In Archive, in Favorites or Not in any album |
|
||||
| Start rating | User-assigned start rating |
|
||||
| Star rating | User-assigned star rating |
|
||||
|
||||
<img src={require('./img/advanced-search-filters.webp').default} width="70%" title='Advanced search filters' />
|
||||
|
||||
### Full path or folder
|
||||
|
||||
Use this mode when you know a folder name or part of the original asset path.
|
||||
|
||||
Example: for /John/Projects/3D_Printing/2026-07-01/IMG_0001.jpg, searches like Projects, 3D, Printing, or 2026 match the asset.
|
||||
|
||||
## Configuration
|
||||
|
||||
Navigating to `Administration > Settings > Machine Learning Settings > Smart Search` will show the options available.
|
||||
|
||||
@@ -18,6 +18,7 @@ For the full list, refer to the [Immich source code](https://github.com/immich-a
|
||||
| `JPEG 2000` | `.jp2` | :white_check_mark: | |
|
||||
| `JPEG` | `.jpeg` `.jpg` `.jpe` `.insp` | :white_check_mark: | |
|
||||
| `JPEG XL` | `.jxl` | :white_check_mark: | |
|
||||
| `MPO` | `.mpo` | :white_check_mark: | Multi-Picture |
|
||||
| `PNG` | `.png` | :white_check_mark: | |
|
||||
| `PSD` | `.psd` | :white_check_mark: | Adobe Photoshop |
|
||||
| `RAW` | `.raw` | :white_check_mark: | |
|
||||
@@ -28,17 +29,17 @@ For the full list, refer to the [Immich source code](https://github.com/immich-a
|
||||
|
||||
## Video formats
|
||||
|
||||
| Format | Extension(s) | Supported? | Notes |
|
||||
| :---------- | :-------------------- | :----------------: | :---- |
|
||||
| `3GPP` | `.3gp` `.3gpp` | :white_check_mark: | |
|
||||
| `AVI` | `.avi` | :white_check_mark: | |
|
||||
| `FLV` | `.flv` | :white_check_mark: | |
|
||||
| `M4V` | `.m4v` | :white_check_mark: | |
|
||||
| `MATROSKA` | `.mkv` | :white_check_mark: | |
|
||||
| `MP2T` | `.mts` `.m2ts` `.m2t` | :white_check_mark: | |
|
||||
| `MP4` | `.mp4` `.insv` | :white_check_mark: | |
|
||||
| `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | |
|
||||
| `MXF` | `.mxf` | :white_check_mark: | |
|
||||
| `QUICKTIME` | `.mov` | :white_check_mark: | |
|
||||
| `WEBM` | `.webm` | :white_check_mark: | |
|
||||
| `WMV` | `.wmv` | :white_check_mark: | |
|
||||
| Format | Extension(s) | Supported? | Notes |
|
||||
| :---------- | :-------------------------- | :----------------: | :---- |
|
||||
| `3GPP` | `.3gp` `.3gpp` | :white_check_mark: | |
|
||||
| `AVI` | `.avi` | :white_check_mark: | |
|
||||
| `FLV` | `.flv` | :white_check_mark: | |
|
||||
| `M4V` | `.m4v` | :white_check_mark: | |
|
||||
| `MATROSKA` | `.mkv` | :white_check_mark: | |
|
||||
| `MP2T` | `.mts` `.m2ts` `.m2t` `.ts` | :white_check_mark: | |
|
||||
| `MP4` | `.mp4` `.insv` | :white_check_mark: | |
|
||||
| `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | |
|
||||
| `MXF` | `.mxf` | :white_check_mark: | |
|
||||
| `QUICKTIME` | `.mov` | :white_check_mark: | |
|
||||
| `WEBM` | `.webm` | :white_check_mark: | |
|
||||
| `WMV` | `.wmv` | :white_check_mark: | |
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
You may decide that you'd like to modify the style document which is used to
|
||||
draw the maps in Immich. In addition to visual customization, this also allows
|
||||
you to pick your own map tile provider instead of the default one. The default
|
||||
`style.json` for [light theme](https://github.com/immich-app/immich/tree/main/server/resources/style-light.json)
|
||||
and [dark theme](https://github.com/immich-app/immich/blob/main/server/resources/style-dark.json)
|
||||
`style.json` for [light theme](https://tiles.immich.cloud/v1/style/light.json)
|
||||
and [dark theme](https://tiles.immich.cloud/v1/style/dark.json)
|
||||
can be used as a basis for creating your own style.
|
||||
|
||||
There are several sources for already-made `style.json` map themes, as well as
|
||||
|
||||
@@ -20,8 +20,6 @@ def upload(file):
|
||||
}
|
||||
|
||||
data = {
|
||||
'deviceAssetId': f'{file}-{stats.st_mtime}',
|
||||
'deviceId': 'python',
|
||||
'fileCreatedAt': datetime.fromtimestamp(stats.st_mtime),
|
||||
'fileModifiedAt': datetime.fromtimestamp(stats.st_mtime),
|
||||
'isFavorite': 'false',
|
||||
|
||||
@@ -39,7 +39,7 @@ You can learn how to set up Tailscale together with Immich with the [tutorial vi
|
||||
### Cons
|
||||
|
||||
- The Tailscale client usually needs to run as root on your devices and it increases the attack surface slightly compared to a minimal Wireguard server. e.g., an [RCE vulnerability](https://github.com/tailscale/tailscale/security/advisories/GHSA-vqp6-rc3h-83cp) was discovered in the Windows Tailscale client in November 2022.
|
||||
- Tailscale is a paid service. However, there is a generous [free tier](https://tailscale.com/pricing/) that permits up to 3 users and up to 100 devices.
|
||||
- Tailscale is a paid service. However, there is a generous [free tier](https://tailscale.com/pricing/) suitable for personal use.
|
||||
- Tailscale needs to be installed and running on both server-side and client-side.
|
||||
|
||||
## Option 3: Reverse Proxy
|
||||
|
||||
@@ -26,8 +26,8 @@ The default configuration looks like this:
|
||||
},
|
||||
"ffmpeg": {
|
||||
"accel": "disabled",
|
||||
"accelDecode": false,
|
||||
"acceptedAudioCodecs": ["aac", "mp3", "libopus"],
|
||||
"accelDecode": true,
|
||||
"acceptedAudioCodecs": ["aac", "mp3", "opus"],
|
||||
"acceptedContainers": ["mov", "ogg", "webm"],
|
||||
"acceptedVideoCodecs": ["h264"],
|
||||
"bframes": -1,
|
||||
@@ -193,6 +193,7 @@ The default configuration looks like this:
|
||||
"defaultStorageQuota": null,
|
||||
"enabled": false,
|
||||
"issuerUrl": "",
|
||||
"endSessionEndpoint": "",
|
||||
"mobileOverrideEnabled": false,
|
||||
"mobileRedirectUri": "",
|
||||
"profileSigningAlgorithm": "none",
|
||||
@@ -263,4 +264,4 @@ volumes:
|
||||
- ./configuration.yml:${IMMICH_CONFIG_FILE}
|
||||
```
|
||||
|
||||
::
|
||||
:::
|
||||
|
||||
@@ -29,28 +29,31 @@ These environment variables are used by the `docker-compose.yml` file and do **N
|
||||
|
||||
## General
|
||||
|
||||
| Variable | Description | Default | Containers | Workers |
|
||||
| :---------------------------------- | :---------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- |
|
||||
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
|
||||
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
|
||||
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
|
||||
| `IMMICH_LOG_FORMAT` | Log output format (`console`, `json`) | `console` | server | api, microservices |
|
||||
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `/data` | server | api, microservices |
|
||||
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
|
||||
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
|
||||
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
|
||||
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
|
||||
| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
|
||||
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
|
||||
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
|
||||
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
|
||||
| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api |
|
||||
| Variable | Description | Default | Containers | Workers |
|
||||
| :---------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- |
|
||||
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
|
||||
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
|
||||
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
|
||||
| `IMMICH_LOG_FORMAT` | Log output format (`console`, `json`) | `console` | server | api, microservices |
|
||||
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `/data` | server | api, microservices |
|
||||
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
|
||||
| `IMMICH_HELMET_FILE` | Path to a json file with [helmet](https://www.npmjs.com/package/helmet) options. Set to `false` to disable. Set to `true` to use `server/helmet.json`<sup>\*3</sup>. | `false` | server | api |
|
||||
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
|
||||
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
|
||||
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
|
||||
| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
|
||||
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
|
||||
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
|
||||
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
|
||||
| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api |
|
||||
|
||||
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
|
||||
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.
|
||||
|
||||
\*2: This path is where the Immich code looks for the files, which is internal to the docker container. Setting it to a path on your host will certainly break things, you should use the `UPLOAD_LOCATION` variable instead.
|
||||
|
||||
\*3: The [default configuration](https://helmetjs.github.io/#content-security-policy) sets `upgrade-insecure-requests`, which tells the browser to upgrade all requests to HTTPS. This breaks on HTTP-only deployments. If you cannot use HTTPS, you should use a custom helmet config file with `"upgrade-insecure-requests": null`.
|
||||
|
||||
## Workers
|
||||
|
||||
| Variable | Description | Default | Containers |
|
||||
@@ -80,7 +83,7 @@ Information on the current workers can be found [here](/administration/jobs-work
|
||||
| `DB_PASSWORD` | Database password | `postgres` | server, database<sup>\*1</sup> |
|
||||
| `DB_DATABASE_NAME` | Database name | `immich` | server, database<sup>\*1</sup> |
|
||||
| `DB_SSL_MODE` | Database SSL mode | | server |
|
||||
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server |
|
||||
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`]) | | server |
|
||||
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
|
||||
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | database |
|
||||
|
||||
@@ -166,6 +169,8 @@ Redis (Sentinel) URL example JSON before encoding:
|
||||
| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
|
||||
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
|
||||
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
|
||||
| `MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION` | Comma-separated list of (recognition) OCR model(s) to preload and cache | | machine learning |
|
||||
| `MACHINE_LEARNING_PRELOAD__OCR__DETECTION` | Comma-separated list of (detection) OCR model(s) to preload and cache | | machine learning |
|
||||
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
|
||||
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
|
||||
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
|
||||
|
||||
@@ -8,7 +8,7 @@ Hardware and software requirements for Immich:
|
||||
|
||||
## Hardware
|
||||
|
||||
- **OS**: Recommended Linux or \*nix operating system (Ubuntu, Debian, etc).
|
||||
- **OS**: Recommended Linux or \*nix 64-bit operating system (Ubuntu, Debian, etc).
|
||||
- Non-Linux OSes tend to provide a poor Docker experience and are strongly discouraged.
|
||||
Our ability to assist with setup or troubleshooting on non-Linux OSes will be severely reduced.
|
||||
If you still want to try to use a non-Linux OS, you can set it up as follows:
|
||||
@@ -19,6 +19,10 @@ Hardware and software requirements for Immich:
|
||||
If you have issues, we recommend that you switch to a supported VM deployment.
|
||||
- **RAM**: Minimum 6GB, recommended 8GB.
|
||||
- **CPU**: Minimum 2 cores, recommended 4 cores.
|
||||
- Immich runs on the `amd64` and `arm64` platforms.
|
||||
Since `v2.6`, the machine learning container on `amd64` requires the `>= x86-64-v2` [microarchitecture level](https://en.wikipedia.org/wiki/X86-64#Microarchitecture_levels).
|
||||
Most CPUs released since ~2012 support this microarchitecture.
|
||||
If you are using a virtual machine, ensure you have selected a [supported microarchitecture](https://pve.proxmox.com/pve-docs/chapter-qm.html#_qemu_cpu_types).
|
||||
- **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions.
|
||||
- The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average.
|
||||
|
||||
@@ -45,7 +49,7 @@ Immich requires [**Docker**](https://docs.docker.com/get-started/get-docker/) wi
|
||||
The Compose plugin will be installed by both Docker Engine and Desktop by following the linked installation guides; it can also be [separately installed](https://docs.docker.com/compose/install/).
|
||||
|
||||
:::note
|
||||
Immich requires the command `docker compose`; the similarly named `docker-compose` is [deprecated](https://docs.docker.com/compose/migrate/) and is no longer supported by Immich.
|
||||
Immich requires the command `docker compose`; the similarly named `docker-compose` is [deprecated](https://docs.docker.com/retired/#docker-compose-v1-replaced-by-compose-v2) and is no longer supported by Immich.
|
||||
:::
|
||||
|
||||
### Special requirements for Windows users
|
||||
|
||||
@@ -52,7 +52,7 @@ Scroll to the bottom of the "**Details**" section and find the `IP Address` list
|
||||
|
||||
## Step 4 - Configure Firewall Settings
|
||||
|
||||
Once your project completes the build process, your containers will start. In order to be able to access Immich from your browser, you need to configure the firewall settings for your Synology NAS.
|
||||
Once your project completes the build process, your containers will start. In order to be able to access Immich from your browser, you need to configure the firewall settings for your Synology NAS to allow communication between the Immich containers.
|
||||
|
||||
Open "**Control Panel**" on your Synology NAS, and select "**Security**". Navigate to "**Firewall**"
|
||||
|
||||
@@ -74,6 +74,7 @@ Read the [Post Installation](/install/post-install.mdx) steps and [upgrade instr
|
||||
|
||||
<details>
|
||||
<summary>Updating Immich using Container Manager</summary>
|
||||
|
||||
Check the post installation and upgrade instructions at the links above before proceeding with this section.
|
||||
|
||||
## Step 1. Backup
|
||||
@@ -110,7 +111,7 @@ Go to **Project**, select **Action** then **Build**. This will download, unpack,
|
||||
|
||||
## Step 5. Update firewall rule
|
||||
|
||||
The default behavior is to automatically start the containers once installed. If `immich_server` runs for a few seconds and then stops, it may be because the firewall rule no longer matches the server IP address.
|
||||
Without a fixed subnet, the default behavior is to automatically start the containers once installed. If `immich_server` runs for a few seconds and then stops, it may be because the firewall rule no longer matches the server IP address.
|
||||
|
||||
Go to the **Container** section. Click on `immich_server` and scroll down on **General** to find the IP address.
|
||||

|
||||
@@ -123,4 +124,67 @@ In this example, the IP addresses mismatch and the firewall rule needs to be edi
|
||||
|
||||

|
||||
|
||||
To prevent future firewall issues, you may set a fixed subnet. [See Set Fixed Subnet](#set-fixed-subnet) for instructions.
|
||||
|
||||
</details>
|
||||
|
||||
<details id="set-fixed-subnet">
|
||||
<summary>Set Fixed Subnet</summary>
|
||||
|
||||
Docker by default assigns dynamic subnets to bridge networks which can change when rebuilding containers and can cause firewall rules to break. To avoid this, define a fixed subnet in your `docker-compose.yml`:
|
||||
|
||||
## Step 1. Determine current subnet
|
||||
|
||||
Go to the **Container** section. Click on `immich_server` and scroll down on **General** to find the IP address.
|
||||

|
||||
|
||||
## Step 2. Add network configuration
|
||||
|
||||
Add the following network configuration at the end of your `docker-compose.yml` file:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
immich-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
gateway: 172.20.0.1
|
||||
```
|
||||
|
||||
If your docker container is running on a different subnet then update accordingly.
|
||||
|
||||
## Step 3. Add network to each service
|
||||
|
||||
Add the network to each service (immich-server, immich-machine-learning, redis, database):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
immich-server:
|
||||
# other config options
|
||||
networks:
|
||||
- immich-network
|
||||
|
||||
immich-machine-learning:
|
||||
# other config options
|
||||
networks:
|
||||
- immich-network
|
||||
|
||||
redis:
|
||||
# other config options
|
||||
networks:
|
||||
- immich-network
|
||||
|
||||
database:
|
||||
# other config options
|
||||
networks:
|
||||
- immich-network
|
||||
```
|
||||
|
||||
Save your changes. Synology will ask if you want to save changes only or rebuild containers. Select rebuild containers.
|
||||
|
||||
## Step 4. Update Firewall Rules, if necessary
|
||||
|
||||
If your firewall rules were not already set for this subnet, the firewall rules will need to be updated. See [Step 4 - Configure Firewall Settings](#step-4---configure-firewall-settings).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -130,7 +130,3 @@ These storage mediums have different performance characteristics. As a result, t
|
||||
#### Can I use the new database image as a general PostgreSQL image outside of Immich?
|
||||
|
||||
It’s a standard PostgreSQL container image that additionally contains the VectorChord, pgvector, and (optionally) pgvecto.rs extensions. If you were using the previous pgvecto.rs image for other purposes, you can similarly do so with this image.
|
||||
|
||||
#### If pgvecto.rs and pgvector still work, why should I switch to VectorChord?
|
||||
|
||||
VectorChord is faster, more stable, uses less RAM, and (with the settings Immich uses) offers higher-quality results than pgvector and pgvecto.rs. This translates to better search and facial recognition experiences. In addition, pgvecto.rs support will be dropped in the future, so changing it sooner will avoid disruption.
|
||||
|
||||
@@ -6,6 +6,8 @@ You can read more about the differences between storage template engine on and o
|
||||
|
||||
The admin user can set the template by using the template builder in the `Administration -> Settings -> Storage Template`. Immich provides a set of variables that you can use in constructing the template, along with additional custom text. If the template produces [multiple files with the same filename, they won't be overwritten](https://github.com/immich-app/immich/discussions/3324) as a sequence number is appended to the filename.
|
||||
|
||||
Date and time variables in storage templates are rendered in the server's local timezone.
|
||||
|
||||
```bash title="Default template"
|
||||
Year/Year-Month-Day/Filename.Extension
|
||||
```
|
||||
|
||||
+12
-15
@@ -4,8 +4,8 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"format": "prettier --cache --check .",
|
||||
"format:fix": "prettier --cache --write --list-different .",
|
||||
"start": "docusaurus start --port 3005",
|
||||
"copy:openapi": "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
|
||||
"build": "pnpm run copy:openapi && docusaurus build",
|
||||
@@ -17,10 +17,10 @@
|
||||
"write-heading-ids": "docusaurus write-heading-ids"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "~3.9.0",
|
||||
"@docusaurus/preset-classic": "~3.9.0",
|
||||
"@docusaurus/theme-common": "~3.9.0",
|
||||
"@docusaurus/theme-mermaid": "~3.9.0",
|
||||
"@docusaurus/core": "~3.10.0",
|
||||
"@docusaurus/preset-classic": "~3.10.0",
|
||||
"@docusaurus/theme-common": "~3.10.0",
|
||||
"@docusaurus/theme-mermaid": "~3.10.0",
|
||||
"@mdi/js": "^7.3.67",
|
||||
"@mdi/react": "^1.6.1",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
@@ -30,17 +30,17 @@
|
||||
"postcss": "^8.4.25",
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwindcss": "^3.2.4",
|
||||
"url": "^0.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "~3.9.0",
|
||||
"@docusaurus/tsconfig": "^3.7.0",
|
||||
"@docusaurus/types": "^3.7.0",
|
||||
"@docusaurus/module-type-aliases": "~3.10.0",
|
||||
"@docusaurus/tsconfig": "^3.10.0",
|
||||
"@docusaurus/types": "^3.10.0",
|
||||
"prettier": "^3.7.4",
|
||||
"typescript": "^5.1.6"
|
||||
"typescript": "^6.0.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@@ -56,8 +56,5 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.13.1"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+8
@@ -1,4 +1,12 @@
|
||||
[
|
||||
{
|
||||
"label": "v2.7.5",
|
||||
"url": "https://docs.v2.7.5.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.6.3",
|
||||
"url": "https://docs.v2.6.3.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.5.6",
|
||||
"url": "https://docs.v2.5.6.archive.immich.app"
|
||||
|
||||
+1
-5
@@ -1,8 +1,4 @@
|
||||
{
|
||||
// This file is not used in compilation. It is here just for a nice editor experience.
|
||||
"extends": "@docusaurus/tsconfig",
|
||||
|
||||
"compilerOptions": {
|
||||
"baseUrl": "."
|
||||
}
|
||||
"extends": "@docusaurus/tsconfig"
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
24.13.1
|
||||
@@ -4,7 +4,7 @@ services:
|
||||
e2e-auth-server:
|
||||
container_name: immich-e2e-auth-server
|
||||
build:
|
||||
context: ../e2e-auth-server
|
||||
context: ../packages/e2e-auth-server
|
||||
ports:
|
||||
- 2286:2286
|
||||
|
||||
@@ -44,7 +44,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich-e2e-redis
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
|
||||
@@ -27,3 +27,18 @@ run = { task = "lint --fix" }
|
||||
[tasks.check]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "tsc --noEmit"
|
||||
|
||||
|
||||
[tasks.ci-setup]
|
||||
depends = ["//:sdk:install", "//:sdk:build", "//cli:install", "//cli:build"]
|
||||
run = { task = ":install" }
|
||||
|
||||
|
||||
[tasks.ci-unit]
|
||||
depends = ["//:sdk:install", "//:sdk:build"]
|
||||
run = [
|
||||
{ task = ":install" },
|
||||
{ task = ":format" },
|
||||
{ task = ":lint" },
|
||||
{ task = ":check" },
|
||||
]
|
||||
|
||||
+11
-17
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "2.5.6",
|
||||
"version": "2.7.5",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
@@ -14,14 +14,11 @@
|
||||
"start:web": "pnpm exec playwright test --ui --project=web",
|
||||
"start:web:maintenance": "pnpm exec playwright test --ui --project=maintenance",
|
||||
"start:web:ui": "pnpm exec playwright test --ui --project=ui",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"format": "prettier --cache --check .",
|
||||
"format:fix": "prettier --cache --write --list-different .",
|
||||
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
||||
"lint:fix": "pnpm run lint --fix",
|
||||
"check": "tsc --noEmit",
|
||||
"screenshots": "pnpm exec playwright test --config playwright.screenshot.config.ts",
|
||||
"screenshots:compare": "pnpm exec tsx src/screenshots/compare.ts",
|
||||
"screenshots:analyze": "pnpm exec tsx src/screenshots/analyze-deps.ts"
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -30,20 +27,20 @@
|
||||
"@eslint/js": "^10.0.0",
|
||||
"@faker-js/faker": "^10.1.0",
|
||||
"@immich/cli": "workspace:*",
|
||||
"@immich/e2e-auth-server": "workspace:*",
|
||||
"@immich/e2e-auth-server": "workspace:*",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@socket.io/component-emitter": "^3.1.2",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/pg": "^8.15.1",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^63.0.0",
|
||||
"eslint-plugin-unicorn": "^64.0.0",
|
||||
"exiftool-vendored": "^35.0.0",
|
||||
"globals": "^17.0.0",
|
||||
"luxon": "^3.4.4",
|
||||
@@ -54,13 +51,10 @@
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io-client": "^4.7.4",
|
||||
"supertest": "^7.0.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript": "^6.0.0",
|
||||
"typescript-eslint": "^8.28.0",
|
||||
"utimes": "^5.2.1",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.13.1"
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"vitest": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const baseUrl = process.env.SCREENSHOT_BASE_URL ?? 'http://127.0.0.1:4173';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './src/screenshots',
|
||||
testMatch: /run-scenarios\.ts/,
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: baseUrl,
|
||||
screenshot: 'off',
|
||||
trace: 'off',
|
||||
},
|
||||
workers: 1,
|
||||
projects: [
|
||||
{
|
||||
name: 'screenshots',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,651 @@
|
||||
import { LoginResponseDto } from '@immich/sdk';
|
||||
import { createUserDto, uuidDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/duplicates', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let user1: LoginResponseDto;
|
||||
let user2: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
|
||||
admin = await utils.adminSetup();
|
||||
|
||||
[user1, user2] = await Promise.all([
|
||||
utils.userSetup(admin.accessToken, createUserDto.user1),
|
||||
utils.userSetup(admin.accessToken, createUserDto.user2),
|
||||
]);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset assets, albums, tags, and stacks between tests to ensure clean state for repeated test runs
|
||||
// Note: We don't reset users since they're set up once in beforeAll
|
||||
// Stack must be reset before asset due to foreign key constraint
|
||||
await utils.resetDatabase(['stack', 'asset', 'album', 'tag']);
|
||||
});
|
||||
|
||||
describe('GET /duplicates', () => {
|
||||
it('should return empty array when no duplicates', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/duplicates')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return duplicate groups with suggestedKeepAssetIds', async () => {
|
||||
// Create assets with different file sizes for duplicate detection
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Manually set duplicateId on both assets to create a duplicate group
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000001';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/duplicates')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([
|
||||
{
|
||||
duplicateId,
|
||||
assets: expect.arrayContaining([
|
||||
expect.objectContaining({ id: asset1.id }),
|
||||
expect.objectContaining({ id: asset2.id }),
|
||||
]),
|
||||
suggestedKeepAssetIds: expect.any(Array),
|
||||
},
|
||||
]);
|
||||
expect(body[0].suggestedKeepAssetIds.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /duplicates/resolve', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.send({
|
||||
groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should return failure for non-existent duplicate group', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
status: 'COMPLETED',
|
||||
results: [
|
||||
{
|
||||
duplicateId: uuidDto.dummy,
|
||||
status: 'FAILED',
|
||||
reason: expect.stringContaining('not found or access denied'),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve duplicate group with keepers', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000002';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
status: 'COMPLETED',
|
||||
results: [
|
||||
{
|
||||
duplicateId,
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Verify side effects: duplicateId cleared on kept asset
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
|
||||
// Verify side effects: trashed asset is trashed and duplicateId cleared
|
||||
const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id);
|
||||
expect(trashedAsset.isTrashed).toBe(true);
|
||||
expect(trashedAsset.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject when keepAssetIds and trashAssetIds overlap', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000003';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset1.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('FAILED');
|
||||
expect(body.results[0].reason).toContain('disjoint');
|
||||
});
|
||||
|
||||
it('should require keepAssetIds when partially trashing', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000004';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('FAILED');
|
||||
expect(body.results[0].reason).toContain('must cover all assets');
|
||||
});
|
||||
|
||||
it('should reject partial resolution (not all assets covered)', async () => {
|
||||
const [asset1, asset2, asset3] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000010';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset3.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('FAILED');
|
||||
expect(body.results[0].reason).toContain('must cover all assets');
|
||||
});
|
||||
|
||||
it('should reject asset not in duplicate group', async () => {
|
||||
const [asset1, asset2, outsideAsset] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000011';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [outsideAsset.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('FAILED');
|
||||
expect(body.results[0].reason).toContain('not a member of duplicate group');
|
||||
});
|
||||
|
||||
it('should allow trash-all without keepers', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000012';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id, asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
status: 'COMPLETED',
|
||||
results: [
|
||||
{
|
||||
duplicateId,
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Verify both assets are trashed
|
||||
const [asset1Info, asset2Info] = await Promise.all([
|
||||
utils.getAssetInfo(user1.accessToken, asset1.id),
|
||||
utils.getAssetInfo(user1.accessToken, asset2.id),
|
||||
]);
|
||||
|
||||
expect(asset1Info.isTrashed).toBe(true);
|
||||
expect(asset1Info.duplicateId).toBeNull();
|
||||
expect(asset2Info.isTrashed).toBe(true);
|
||||
expect(asset2Info.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject cross-user duplicate group access', async () => {
|
||||
const asset1 = await utils.createAsset(user1.accessToken);
|
||||
const asset2 = await utils.createAsset(user2.accessToken);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000013';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user2.accessToken, asset2.id, duplicateId);
|
||||
|
||||
// User1 tries to resolve a group containing user2's asset
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('FAILED');
|
||||
expect(body.results[0].reason).toContain('not a member of duplicate group');
|
||||
});
|
||||
|
||||
it('should synchronize favorites when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Mark one asset as favorite
|
||||
await request(app)
|
||||
.put('/assets')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ ids: [asset2.id], isFavorite: true });
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000020';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify favorite was synchronized to keeper
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.isFavorite).toBe(true);
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should synchronize visibility when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Archive one asset
|
||||
await utils.archiveAssets(user1.accessToken, [asset2.id]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000021';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify visibility was synchronized to keeper
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.visibility).toBe('archive');
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should synchronize rating when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Set rating on one asset
|
||||
await request(app)
|
||||
.put('/assets')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ ids: [asset2.id], rating: 5 });
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000022';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify rating was synchronized to keeper
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.exifInfo?.rating).toBe(5);
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should synchronize description when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Set description on one asset
|
||||
await request(app)
|
||||
.put('/assets')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ ids: [asset2.id], description: 'Test description for duplicate' });
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000023';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify description was synchronized to keeper
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.exifInfo?.description).toBe('Test description for duplicate');
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should synchronize location when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Set location on one asset
|
||||
await request(app)
|
||||
.put('/assets')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ ids: [asset2.id], latitude: 40.7128, longitude: -74.006 });
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000024';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify location was synchronized to keeper
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.exifInfo?.latitude).toBe(40.7128);
|
||||
expect(keptAsset.exifInfo?.longitude).toBe(-74.006);
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should synchronize albums when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Create albums and add assets to different albums
|
||||
const album1 = await utils.createAlbum(user1.accessToken, {
|
||||
albumName: 'Album 1',
|
||||
assetIds: [asset1.id],
|
||||
});
|
||||
const album2 = await utils.createAlbum(user1.accessToken, {
|
||||
albumName: 'Album 2',
|
||||
assetIds: [asset2.id],
|
||||
});
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000025';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify keeper is now in both albums
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
|
||||
// Check albums directly
|
||||
const { status: album1Status, body: album1Body } = await request(app)
|
||||
.get(`/albums/${album1.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
const { status: album2Status, body: album2Body } = await request(app)
|
||||
.get(`/albums/${album2.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(album1Status).toBe(200);
|
||||
expect(album2Status).toBe(200);
|
||||
expect(album1Body.assets.map((a: any) => a.id)).toContain(asset1.id);
|
||||
expect(album2Body.assets.map((a: any) => a.id)).toContain(asset1.id);
|
||||
});
|
||||
|
||||
it('should synchronize tags when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Wait for metadata extraction to complete before adding tags
|
||||
// Otherwise, metadata jobs will race and overwrite our tags
|
||||
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
|
||||
|
||||
// Create tags and tag assets differently
|
||||
const tags = await utils.upsertTags(user1.accessToken, ['tag1', 'tag2']);
|
||||
await utils.tagAssets(user1.accessToken, tags[0].id, [asset1.id]);
|
||||
await utils.tagAssets(user1.accessToken, tags[1].id, [asset2.id]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000026';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify keeper has both tags
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
expect(keptAsset.tags).toBeDefined();
|
||||
const tagIds = keptAsset.tags?.map((t) => t.id) || [];
|
||||
expect(tagIds).toContain(tags[0].id);
|
||||
expect(tagIds).toContain(tags[1].id);
|
||||
});
|
||||
|
||||
it('should handle batch resolve with mixed success and failure', async () => {
|
||||
// Create first group that will succeed
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
const duplicateId1 = '00000000-0000-4000-8000-000000000027';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId1);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId1);
|
||||
|
||||
// Create second group with non-existent duplicate ID (will fail)
|
||||
const fakeId = '00000000-0000-4000-8000-000000000099';
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [
|
||||
{ duplicateId: duplicateId1, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] },
|
||||
{ duplicateId: fakeId, keepAssetIds: [], trashAssetIds: [] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.status).toBe('COMPLETED');
|
||||
expect(body.results).toHaveLength(2);
|
||||
|
||||
// First group should succeed
|
||||
expect(body.results[0].duplicateId).toBe(duplicateId1);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Second group should fail
|
||||
expect(body.results[1].duplicateId).toBe(fakeId);
|
||||
expect(body.results[1].status).toBe('FAILED');
|
||||
expect(body.results[1].reason).toContain('not found or access denied');
|
||||
|
||||
// Verify first group was actually resolved despite second failure
|
||||
const asset1Info = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(asset1Info.duplicateId).toBeNull();
|
||||
const asset2Info = await utils.getAssetInfo(user1.accessToken, asset2.id);
|
||||
expect(asset2Info.isTrashed).toBe(true);
|
||||
});
|
||||
|
||||
it('should trash assets when trash is enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000028';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
// Ensure trash is enabled (default)
|
||||
const config = await utils.getSystemConfig(admin.accessToken);
|
||||
expect(config.trash.enabled).toBe(true);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify asset is trashed (not deleted)
|
||||
const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id);
|
||||
expect(trashedAsset.isTrashed).toBe(true);
|
||||
});
|
||||
|
||||
it('should delete assets when trash is disabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000029';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
// Disable trash
|
||||
await request(app)
|
||||
.put('/system-config')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
trash: { enabled: false, days: 30 },
|
||||
});
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Asset should be marked as deleted (force delete)
|
||||
const { status: getStatus } = await request(app)
|
||||
.get(`/assets/${asset2.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
// Asset should still be accessible (soft deleted) but marked as deleted
|
||||
expect(getStatus).toBe(200);
|
||||
|
||||
// Re-enable trash for other tests
|
||||
await utils.resetAdminConfig(admin.accessToken);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@ export const uuidDto = {
|
||||
invalid: 'invalid-uuid',
|
||||
// valid uuid v4
|
||||
notFound: '00000000-0000-4000-a000-000000000000',
|
||||
dummy: '00000000-0000-4000-a000-000000000001',
|
||||
dummy2: '00000000-0000-4000-a000-000000000002',
|
||||
};
|
||||
|
||||
const adminLoginDto = {
|
||||
|
||||
+4
-42
@@ -2,82 +2,44 @@ import { expect } from 'vitest';
|
||||
|
||||
export const errorDto = {
|
||||
unauthorized: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Authentication required',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
unauthorizedWithMessage: (message: string) => ({
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message,
|
||||
correlationId: expect.any(String),
|
||||
}),
|
||||
forbidden: {
|
||||
error: 'Forbidden',
|
||||
statusCode: 403,
|
||||
message: expect.any(String),
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
missingPermission: (permission: string) => ({
|
||||
error: 'Forbidden',
|
||||
statusCode: 403,
|
||||
message: `Missing required permission: ${permission}`,
|
||||
correlationId: expect.any(String),
|
||||
}),
|
||||
wrongPassword: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: 'Wrong password',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
invalidToken: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Invalid user token',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
invalidShareKey: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Invalid share key',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
passwordRequired: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Password required',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
badRequest: (message: any = null) => ({
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: message ?? expect.anything(),
|
||||
correlationId: expect.any(String),
|
||||
}),
|
||||
validationError: (errors?: ReadonlyArray<{ path: ReadonlyArray<string | number>; message: string }>) => ({
|
||||
message: 'Validation failed',
|
||||
errors: errors ? expect.arrayContaining(errors.map((e) => expect.objectContaining(e))) : expect.any(Array),
|
||||
}),
|
||||
noPermission: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: expect.stringContaining('Not found or no'),
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
incorrectLogin: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Incorrect email or password',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
alreadyHasAdmin: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: 'The server already has an admin',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
invalidEmail: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: ['email must be an email'],
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
/**
|
||||
* Reverse dependency analyzer for the Immich web app.
|
||||
*
|
||||
* Given a list of changed files, traces upward through the import graph
|
||||
* to find which +page.svelte routes are affected, then maps those to URL paths.
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
|
||||
const WEB_SRC = resolve(import.meta.dirname, '../../../web/src');
|
||||
const LIB_ALIAS = resolve(WEB_SRC, 'lib');
|
||||
|
||||
/** Collect all .svelte, .ts, .js files under web/src/ */
|
||||
function collectFiles(dir: string): string[] {
|
||||
const results: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const stat = statSync(full);
|
||||
if (stat.isDirectory()) {
|
||||
if (entry === 'node_modules' || entry === '.svelte-kit') {
|
||||
continue;
|
||||
}
|
||||
results.push(...collectFiles(full));
|
||||
} else if (/\.(svelte|ts|js)$/.test(entry)) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Extract import specifiers from a file's source text. */
|
||||
function extractImports(source: string): string[] {
|
||||
const specifiers: string[] = [];
|
||||
|
||||
// Match: import ... from '...' / import '...' / export ... from '...'
|
||||
const importRegex = /(?:import|export)\s+(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"]/g;
|
||||
let match;
|
||||
while ((match = importRegex.exec(source)) !== null) {
|
||||
specifiers.push(match[1]);
|
||||
}
|
||||
|
||||
// Match dynamic imports: import('...')
|
||||
const dynamicRegex = /import\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||
while ((match = dynamicRegex.exec(source)) !== null) {
|
||||
specifiers.push(match[1]);
|
||||
}
|
||||
|
||||
return specifiers;
|
||||
}
|
||||
|
||||
/** Resolve an import specifier to an absolute file path (or null if external). */
|
||||
function resolveImport(specifier: string, fromFile: string, allFiles: Set<string>): string | null {
|
||||
// Handle $lib alias
|
||||
let resolved: string;
|
||||
if (specifier.startsWith('$lib/') || specifier === '$lib') {
|
||||
resolved = specifier.replace('$lib', LIB_ALIAS);
|
||||
} else if (specifier.startsWith('./') || specifier.startsWith('../')) {
|
||||
resolved = resolve(dirname(fromFile), specifier);
|
||||
} else {
|
||||
// External package import — not relevant
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try exact match, then common extensions
|
||||
const extensions = ['', '.ts', '.js', '.svelte', '/index.ts', '/index.js', '/index.svelte'];
|
||||
for (const ext of extensions) {
|
||||
const candidate = resolved + ext;
|
||||
if (allFiles.has(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Build the forward dependency graph: file → set of files it imports. */
|
||||
function buildDependencyGraph(files: string[]): Map<string, Set<string>> {
|
||||
const fileSet = new Set(files);
|
||||
const graph = new Map<string, Set<string>>();
|
||||
|
||||
for (const file of files) {
|
||||
const deps = new Set<string>();
|
||||
graph.set(file, deps);
|
||||
|
||||
try {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
for (const specifier of extractImports(source)) {
|
||||
const resolved = resolveImport(specifier, file, fileSet);
|
||||
if (resolved) {
|
||||
deps.add(resolved);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip files that can't be read
|
||||
}
|
||||
}
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
/** Invert the dependency graph: file → set of files that import it. */
|
||||
function buildReverseDependencyGraph(forwardGraph: Map<string, Set<string>>): Map<string, Set<string>> {
|
||||
const reverse = new Map<string, Set<string>>();
|
||||
|
||||
for (const [file, deps] of forwardGraph) {
|
||||
for (const dep of deps) {
|
||||
let importers = reverse.get(dep);
|
||||
if (!importers) {
|
||||
importers = new Set();
|
||||
reverse.set(dep, importers);
|
||||
}
|
||||
importers.add(file);
|
||||
}
|
||||
}
|
||||
|
||||
return reverse;
|
||||
}
|
||||
|
||||
/** BFS from changed files upward through reverse deps to find +page.svelte files. */
|
||||
function findAffectedPages(changedFiles: string[], reverseGraph: Map<string, Set<string>>): Set<string> {
|
||||
const visited = new Set<string>();
|
||||
const pages = new Set<string>();
|
||||
const queue = [...changedFiles];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const file = queue.shift()!;
|
||||
if (visited.has(file)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(file);
|
||||
|
||||
if (file.endsWith('+page.svelte') || file.endsWith('+layout.svelte')) {
|
||||
pages.add(file);
|
||||
// If it's a layout, keep tracing upward because the layout itself
|
||||
// isn't a page — but the pages under it are affected.
|
||||
// If it's a +page.svelte, we still want to continue in case
|
||||
// this page is imported by others.
|
||||
}
|
||||
|
||||
const importers = reverseGraph.get(file);
|
||||
if (importers) {
|
||||
for (const importer of importers) {
|
||||
if (!visited.has(importer)) {
|
||||
queue.push(importer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For +layout.svelte hits, also find all +page.svelte under the same directory tree
|
||||
const layoutDirs: string[] = [];
|
||||
for (const page of pages) {
|
||||
if (page.endsWith('+layout.svelte')) {
|
||||
layoutDirs.push(dirname(page));
|
||||
pages.delete(page);
|
||||
}
|
||||
}
|
||||
|
||||
if (layoutDirs.length > 0) {
|
||||
for (const file of reverseGraph.keys()) {
|
||||
if (file.endsWith('+page.svelte')) {
|
||||
for (const layoutDir of layoutDirs) {
|
||||
if (file.startsWith(layoutDir)) {
|
||||
pages.add(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also check the forward graph keys for page files under layout dirs
|
||||
for (const layoutDir of layoutDirs) {
|
||||
const allFiles = collectFiles(layoutDir);
|
||||
for (const f of allFiles) {
|
||||
if (f.endsWith('+page.svelte')) {
|
||||
pages.add(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
/** Convert a +page.svelte file path to its URL route. */
|
||||
export function pageFileToRoute(pageFile: string): string {
|
||||
const routesDir = resolve(WEB_SRC, 'routes');
|
||||
let rel = relative(routesDir, dirname(pageFile));
|
||||
|
||||
// Remove SvelteKit group markers: (user), (list), etc.
|
||||
rel = rel.replaceAll(/\([^)]+\)\/?/g, '');
|
||||
|
||||
// Remove parameter segments: [albumId=id], [[photos=photos]], [[assetId=id]]
|
||||
rel = rel.replaceAll(/\[\[?[^\]]+\]\]?\/?/g, '');
|
||||
|
||||
// Clean up trailing slashes and normalize
|
||||
rel = rel.replaceAll(/\/+/g, '/').replace(/\/$/, '');
|
||||
|
||||
return '/' + rel;
|
||||
}
|
||||
|
||||
export interface AnalysisResult {
|
||||
affectedPages: string[];
|
||||
affectedRoutes: string[];
|
||||
}
|
||||
|
||||
/** Main entry: analyze which routes are affected by the given changed files. */
|
||||
export function analyzeAffectedRoutes(changedFiles: string[]): AnalysisResult {
|
||||
// Resolve changed files to absolute paths relative to web/src
|
||||
const webRoot = resolve(WEB_SRC, '..');
|
||||
const resolvedChanged = changedFiles
|
||||
.filter((f) => f.startsWith('web/'))
|
||||
.map((f) => resolve(webRoot, '..', f))
|
||||
.filter((f) => statSync(f, { throwIfNoEntry: false })?.isFile());
|
||||
|
||||
if (resolvedChanged.length === 0) {
|
||||
return { affectedPages: [], affectedRoutes: [] };
|
||||
}
|
||||
|
||||
const allFiles = collectFiles(WEB_SRC);
|
||||
const forwardGraph = buildDependencyGraph(allFiles);
|
||||
const reverseGraph = buildReverseDependencyGraph(forwardGraph);
|
||||
|
||||
const pages = findAffectedPages(resolvedChanged, reverseGraph);
|
||||
|
||||
const affectedPages = [...pages].toSorted();
|
||||
const affectedRoutes = [...new Set(affectedPages.map((f) => pageFileToRoute(f)))].toSorted();
|
||||
|
||||
return { affectedPages, affectedRoutes };
|
||||
}
|
||||
|
||||
// CLI usage: node --import tsx analyze-deps.ts file1 file2 ...
|
||||
if (process.argv[1]?.endsWith('analyze-deps.ts') || process.argv[1]?.endsWith('analyze-deps.js')) {
|
||||
const files = process.argv.slice(2);
|
||||
if (files.length === 0) {
|
||||
console.log('Usage: analyze-deps.ts <changed-file1> <changed-file2> ...');
|
||||
console.log('Files should be relative to the repo root (e.g. web/src/lib/components/Button.svelte)');
|
||||
throw new Error('No files provided');
|
||||
}
|
||||
|
||||
const result = analyzeAffectedRoutes(files);
|
||||
console.log('Affected pages:');
|
||||
for (const page of result.affectedPages) {
|
||||
console.log(` ${page}`);
|
||||
}
|
||||
console.log('\nAffected routes:');
|
||||
for (const route of result.affectedRoutes) {
|
||||
console.log(` ${route}`);
|
||||
}
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
/**
|
||||
* Pixel-level comparison of base vs PR screenshots.
|
||||
*
|
||||
* Uses pixelmatch to generate diff images and calculate change percentages.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx e2e/src/screenshots/compare.ts <base-dir> <pr-dir> <output-dir>
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
import { PNG } from 'pngjs';
|
||||
|
||||
// pixelmatch is a lightweight dependency — use a simple inline implementation
|
||||
// based on the approach from the pixelmatch library to avoid adding a new dependency.
|
||||
// The e2e package already has pngjs.
|
||||
|
||||
function pixelMatch(img1Data: Uint8Array, img2Data: Uint8Array, diffData: Uint8Array): number {
|
||||
let diffCount = 0;
|
||||
|
||||
for (let i = 0; i < img1Data.length; i += 4) {
|
||||
const r1 = img1Data[i];
|
||||
const g1 = img1Data[i + 1];
|
||||
const b1 = img1Data[i + 2];
|
||||
|
||||
const r2 = img2Data[i];
|
||||
const g2 = img2Data[i + 1];
|
||||
const b2 = img2Data[i + 2];
|
||||
|
||||
const dr = Math.abs(r1 - r2);
|
||||
const dg = Math.abs(g1 - g2);
|
||||
const db = Math.abs(b1 - b2);
|
||||
|
||||
// Threshold: if any channel differs by more than 25, mark as different
|
||||
const isDiff = dr > 25 || dg > 25 || db > 25;
|
||||
|
||||
if (isDiff) {
|
||||
// Red highlight for diff pixels
|
||||
diffData[i] = 255;
|
||||
diffData[i + 1] = 0;
|
||||
diffData[i + 2] = 0;
|
||||
diffData[i + 3] = 255;
|
||||
diffCount++;
|
||||
} else {
|
||||
// Dimmed original for unchanged pixels
|
||||
const gray = Math.round(0.299 * r1 + 0.587 * g1 + 0.114 * b1);
|
||||
diffData[i] = gray;
|
||||
diffData[i + 1] = gray;
|
||||
diffData[i + 2] = gray;
|
||||
diffData[i + 3] = 128;
|
||||
}
|
||||
}
|
||||
|
||||
return diffCount;
|
||||
}
|
||||
|
||||
export interface ComparisonResult {
|
||||
name: string;
|
||||
baseExists: boolean;
|
||||
prExists: boolean;
|
||||
diffPixels: number;
|
||||
totalPixels: number;
|
||||
changePercent: number;
|
||||
diffImagePath: string | null;
|
||||
baseImagePath: string | null;
|
||||
prImagePath: string | null;
|
||||
}
|
||||
|
||||
export function compareScreenshots(baseDir: string, prDir: string, outputDir: string): ComparisonResult[] {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
// Collect all screenshot names from both directories
|
||||
const baseFiles = existsSync(baseDir)
|
||||
? new Set(readdirSync(baseDir).filter((f) => f.endsWith('.png')))
|
||||
: new Set<string>();
|
||||
const prFiles = existsSync(prDir) ? new Set(readdirSync(prDir).filter((f) => f.endsWith('.png'))) : new Set<string>();
|
||||
|
||||
const allNames = new Set([...baseFiles, ...prFiles]);
|
||||
const results: ComparisonResult[] = [];
|
||||
|
||||
for (const fileName of [...allNames].toSorted()) {
|
||||
const name = basename(fileName, '.png');
|
||||
const basePath = join(baseDir, fileName);
|
||||
const prPath = join(prDir, fileName);
|
||||
const baseExists = baseFiles.has(fileName);
|
||||
const prExists = prFiles.has(fileName);
|
||||
|
||||
if (!baseExists || !prExists) {
|
||||
// New or removed page
|
||||
results.push({
|
||||
name,
|
||||
baseExists,
|
||||
prExists,
|
||||
diffPixels: -1,
|
||||
totalPixels: -1,
|
||||
changePercent: 100,
|
||||
diffImagePath: null,
|
||||
baseImagePath: baseExists ? basePath : null,
|
||||
prImagePath: prExists ? prPath : null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load both PNGs
|
||||
const basePng = PNG.sync.read(readFileSync(basePath));
|
||||
const prPng = PNG.sync.read(readFileSync(prPath));
|
||||
|
||||
// Handle size mismatches by comparing the overlapping region
|
||||
const width = Math.max(basePng.width, prPng.width);
|
||||
const height = Math.max(basePng.height, prPng.height);
|
||||
|
||||
// Resize images to the same dimensions (pad with transparent)
|
||||
const normalizedBase = normalizeImage(basePng, width, height);
|
||||
const normalizedPr = normalizeImage(prPng, width, height);
|
||||
|
||||
const diffPng = new PNG({ width, height });
|
||||
const totalPixels = width * height;
|
||||
const diffPixels = pixelMatch(normalizedBase, normalizedPr, diffPng.data as unknown as Uint8Array);
|
||||
|
||||
const diffImagePath = join(outputDir, `${name}-diff.png`);
|
||||
writeFileSync(diffImagePath, PNG.sync.write(diffPng));
|
||||
|
||||
results.push({
|
||||
name,
|
||||
baseExists,
|
||||
prExists,
|
||||
diffPixels,
|
||||
totalPixels,
|
||||
changePercent: totalPixels > 0 ? (diffPixels / totalPixels) * 100 : 0,
|
||||
diffImagePath,
|
||||
baseImagePath: basePath,
|
||||
prImagePath: prPath,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function normalizeImage(png: PNG, targetWidth: number, targetHeight: number): Uint8Array {
|
||||
if (png.width === targetWidth && png.height === targetHeight) {
|
||||
return png.data as unknown as Uint8Array;
|
||||
}
|
||||
|
||||
const data = new Uint8Array(targetWidth * targetHeight * 4);
|
||||
for (let y = 0; y < targetHeight; y++) {
|
||||
for (let x = 0; x < targetWidth; x++) {
|
||||
const targetIdx = (y * targetWidth + x) * 4;
|
||||
if (x < png.width && y < png.height) {
|
||||
const sourceIdx = (y * png.width + x) * 4;
|
||||
data[targetIdx] = png.data[sourceIdx];
|
||||
data[targetIdx + 1] = png.data[sourceIdx + 1];
|
||||
data[targetIdx + 2] = png.data[sourceIdx + 2];
|
||||
data[targetIdx + 3] = png.data[sourceIdx + 3];
|
||||
} else {
|
||||
// Transparent padding
|
||||
data[targetIdx + 3] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Generate a text-only markdown summary for the PR comment. */
|
||||
export function generateMarkdownReport(results: ComparisonResult[]): string {
|
||||
const changed = results.filter((r) => r.changePercent > 0.1);
|
||||
const unchanged = results.filter((r) => r.changePercent <= 0.1);
|
||||
|
||||
if (changed.length === 0) {
|
||||
return '## Visual Review\n\nNo visual changes detected in the affected pages.';
|
||||
}
|
||||
|
||||
let md = '## Visual Review\n\n';
|
||||
md += `Found **${changed.length}** page(s) with visual changes`;
|
||||
if (unchanged.length > 0) {
|
||||
md += ` (${unchanged.length} unchanged)`;
|
||||
}
|
||||
md += '.\n\n';
|
||||
|
||||
md += '| Page | Status | Change |\n';
|
||||
md += '|------|--------|--------|\n';
|
||||
|
||||
for (const result of changed) {
|
||||
if (result.baseExists && result.prExists) {
|
||||
md += `| ${result.name} | Changed | ${result.changePercent.toFixed(1)}% |\n`;
|
||||
} else if (result.prExists) {
|
||||
md += `| ${result.name} | New | - |\n`;
|
||||
} else {
|
||||
md += `| ${result.name} | Removed | - |\n`;
|
||||
}
|
||||
}
|
||||
|
||||
md += '\n';
|
||||
|
||||
if (unchanged.length > 0) {
|
||||
md += '<details>\n<summary>Unchanged pages</summary>\n\n';
|
||||
for (const result of unchanged) {
|
||||
md += `- ${result.name}\n`;
|
||||
}
|
||||
md += '\n</details>\n';
|
||||
}
|
||||
|
||||
return md;
|
||||
}
|
||||
|
||||
function imgTag(filePath: string | null, alt: string): string {
|
||||
if (!filePath || !existsSync(filePath)) {
|
||||
return `<div class="no-image">${alt} not available</div>`;
|
||||
}
|
||||
const data = readFileSync(filePath);
|
||||
return `<img src="data:image/png;base64,${data.toString('base64')}" alt="${alt}" loading="lazy" />`;
|
||||
}
|
||||
|
||||
/** Generate an HTML report with embedded base64 images for the artifact. */
|
||||
export function generateHtmlReport(results: ComparisonResult[]): string {
|
||||
const changed = results.filter((r) => r.changePercent > 0.1);
|
||||
const unchanged = results.filter((r) => r.changePercent <= 0.1);
|
||||
|
||||
let html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Visual Review</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
background: #0d1117; color: #e6edf3; padding: 32px; line-height: 1.5; }
|
||||
.container { max-width: 1800px; margin: 0 auto; }
|
||||
h1 { font-size: 24px; border-bottom: 1px solid #30363d; padding-bottom: 12px; margin-bottom: 24px; }
|
||||
.summary { color: #8b949e; margin-bottom: 32px; font-size: 16px; }
|
||||
.scenario { margin-bottom: 40px; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
|
||||
.scenario-header { background: #161b22; padding: 12px 16px; display: flex; align-items: center; gap: 12px; }
|
||||
.scenario-header h2 { font-size: 16px; font-weight: 600; }
|
||||
.badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 500; }
|
||||
.badge-changed { background: #da363380; color: #f85149; }
|
||||
.badge-new { background: #1f6feb80; color: #58a6ff; }
|
||||
.badge-removed { background: #6e767e80; color: #8b949e; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1px; background: #30363d; }
|
||||
.grid-cell { background: #0d1117; }
|
||||
.grid-label { text-align: center; padding: 8px; font-size: 13px; color: #8b949e; font-weight: 600;
|
||||
background: #161b22; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.grid-cell img { width: 100%; display: block; }
|
||||
.no-image { padding: 40px; text-align: center; color: #484f58; font-style: italic; }
|
||||
.unchanged-section { margin-top: 32px; color: #8b949e; }
|
||||
.unchanged-section summary { cursor: pointer; font-size: 14px; }
|
||||
.unchanged-section ul { margin-top: 8px; padding-left: 24px; }
|
||||
.unchanged-section li { font-size: 14px; margin: 4px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Visual Review</h1>
|
||||
`;
|
||||
|
||||
if (changed.length === 0) {
|
||||
html += '<p class="summary">No visual changes detected in the affected pages.</p>';
|
||||
} else {
|
||||
html += `<p class="summary">Found <strong>${changed.length}</strong> page(s) with visual changes`;
|
||||
if (unchanged.length > 0) {
|
||||
html += ` (${unchanged.length} unchanged)`;
|
||||
}
|
||||
html += '.</p>\n';
|
||||
|
||||
for (const result of changed) {
|
||||
html += '<div class="scenario">\n<div class="scenario-header">\n';
|
||||
html += `<h2>${result.name}</h2>\n`;
|
||||
|
||||
if (!result.baseExists) {
|
||||
html += '<span class="badge badge-new">New</span>\n';
|
||||
html += '</div>\n';
|
||||
html += `<div style="padding: 16px;">${imgTag(result.prImagePath, 'PR')}</div>\n`;
|
||||
html += '</div>\n';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!result.prExists) {
|
||||
html += '<span class="badge badge-removed">Removed</span>\n';
|
||||
html += '</div>\n</div>\n';
|
||||
continue;
|
||||
}
|
||||
|
||||
html += `<span class="badge badge-changed">${result.changePercent.toFixed(1)}% changed</span>\n`;
|
||||
html += '</div>\n';
|
||||
html += '<div class="grid">\n';
|
||||
html += `<div class="grid-cell"><div class="grid-label">Base</div>${imgTag(result.baseImagePath, 'Base')}</div>\n`;
|
||||
html += `<div class="grid-cell"><div class="grid-label">PR</div>${imgTag(result.prImagePath, 'PR')}</div>\n`;
|
||||
html += `<div class="grid-cell"><div class="grid-label">Diff</div>${imgTag(result.diffImagePath, 'Diff')}</div>\n`;
|
||||
html += '</div>\n</div>\n';
|
||||
}
|
||||
}
|
||||
|
||||
if (unchanged.length > 0) {
|
||||
html += '<div class="unchanged-section">\n<details>\n<summary>Unchanged pages</summary>\n<ul>\n';
|
||||
for (const result of unchanged) {
|
||||
html += `<li>${result.name}</li>\n`;
|
||||
}
|
||||
html += '</ul>\n</details>\n</div>\n';
|
||||
}
|
||||
|
||||
html += '</div>\n</body>\n</html>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// CLI usage
|
||||
if (process.argv[1]?.endsWith('compare.ts') || process.argv[1]?.endsWith('compare.js')) {
|
||||
const [baseDir, prDir, outputDir] = process.argv.slice(2);
|
||||
|
||||
if (!baseDir || !prDir || !outputDir) {
|
||||
throw new Error('Usage: compare.ts <base-dir> <pr-dir> <output-dir>');
|
||||
}
|
||||
|
||||
const resolvedOutputDir = resolve(outputDir);
|
||||
const results = compareScreenshots(resolve(baseDir), resolve(prDir), resolvedOutputDir);
|
||||
|
||||
console.log('\nComparison Results:');
|
||||
console.log('==================');
|
||||
for (const r of results) {
|
||||
const status = r.changePercent > 0.1 ? 'CHANGED' : 'unchanged';
|
||||
console.log(` ${r.name}: ${status} (${r.changePercent.toFixed(1)}%)`);
|
||||
}
|
||||
|
||||
const report = generateMarkdownReport(results);
|
||||
const reportPath = join(resolvedOutputDir, 'report.md');
|
||||
writeFileSync(reportPath, report);
|
||||
console.log(`\nMarkdown report written to: ${reportPath}`);
|
||||
|
||||
const htmlReport = generateHtmlReport(results);
|
||||
const htmlPath = join(resolvedOutputDir, 'visual-review.html');
|
||||
writeFileSync(htmlPath, htmlReport);
|
||||
console.log(`HTML report written to: ${htmlPath}`);
|
||||
|
||||
const jsonPath = join(resolvedOutputDir, 'results.json');
|
||||
writeFileSync(jsonPath, JSON.stringify(results, null, 2));
|
||||
console.log(`Results JSON written to: ${jsonPath}`);
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
/**
|
||||
* Maps URL routes to screenshot scenario keys.
|
||||
*
|
||||
* Routes discovered by the dependency analyzer are matched against this map
|
||||
* to determine which screenshot scenarios to run. Routes not in this map
|
||||
* are skipped (they don't have a scenario defined yet).
|
||||
*/
|
||||
|
||||
export interface ScenarioDefinition {
|
||||
/** The URL path to navigate to */
|
||||
url: string;
|
||||
/** Human-readable name for the screenshot file */
|
||||
name: string;
|
||||
/** Which mock networks this scenario needs */
|
||||
mocks: ('base' | 'timeline' | 'memory')[];
|
||||
/** Optional: selector to wait for before screenshotting */
|
||||
waitForSelector?: string;
|
||||
/** Optional: time to wait after page load (ms) for animations to settle */
|
||||
settleTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map from route paths (as output by analyze-deps) to scenario definitions.
|
||||
* A single route might map to multiple scenarios (e.g., different states).
|
||||
*/
|
||||
export const PAGE_SCENARIOS: Record<string, ScenarioDefinition[]> = {
|
||||
'/photos': [
|
||||
{
|
||||
url: '/photos',
|
||||
name: 'photos-timeline',
|
||||
mocks: ['base', 'timeline'],
|
||||
waitForSelector: '[data-thumbnail-focus-container]',
|
||||
settleTime: 500,
|
||||
},
|
||||
],
|
||||
'/albums': [
|
||||
{
|
||||
url: '/albums',
|
||||
name: 'albums-list',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/explore': [
|
||||
{
|
||||
url: '/explore',
|
||||
name: 'explore',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/favorites': [
|
||||
{
|
||||
url: '/favorites',
|
||||
name: 'favorites',
|
||||
mocks: ['base', 'timeline'],
|
||||
waitForSelector: '#asset-grid',
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/archive': [
|
||||
{
|
||||
url: '/archive',
|
||||
name: 'archive',
|
||||
mocks: ['base', 'timeline'],
|
||||
waitForSelector: '#asset-grid',
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/trash': [
|
||||
{
|
||||
url: '/trash',
|
||||
name: 'trash',
|
||||
mocks: ['base', 'timeline'],
|
||||
waitForSelector: '#asset-grid',
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/people': [
|
||||
{
|
||||
url: '/people',
|
||||
name: 'people',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/sharing': [
|
||||
{
|
||||
url: '/sharing',
|
||||
name: 'sharing',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/search': [
|
||||
{
|
||||
url: '/search',
|
||||
name: 'search',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/memory': [
|
||||
{
|
||||
url: '/memory',
|
||||
name: 'memory',
|
||||
mocks: ['base', 'memory'],
|
||||
settleTime: 500,
|
||||
},
|
||||
],
|
||||
'/user-settings': [
|
||||
{
|
||||
url: '/user-settings',
|
||||
name: 'user-settings',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/map': [
|
||||
{
|
||||
url: '/map',
|
||||
name: 'map',
|
||||
mocks: ['base'],
|
||||
settleTime: 500,
|
||||
},
|
||||
],
|
||||
'/admin': [
|
||||
{
|
||||
url: '/admin',
|
||||
name: 'admin-dashboard',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/admin/system-settings': [
|
||||
{
|
||||
url: '/admin/system-settings',
|
||||
name: 'admin-system-settings',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/admin/users': [
|
||||
{
|
||||
url: '/admin/users',
|
||||
name: 'admin-users',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/auth/login': [
|
||||
{
|
||||
url: '/auth/login',
|
||||
name: 'login',
|
||||
mocks: [],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/': [
|
||||
{
|
||||
url: '/',
|
||||
name: 'landing',
|
||||
mocks: [],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Given a list of routes from the analyzer, return the matching scenarios. */
|
||||
export function getScenariosForRoutes(routes: string[]): ScenarioDefinition[] {
|
||||
const scenarios: ScenarioDefinition[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const route of routes) {
|
||||
const defs = PAGE_SCENARIOS[route];
|
||||
if (defs) {
|
||||
for (const def of defs) {
|
||||
if (!seen.has(def.name)) {
|
||||
seen.add(def.name);
|
||||
scenarios.push(def);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scenarios;
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* Playwright script to capture screenshots for visual diff scenarios.
|
||||
*
|
||||
* Usage:
|
||||
* npx playwright test --config e2e/playwright.screenshot.config.ts
|
||||
*
|
||||
* Environment variables:
|
||||
* SCREENSHOT_SCENARIOS - JSON array of scenario names to run (from page-map.ts)
|
||||
* If not set, runs all scenarios.
|
||||
* SCREENSHOT_OUTPUT_DIR - Directory to save screenshots to. Defaults to e2e/screenshots-output.
|
||||
*/
|
||||
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { MemoryResponseDto } from '@immich/sdk';
|
||||
import { test } from '@playwright/test';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { generateMemoriesFromTimeline } from 'src/ui/generators/memory';
|
||||
import {
|
||||
createDefaultTimelineConfig,
|
||||
generateTimelineData,
|
||||
type TimelineAssetConfig,
|
||||
type TimelineData,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||
import { setupMemoryMockApiRoutes } from 'src/ui/mock-network/memory-network';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
||||
import { PAGE_SCENARIOS, type ScenarioDefinition } from './page-map';
|
||||
|
||||
const OUTPUT_DIR = process.env.SCREENSHOT_OUTPUT_DIR || resolve(import.meta.dirname, '../../../screenshots-output');
|
||||
const SCENARIO_FILTER: string[] | null = process.env.SCREENSHOT_SCENARIOS
|
||||
? JSON.parse(process.env.SCREENSHOT_SCENARIOS)
|
||||
: null;
|
||||
|
||||
// Collect scenarios to run
|
||||
const allScenarios: ScenarioDefinition[] = [];
|
||||
for (const defs of Object.values(PAGE_SCENARIOS)) {
|
||||
for (const def of defs) {
|
||||
if (!SCENARIO_FILTER || SCENARIO_FILTER.includes(def.name)) {
|
||||
allScenarios.push(def);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use a fixed seed so screenshots are deterministic across runs
|
||||
faker.seed(42);
|
||||
|
||||
let adminUserId: string;
|
||||
let timelineData: TimelineData;
|
||||
let timelineAssets: TimelineAssetConfig[];
|
||||
let memories: MemoryResponseDto[];
|
||||
|
||||
test.beforeAll(async () => {
|
||||
adminUserId = faker.string.uuid();
|
||||
timelineData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||
|
||||
timelineAssets = [];
|
||||
for (const timeBucket of timelineData.buckets.values()) {
|
||||
timelineAssets.push(...timeBucket);
|
||||
}
|
||||
|
||||
memories = generateMemoriesFromTimeline(
|
||||
timelineAssets,
|
||||
adminUserId,
|
||||
[
|
||||
{ year: 2024, assetCount: 3 },
|
||||
{ year: 2023, assetCount: 2 },
|
||||
],
|
||||
42,
|
||||
);
|
||||
|
||||
mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
for (const scenario of allScenarios) {
|
||||
test(`Screenshot: ${scenario.name}`, async ({ context, page }) => {
|
||||
// Set up mocks based on scenario requirements
|
||||
if (scenario.mocks.includes('base')) {
|
||||
await setupBaseMockApiRoutes(context, adminUserId);
|
||||
}
|
||||
|
||||
if (scenario.mocks.includes('timeline')) {
|
||||
const testContext = new TimelineTestContext();
|
||||
testContext.adminId = adminUserId;
|
||||
await setupTimelineMockApiRoutes(
|
||||
context,
|
||||
timelineData,
|
||||
{
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
},
|
||||
testContext,
|
||||
);
|
||||
}
|
||||
|
||||
if (scenario.mocks.includes('memory')) {
|
||||
await setupMemoryMockApiRoutes(context, memories, {
|
||||
memoryDeletions: [],
|
||||
assetRemovals: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
// Navigate to the page. Use networkidle so SvelteKit hydrates and API
|
||||
// calls complete, but fall back to domcontentloaded if it times out
|
||||
// (e.g. a persistent connection the catch-all mock didn't cover).
|
||||
try {
|
||||
await page.goto(scenario.url, { waitUntil: 'networkidle', timeout: 15_000 });
|
||||
} catch {
|
||||
console.warn(`networkidle timed out for ${scenario.name}, falling back to current state`);
|
||||
// Page has already navigated, just continue with what we have
|
||||
}
|
||||
|
||||
// Wait for specific selector if specified
|
||||
if (scenario.waitForSelector) {
|
||||
try {
|
||||
await page.waitForSelector(scenario.waitForSelector, { timeout: 15_000 });
|
||||
} catch {
|
||||
console.warn(`Selector ${scenario.waitForSelector} not found for ${scenario.name}, continuing...`);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for loading spinners to disappear
|
||||
await page
|
||||
.waitForFunction(() => document.querySelectorAll('[data-testid="loading-spinner"]').length === 0, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Wait for animations/transitions to settle
|
||||
await page.waitForTimeout(scenario.settleTime ?? 500);
|
||||
|
||||
// Take the screenshot
|
||||
await page.screenshot({
|
||||
path: resolve(OUTPUT_DIR, `${scenario.name}.png`),
|
||||
fullPage: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { LoginResponseDto, ManualJobName } from '@immich/sdk';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/admin/database-backups', () => {
|
||||
let cookie: string | undefined;
|
||||
@@ -10,7 +10,12 @@ describe('/admin/database-backups', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
admin = await utils.adminSetup({
|
||||
onboarding: false,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
});
|
||||
|
||||
@@ -94,7 +99,9 @@ describe('/admin/database-backups', () => {
|
||||
({ status, body }) => status === 200 && !body.maintenanceMode,
|
||||
);
|
||||
|
||||
admin = await utils.adminSetup();
|
||||
admin = await utils.adminSetup({
|
||||
onboarding: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.sequential('should not work when the server is configured', async () => {
|
||||
|
||||
@@ -130,12 +130,11 @@ describe('/albums', () => {
|
||||
describe('GET /albums', () => {
|
||||
it("should not show other users' favorites", async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/albums/${user1Albums[0].id}?withoutAssets=false`)
|
||||
.get(`/albums/${user1Albums[0].id}`)
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`);
|
||||
expect(status).toEqual(200);
|
||||
expect(body).toEqual({
|
||||
...user1Albums[0],
|
||||
assets: [expect.objectContaining({ isFavorite: false })],
|
||||
contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
|
||||
lastModifiedAssetTimestamp: expect.any(String),
|
||||
startDate: expect.any(String),
|
||||
@@ -147,7 +146,7 @@ describe('/albums', () => {
|
||||
|
||||
it('should not return shared albums with a deleted owner', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/albums?shared=true')
|
||||
.get('/albums?isShared=true')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
@@ -155,23 +154,31 @@ describe('/albums', () => {
|
||||
expect(body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
ownerId: user1.userId,
|
||||
albumName: user1SharedLink,
|
||||
albumUsers: expect.arrayContaining([
|
||||
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
|
||||
]),
|
||||
shared: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
ownerId: user1.userId,
|
||||
albumName: user1SharedEditorUser,
|
||||
albumUsers: expect.arrayContaining([
|
||||
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
|
||||
]),
|
||||
shared: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
ownerId: user1.userId,
|
||||
albumName: user1SharedViewerUser,
|
||||
albumUsers: expect.arrayContaining([
|
||||
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
|
||||
]),
|
||||
shared: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
ownerId: user2.userId,
|
||||
albumName: user2SharedUser,
|
||||
albumUsers: expect.arrayContaining([
|
||||
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user2.userId }) },
|
||||
]),
|
||||
shared: true,
|
||||
}),
|
||||
]),
|
||||
@@ -181,82 +188,164 @@ describe('/albums', () => {
|
||||
it('should return the album collection including owned and shared', async () => {
|
||||
const { status, body } = await request(app).get('/albums').set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(4);
|
||||
expect(body).toHaveLength(5);
|
||||
expect(body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
ownerId: user1.userId,
|
||||
albumName: user1SharedEditorUser,
|
||||
albumUsers: expect.arrayContaining([
|
||||
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
|
||||
]),
|
||||
shared: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
ownerId: user1.userId,
|
||||
albumName: user1SharedViewerUser,
|
||||
albumUsers: expect.arrayContaining([
|
||||
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
|
||||
]),
|
||||
shared: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
ownerId: user1.userId,
|
||||
albumName: user1SharedLink,
|
||||
albumUsers: expect.arrayContaining([
|
||||
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
|
||||
]),
|
||||
shared: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
ownerId: user1.userId,
|
||||
albumName: user1NotShared,
|
||||
albumUsers: expect.arrayContaining([
|
||||
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
|
||||
]),
|
||||
shared: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
albumName: user2SharedUser,
|
||||
albumUsers: expect.arrayContaining([
|
||||
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user2.userId }) },
|
||||
]),
|
||||
shared: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the album collection filtered by shared', async () => {
|
||||
it('should return the album collection filtered by isShared', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/albums?shared=true')
|
||||
.get('/albums?isShared=true')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(4);
|
||||
expect(body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
ownerId: user1.userId,
|
||||
albumName: user1SharedEditorUser,
|
||||
albumUsers: expect.arrayContaining([
|
||||
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
|
||||
]),
|
||||
shared: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
ownerId: user1.userId,
|
||||
albumName: user1SharedViewerUser,
|
||||
albumUsers: expect.arrayContaining([
|
||||
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
|
||||
]),
|
||||
shared: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
ownerId: user1.userId,
|
||||
albumName: user1SharedLink,
|
||||
albumUsers: expect.arrayContaining([
|
||||
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
|
||||
]),
|
||||
shared: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
ownerId: user2.userId,
|
||||
albumName: user2SharedUser,
|
||||
albumUsers: expect.arrayContaining([
|
||||
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user2.userId }) },
|
||||
]),
|
||||
shared: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the album collection filtered by NOT shared', async () => {
|
||||
it('should return the album collection filtered by NOT isShared', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/albums?shared=false')
|
||||
.get('/albums?isShared=false')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
ownerId: user1.userId,
|
||||
albumName: user1NotShared,
|
||||
albumUsers: expect.arrayContaining([
|
||||
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
|
||||
]),
|
||||
shared: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return only owned albums when filtered by isOwned=true', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/albums?isOwned=true')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(4);
|
||||
expect(body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ albumName: user1SharedEditorUser }),
|
||||
expect.objectContaining({ albumName: user1SharedViewerUser }),
|
||||
expect.objectContaining({ albumName: user1SharedLink }),
|
||||
expect.objectContaining({ albumName: user1NotShared }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return only shared-with-me albums when filtered by isOwned=false', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/albums?isOwned=false')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
albumName: user2SharedUser,
|
||||
albumUsers: expect.arrayContaining([
|
||||
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user2.userId }) },
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return owned shared-out albums when filtered by isOwned=true&ishared=true', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/albums?isOwned=true&isShared=true')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(3);
|
||||
expect(body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ albumName: user1SharedEditorUser }),
|
||||
expect.objectContaining({ albumName: user1SharedViewerUser }),
|
||||
expect.objectContaining({ albumName: user1SharedLink }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty list when filtered by isOwned=false&isShared=false', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/albums?isOwned=false&isShared=false')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return the album collection filtered by assetId', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/albums?assetId=${user1Asset2.id}`)
|
||||
@@ -265,17 +354,17 @@ describe('/albums', () => {
|
||||
expect(body).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return the album collection filtered by assetId and ignores shared=true', async () => {
|
||||
it('should return the album collection filtered by assetId and ignores isShared=true', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/albums?shared=true&assetId=${user1Asset1.id}`)
|
||||
.get(`/albums?isShared=true&assetId=${user1Asset1.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should return the album collection filtered by assetId and ignores shared=false', async () => {
|
||||
it('should return the album collection filtered by assetId and ignores isShared=false', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/albums?shared=false&assetId=${user1Asset1.id}`)
|
||||
.get(`/albums?isShared=false&assetId=${user1Asset1.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(5);
|
||||
@@ -287,13 +376,17 @@ describe('/albums', () => {
|
||||
expect(body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
ownerId: user4.userId,
|
||||
albumName: user4DeletedAsset,
|
||||
albumUsers: expect.arrayContaining([
|
||||
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user4.userId }) },
|
||||
]),
|
||||
shared: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
ownerId: user4.userId,
|
||||
albumName: user4Empty,
|
||||
albumUsers: expect.arrayContaining([
|
||||
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user4.userId }) },
|
||||
]),
|
||||
shared: false,
|
||||
}),
|
||||
]),
|
||||
@@ -304,13 +397,12 @@ describe('/albums', () => {
|
||||
describe('GET /albums/:id', () => {
|
||||
it('should return album info for own album', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/albums/${user1Albums[0].id}?withoutAssets=false`)
|
||||
.get(`/albums/${user1Albums[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
...user1Albums[0],
|
||||
assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })],
|
||||
contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
|
||||
lastModifiedAssetTimestamp: expect.any(String),
|
||||
startDate: expect.any(String),
|
||||
@@ -322,7 +414,7 @@ describe('/albums', () => {
|
||||
|
||||
it('should return album info for shared album (editor)', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/albums/${user2Albums[0].id}?withoutAssets=false`)
|
||||
.get(`/albums/${user2Albums[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
@@ -331,14 +423,14 @@ describe('/albums', () => {
|
||||
|
||||
it('should return album info for shared album (viewer)', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/albums/${user1Albums[3].id}?withoutAssets=false`)
|
||||
.get(`/albums/${user1Albums[3].id}`)
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ id: user1Albums[3].id });
|
||||
});
|
||||
|
||||
it('should return album info with assets when withoutAssets is undefined', async () => {
|
||||
it('should return album info', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/albums/${user1Albums[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
@@ -346,25 +438,6 @@ describe('/albums', () => {
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
...user1Albums[0],
|
||||
assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })],
|
||||
contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
|
||||
lastModifiedAssetTimestamp: expect.any(String),
|
||||
startDate: expect.any(String),
|
||||
endDate: expect.any(String),
|
||||
albumUsers: expect.any(Array),
|
||||
shared: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return album info without assets when withoutAssets is true', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/albums/${user1Albums[0].id}?withoutAssets=true`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
...user1Albums[0],
|
||||
assets: [],
|
||||
contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
|
||||
assetCount: 1,
|
||||
lastModifiedAssetTimestamp: expect.any(String),
|
||||
@@ -379,21 +452,21 @@ describe('/albums', () => {
|
||||
await utils.deleteAssets(user1.accessToken, [user1Asset2.id]);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get(`/albums/${user2Albums[0].id}?withoutAssets=true`)
|
||||
.get(`/albums/${user2Albums[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
...user2Albums[0],
|
||||
assets: [],
|
||||
contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
|
||||
assetCount: 1,
|
||||
lastModifiedAssetTimestamp: expect.any(String),
|
||||
endDate: expect.any(String),
|
||||
startDate: expect.any(String),
|
||||
albumUsers: expect.any(Array),
|
||||
shared: true,
|
||||
});
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
|
||||
assetCount: 1,
|
||||
lastModifiedAssetTimestamp: expect.any(String),
|
||||
endDate: expect.any(String),
|
||||
startDate: expect.any(String),
|
||||
albumUsers: expect.any(Array),
|
||||
shared: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -419,16 +492,13 @@ describe('/albums', () => {
|
||||
id: expect.any(String),
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
ownerId: user1.userId,
|
||||
albumName: 'New album',
|
||||
description: '',
|
||||
albumThumbnailAssetId: null,
|
||||
shared: false,
|
||||
albumUsers: [],
|
||||
albumUsers: [{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) }],
|
||||
hasSharedLink: false,
|
||||
assets: [],
|
||||
assetCount: 0,
|
||||
owner: expect.objectContaining({ email: user1.userEmail }),
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.Desc,
|
||||
});
|
||||
@@ -524,14 +594,19 @@ describe('/albums', () => {
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
|
||||
});
|
||||
|
||||
it('should not be able to update as an editor', async () => {
|
||||
it('should be able to update as an editor', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.patch(`/albums/${user1Albums[0].id}`)
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`)
|
||||
.send({ albumName: 'New album name' });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
id: user1Albums[0].id,
|
||||
albumName: 'New album name',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -639,11 +714,11 @@ describe('/albums', () => {
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
albumUsers: [
|
||||
albumUsers: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
user: expect.objectContaining({ id: user2.userId }),
|
||||
}),
|
||||
],
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -655,7 +730,7 @@ describe('/albums', () => {
|
||||
.send({ albumUsers: [{ userId: user1.userId, role: AlbumUserRole.Editor }] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('Cannot be shared with owner'));
|
||||
expect(body).toEqual(errorDto.badRequest('User already added'));
|
||||
});
|
||||
|
||||
it('should not be able to add existing user to shared album', async () => {
|
||||
@@ -681,7 +756,7 @@ describe('/albums', () => {
|
||||
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
|
||||
});
|
||||
|
||||
expect(album.albumUsers[0].role).toEqual(AlbumUserRole.Viewer);
|
||||
expect(album.albumUsers[1].role).toEqual(AlbumUserRole.Viewer);
|
||||
|
||||
const { status } = await request(app)
|
||||
.put(`/albums/${album.id}/user/${user2.userId}`)
|
||||
@@ -696,7 +771,10 @@ describe('/albums', () => {
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
albumUsers: [expect.objectContaining({ role: AlbumUserRole.Editor })],
|
||||
albumUsers: [
|
||||
expect.objectContaining({ role: AlbumUserRole.Owner }),
|
||||
expect.objectContaining({ role: AlbumUserRole.Editor }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -707,7 +785,7 @@ describe('/albums', () => {
|
||||
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
|
||||
});
|
||||
|
||||
expect(album.albumUsers[0].role).toEqual(AlbumUserRole.Viewer);
|
||||
expect(album.albumUsers[1].role).toEqual(AlbumUserRole.Viewer);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put(`/albums/${album.id}/user/${user2.userId}`)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user