mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
734 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9124c9be9c | |||
| 3ae3c6540d | |||
| 313ebdd402 | |||
| db52792ada | |||
| a8065680bb | |||
| 3304f09b1d | |||
| ba621cfc4a | |||
| deca7735a7 | |||
| b503fdee7a | |||
| 3fe37e050b | |||
| d3233a62c0 | |||
| 165eee7b36 | |||
| 78526afc9c | |||
| f534e0607a | |||
| db64a6c093 | |||
| 5c9e7fcaa6 | |||
| af67f4cd6e | |||
| 73de139d1f | |||
| e0cc481b96 | |||
| 7ac75ec69a | |||
| 0e73e43cbd | |||
| bd41ab3fb4 | |||
| 73b95a8e38 | |||
| 5a5555d28a | |||
| 4dc9cbe2e7 | |||
| 6af0e5fdcf | |||
| f0055b76fe | |||
| 0eb3f104f4 | |||
| c171b38155 | |||
| 89d7a6cbb2 | |||
| 85918d1196 | |||
| fa9307e2e1 | |||
| 5ab18c2725 | |||
| a4a2bde1a4 | |||
| 28113f5fdc | |||
| 42b8805e94 | |||
| 51a64f41b9 | |||
| a7bd9a942c | |||
| 97491a6175 | |||
| ea5cb3a130 | |||
| 921010926b | |||
| 7425c3d57b | |||
| 1b7d4eef46 | |||
| c30dab56a3 | |||
| e365250440 | |||
| 47d10e1f58 | |||
| e6473300a7 | |||
| 994f64f753 | |||
| 90064a5480 | |||
| 6e8eac9887 | |||
| d3051eaffe | |||
| 496a897782 | |||
| 22b7290ee1 | |||
| 14037d5dea | |||
| 152db3fb9f | |||
| 0413d123da | |||
| 5f6bd7b5cd | |||
| 3c25bb5d61 | |||
| 659b91b000 | |||
| fe5b3bd56a | |||
| 82bbb116ae | |||
| 9f5ff7e6f0 | |||
| a00504080a | |||
| fc6e2e6f10 | |||
| 65c5f3e4bf | |||
| 07e340251b | |||
| 9b956b928b | |||
| c3187f6e3f | |||
| 60c815a8b3 | |||
| 41b155ea31 | |||
| 2888ae8bf7 | |||
| 7363064d89 | |||
| 1d53bf2ae1 | |||
| 618165ec31 | |||
| 87c66a9b8c | |||
| 1ea4724261 | |||
| 3d372f039e | |||
| 0384017e98 | |||
| 0332579d5f | |||
| 6aefe18caa | |||
| ef72fc8d83 | |||
| 4764c09572 | |||
| 98ca766a37 | |||
| 13c9035b76 | |||
| ad6d0ba21d | |||
| 431f7f088b | |||
| 3f13ed1113 | |||
| 9d359c40dd | |||
| 0e7dbd6215 | |||
| 7fd8de91cb | |||
| 5f323eb2ce | |||
| c0ac8bf11a | |||
| b676733af7 | |||
| 7a2027a3a7 | |||
| 9ffb74adce | |||
| 13faff7078 | |||
| a8a4e88d86 | |||
| 8fa5db88ff | |||
| 3c325f81c8 | |||
| 585434010c | |||
| eb1a705cae | |||
| 10770b2b77 | |||
| b61c51e064 | |||
| 019ecbec7b | |||
| 6a96c5640e | |||
| 4025e92cb4 | |||
| 2355419ef9 | |||
| 86214ea796 | |||
| 774f2d4695 | |||
| efa64051ea | |||
| 5095347ace | |||
| ad9260f8fe | |||
| 4ae2502096 | |||
| c05b5259ef | |||
| da1fb02c9d | |||
| eb09251424 | |||
| 5584862352 | |||
| d5251c1e4e | |||
| 96a554b4b5 | |||
| 3bd441c288 | |||
| c514fb360e | |||
| c0c1412f4a | |||
| 360f9c173b | |||
| 60b11bc2bc | |||
| ac546f1197 | |||
| 93c7487c96 | |||
| 2e8b1cb7a1 | |||
| cdb1564826 | |||
| 90c10137e9 | |||
| 20ceef1fcf | |||
| 43fdfe8202 | |||
| 97d162569d | |||
| c5994ed8a0 | |||
| 8b0260e0e1 | |||
| 56cdb63978 | |||
| a81fbaa6a6 | |||
| 8c3f5c53be | |||
| 44ca451cd4 | |||
| 94057ef3e7 | |||
| 785f18d4b2 | |||
| 0367d3c8b5 | |||
| 99ef2ed410 | |||
| 0aff87c69d | |||
| 32fdf3fe61 | |||
| 11685101eb | |||
| 3641d084aa | |||
| afae22ffe2 | |||
| a59cb5b858 | |||
| c55d29ae84 | |||
| 3493aeff0b | |||
| deec4ead6d | |||
| e657a5aa84 | |||
| b1d792d091 | |||
| cd1297a30f | |||
| 71bf60c69c | |||
| 0bcb1fec87 | |||
| cbe0d7e88f | |||
| a8706df827 | |||
| 65d1ad5ae5 | |||
| b489bc491b | |||
| aa15cfe1f1 | |||
| 111c38c36d | |||
| cc7c898277 | |||
| 7a0d880be9 | |||
| d4c753b08a | |||
| 27c86afff4 | |||
| 6872e9f646 | |||
| 5bf2c1f5d4 | |||
| 91cee6cb37 | |||
| 801b2fa59d | |||
| dc6cb316c1 | |||
| 495292ab3b | |||
| d368353656 | |||
| 1dc9086bb4 | |||
| 541de2165e | |||
| 0d9ba2c33a | |||
| 2f1f427b9e | |||
| 3edeb94c93 | |||
| b203719eb7 | |||
| 8af0c676b5 | |||
| 8071285ff2 | |||
| 42ef19b7e5 | |||
| c0d743d89d | |||
| c32f23a626 | |||
| f436267572 | |||
| 5c6fe91742 | |||
| 60d2cfe2f1 | |||
| a64a98be23 | |||
| 0dd9f8399f | |||
| 06b25564ea | |||
| cc056077ee | |||
| 19381eb270 | |||
| 923198ccea | |||
| 8ea278cfb7 | |||
| b610dda098 | |||
| 78de029ef3 | |||
| 470a639091 | |||
| 9833c60e38 | |||
| a6f2d0208d | |||
| 862443dda6 | |||
| 077bb10d25 | |||
| e700962f2a | |||
| 2df7c34cf7 | |||
| e7d988ebf8 | |||
| ccd9756b6f | |||
| 1afc69baa0 | |||
| 87928cfa4d | |||
| d11f784438 | |||
| c07290619c | |||
| 77b6e448e0 | |||
| a1687eb746 | |||
| 204f7fb676 | |||
| 1b56469beb | |||
| aec1ca3e1b | |||
| 5daee11c77 | |||
| 5ecb5e2144 | |||
| 3bea1ca276 | |||
| 0493823b99 | |||
| e18f73ed3c | |||
| 99edbe82b5 | |||
| 5cdc556c80 | |||
| bdf29103f0 | |||
| 7ebeddfa38 | |||
| 7cf0e468ff | |||
| 941bb273b9 | |||
| b41f62d572 | |||
| ca6abdc392 | |||
| 6bb06fac14 | |||
| a97dc35790 | |||
| e4abb03ca5 | |||
| 5455d4d2e4 | |||
| f386333b9c | |||
| 1ee10caa5a | |||
| a2be6a2127 | |||
| 41f39d9701 | |||
| e22b537de4 | |||
| d18d4239db | |||
| a5a4baeaf6 | |||
| c8fe2f6516 | |||
| f88b833cbc | |||
| d45a8ceeec | |||
| cf6688a5be | |||
| 5dfe42b253 | |||
| 1d259f5135 | |||
| 81e42a5b8a | |||
| 24808f6802 | |||
| 3b1e797893 | |||
| 53f659e113 | |||
| e47304f96d | |||
| 05826e2fc7 | |||
| 3b74a68c83 | |||
| 9b48c6d57f | |||
| 6d5a141096 | |||
| 49e8a0c34e | |||
| e5bdc080b4 | |||
| c229268263 | |||
| c9cc4a6ee9 | |||
| f25873e1aa | |||
| 649339ac95 | |||
| 674263c0d8 | |||
| c5116c1a3b | |||
| 7887ea6dc5 | |||
| b536cd1a6e | |||
| e8ecf764d4 | |||
| 1d984d75bb | |||
| d26b557ea0 | |||
| ea9845622c | |||
| 9db0be2d79 | |||
| 7cf27ab509 | |||
| 94ac6fa6ce | |||
| 300d3fd502 | |||
| fd721157db | |||
| 246eaa7b53 | |||
| 36d2d2fbf4 | |||
| bdbb2a079d | |||
| 94e179ce48 | |||
| b2b7bd6c0e | |||
| 560bae2b5d | |||
| 91b9543dc1 | |||
| c6432cf0c0 | |||
| 6351eba0ee | |||
| d049fee084 | |||
| ebd43f150c | |||
| 47069fb751 | |||
| fb78a167e1 | |||
| ccfa50e3a9 | |||
| 0762f9fb5c | |||
| 8322808741 | |||
| d53d01b419 | |||
| a0b8ab3807 | |||
| 008f3f2336 | |||
| c32af8ff66 | |||
| 0c4d871464 | |||
| 44ebe47f64 | |||
| d675c4e8ed | |||
| b3c8b38f7a | |||
| 9f22e4060b | |||
| ad1c3e55ba | |||
| 2d7cb55c02 | |||
| 5895d135a8 | |||
| e5b946487f | |||
| 8d0aa2b589 | |||
| 4c839b3f4e | |||
| ce9c7638fa | |||
| dd438d1ca4 | |||
| 185f5a31d9 | |||
| 5c59664ed2 | |||
| 8dff7f8a91 | |||
| 2e660325d6 | |||
| e41105910e | |||
| 2fbfed3ac9 | |||
| 5beb2df342 | |||
| 06bb295f66 | |||
| 5ef831f830 | |||
| bdc3a169f7 | |||
| 5450bc7c49 | |||
| a158039a13 | |||
| ba5262b648 | |||
| 45ede69d7e | |||
| bd35e5adf7 | |||
| a28438fac6 | |||
| bc5eed4dec | |||
| cf07a5167a | |||
| 4536b6199c | |||
| 4b50b5d166 | |||
| 3bfb05450f | |||
| cdb532ac55 | |||
| e24fe022a4 | |||
| cec89cad3b | |||
| c4f85e7b2c | |||
| 902758e8af | |||
| 67eefde17f | |||
| 2770a531f1 | |||
| 49c146048c | |||
| 83325eecea | |||
| 0a5c65dd33 | |||
| a587054443 | |||
| 0e11d64a2f | |||
| 53b4552c72 | |||
| 6f65544542 | |||
| 82186eb093 | |||
| d184e687c7 | |||
| f5e36c9a5c | |||
| a7e7dcf764 | |||
| d67dab1f4c | |||
| 1c0e51135e | |||
| 75fed9532c | |||
| 6534347be6 | |||
| 335a7fa14b | |||
| 09c4e9252c | |||
| c08460baa7 | |||
| 7d883515d4 | |||
| b71ff818fa | |||
| 93be60ed99 | |||
| 65adca04df | |||
| 0da96f7e4e | |||
| a4a93b7fed | |||
| 74aa654c92 | |||
| bce0143dbc | |||
| d1307bfcc1 | |||
| 13cca43813 | |||
| e16fecb3ec | |||
| 16c3ed239a | |||
| 89af1dad32 | |||
| c61d0190f2 | |||
| 087a00b5d0 | |||
| 3419077b67 | |||
| d476f8ac8b | |||
| b2c04bb7b6 | |||
| 64a7f78e1b | |||
| 42ed6960a1 | |||
| 7efa5d20b0 | |||
| 8a57410626 | |||
| 4e00c4c14d | |||
| 5eb0d4c8af | |||
| 9021007027 | |||
| 02fe4fd374 | |||
| 3d3679c21c | |||
| baa4a38b0a | |||
| 29f5df673c | |||
| f49ef7f1df | |||
| ab6b8178f4 | |||
| 9462f3b021 | |||
| 82df266c4e | |||
| 326d7f2eba | |||
| 92b5fbf5b5 | |||
| b31e22bfb5 | |||
| 21a0ba023d | |||
| 359ee5003d | |||
| e267781ab6 | |||
| 076dc1269f | |||
| 2693c04123 | |||
| 711b538f3d | |||
| 6f3e678f79 | |||
| 95e723a931 | |||
| c00c55cff4 | |||
| ce2d194661 | |||
| 996030eae8 | |||
| 070fd7459e | |||
| bbc1d542bb | |||
| d344446406 | |||
| dd3d4c1aec | |||
| 9bdf1a73e7 | |||
| 68e6b5050b | |||
| a7c8d46589 | |||
| e5b5ecce93 | |||
| 35f8a166c6 | |||
| aa506d6566 | |||
| d93a28f18f | |||
| 55673b1675 | |||
| 13d61200fc | |||
| 520b99d50f | |||
| e898605f3a | |||
| e56eed0758 | |||
| c212207f74 | |||
| 40cb8b07a2 | |||
| 6be3409e3e | |||
| 3957349dc8 | |||
| 7483b10853 | |||
| 34c9cb4244 | |||
| 34396a3a8b | |||
| 7d7f2662dc | |||
| 1b2cbabc71 | |||
| 0e79763462 | |||
| a7fea2a0f5 | |||
| 47cd20f2ea | |||
| a8ee96211e | |||
| ea34d7578e | |||
| aec5914846 | |||
| f7e6ae7b87 | |||
| fb6a6b0c2f | |||
| 192f35d9fa | |||
| b164a46dd2 | |||
| c90c134d3a | |||
| efca3cbe69 | |||
| ada41e7078 | |||
| 03741aff79 | |||
| 7c579575b5 | |||
| 7b881d031c | |||
| 6e61c8b545 | |||
| 9ef7721527 | |||
| 8e43f66513 | |||
| dff19801b6 | |||
| b40e2c7280 | |||
| 9b4884359b | |||
| f873997d27 | |||
| 87bb94d4df | |||
| ce38fd257f | |||
| b44c7f860b | |||
| cce88ce3b0 | |||
| b16f7d6663 | |||
| 0cec6606c1 | |||
| 0a3748e564 | |||
| 6c50697a1a | |||
| b492d9f867 | |||
| 692b7fb9df | |||
| e4b1488e11 | |||
| 0291ab8194 | |||
| 29ac7765d2 | |||
| fb362e1fec | |||
| a0aeb07f03 | |||
| b128b4d11e | |||
| 850d3db811 | |||
| 91afbe54a2 | |||
| 227407993c | |||
| ab4637beed | |||
| 3368ac2947 | |||
| 8e5bf73edc | |||
| 801af37462 | |||
| 7766588f67 | |||
| 6e7854e8f6 | |||
| 829b88036a | |||
| 18ae6df115 | |||
| 866b75f644 | |||
| 3f40b18909 | |||
| 877912fe4b | |||
| 5fc907e51b | |||
| 833966afb2 | |||
| a9b85c0f3f | |||
| 284ec873bc | |||
| 9a601a66b4 | |||
| 852fe6ead0 | |||
| 9af9217903 | |||
| 4979470469 | |||
| 6d872f45aa | |||
| 74fef3e52c | |||
| db45b46f7b | |||
| 61949c1e1c | |||
| 713fc6ec1e | |||
| fea1b1dda1 | |||
| 0a628a76f1 | |||
| 90bfeb11f2 | |||
| eec2ee7fe3 | |||
| a5bf4bfdd4 | |||
| c9e6adf041 | |||
| ea44f21fcf | |||
| 03be4df6e2 | |||
| 9b9f8eae02 | |||
| 93afa1aebb | |||
| 406b311a8e | |||
| 904701f29f | |||
| 479a06f008 | |||
| a623702276 | |||
| 4a3ed77395 | |||
| c76c72966a | |||
| 22b76c5e1d | |||
| a15ef68387 | |||
| 52b772ea45 | |||
| 4b2839d753 | |||
| b2b43e58da | |||
| b6047af9fb | |||
| 5f42836bbf | |||
| 4e0929bbca | |||
| 9070b4827c | |||
| f9fb8630f9 | |||
| 45e45d1043 | |||
| be8d001747 | |||
| 8a6f76f039 | |||
| ce1b234b17 | |||
| a177dc61dd | |||
| ba43117a57 | |||
| 5d942f2c42 | |||
| d1a8824842 | |||
| 4b1f720107 | |||
| 4ee8d15cb8 | |||
| d270e9034f | |||
| 1568ae127a | |||
| a7557918ce | |||
| 585edeb984 | |||
| 8d1d1f18a8 | |||
| 91df7521ce | |||
| a13aba1d9f | |||
| 7d0afd0fcb | |||
| 0e28d07122 | |||
| 52d75171b2 | |||
| 7274ea19b7 | |||
| d9f87c61b6 | |||
| 8b32d99b5c | |||
| 2ad9606ad5 | |||
| 42d04afbb2 | |||
| bb425ee9d4 | |||
| f05ee25ff8 | |||
| 23d1fbfddb | |||
| c22d8271ae | |||
| 66a75e61b4 | |||
| 197d8bd463 | |||
| 53135b444f | |||
| 7ef4af6287 | |||
| 95794f7bbc | |||
| e99d70b017 | |||
| 39144d70ba | |||
| fab2b98096 | |||
| 62b204367c | |||
| d90b58ba22 | |||
| 542e85a067 | |||
| 859d6e9d24 | |||
| fd0428ee7d | |||
| 0bf983cec8 | |||
| 6a892d6549 | |||
| 0f82cb3084 | |||
| 9f2d7a507b | |||
| 8c8ff131f5 | |||
| eab8c3c709 | |||
| eda5d9a978 | |||
| bfb0243712 | |||
| 7d7f3cc758 | |||
| a55fc502ca | |||
| 76fe0dbde9 | |||
| 81f0cabe91 | |||
| fe0e7a09ce | |||
| 76d6e0c8ff | |||
| 691b1cee17 | |||
| 7e77af5a7d | |||
| 63a834b8ee | |||
| 2e26b71478 | |||
| ab9e5d6a8f | |||
| cf6c58d26e | |||
| dea45c5423 | |||
| 22556f65d3 | |||
| 8c8d472524 | |||
| 681fc33dd5 | |||
| 88ee6d6bb7 | |||
| a5d061afa6 | |||
| df02fd1166 | |||
| 7e37126f7c | |||
| 132096a596 | |||
| eb5ceba366 | |||
| afd42a5985 | |||
| 33698daa3f | |||
| b35b36738e | |||
| 1d88a09568 | |||
| d77e890f51 | |||
| a3b4dbf1c1 | |||
| b38d47a27d | |||
| cf2f77f35d | |||
| d94330b30f | |||
| 22b64d3d47 | |||
| f1d87638f3 | |||
| de3f1f3bb4 | |||
| e08b827861 | |||
| 3fa6932c99 | |||
| 74271081f4 | |||
| e50282178d | |||
| 48aa08fc94 | |||
| 6a9096d3aa | |||
| c6455103e6 | |||
| 8fd2ab4b51 | |||
| f0bc066134 | |||
| d03f84e78d | |||
| 7cc59f095c | |||
| 93c9cb7dfb | |||
| 8f0c6d2324 | |||
| b2ccd04844 | |||
| 7e83047fdb | |||
| b9b7b7052a | |||
| 518e7aa685 | |||
| 4e5797f1f4 | |||
| 260cc164a0 | |||
| e97891175a | |||
| 0572612833 | |||
| 2c45fa596a | |||
| 5bbc9fadfe | |||
| d22b48a801 | |||
| d35086f49d | |||
| e8c2cf1ea4 | |||
| ce9abf6306 | |||
| 99ecae0c44 | |||
| ce8ffabba7 | |||
| f8ef35c8e1 | |||
| 67ba1bdcce | |||
| 3c946559f5 | |||
| 7e01c42857 | |||
| be6b8bf27f | |||
| b5e11b6938 | |||
| db65e54c45 | |||
| 99fe7329f7 | |||
| 633a8cf715 | |||
| 931db7a242 | |||
| 7500cf07cb | |||
| a8395c9de7 | |||
| 27df93fa3d | |||
| 2f7ec3ab17 | |||
| 4361ba73ba | |||
| 00b9c1bb79 | |||
| bbf8f41507 | |||
| b430b5e638 | |||
| dfe2b3f017 | |||
| 12e12bfa28 | |||
| 7b2241ffeb | |||
| b7ea68a67c | |||
| 2d90d9cba5 | |||
| ab64101141 | |||
| 48d33275cb | |||
| fc0dc70bad | |||
| 1b86de7c26 | |||
| 3031893156 | |||
| 5a7d90289b | |||
| 00b5418fb0 | |||
| 4f8831c8fd | |||
| 773343d744 | |||
| 895444b90f | |||
| b0387ed40e | |||
| af4bb04fa4 | |||
| 890d8e49c7 | |||
| 02ab0b0c9d | |||
| 32d7fb0f45 | |||
| fef90aea5d | |||
| 5924204e01 | |||
| 1051fb1825 | |||
| c1b5dac784 | |||
| f457d0050e | |||
| 9ad853168c | |||
| 05d49ca470 | |||
| a6a1226ede | |||
| a666245c44 | |||
| 5e2af11b40 | |||
| 91293dbe92 | |||
| 30a6a99023 | |||
| 1e6213d558 | |||
| 9750f40b15 | |||
| 0084897fb5 | |||
| d7b99aef6f | |||
| e146177b59 | |||
| caab131398 | |||
| 2dfccfd779 | |||
| 6afbe44480 | |||
| 5b3798a283 | |||
| b5f1f1954e | |||
| 68a5a1e3c0 | |||
| e93eb40f4e | |||
| f553674b71 | |||
| 32ccb33fed | |||
| 352edd86ed | |||
| 5baf9515ea | |||
| c094818594 | |||
| 3a2db6bdd9 | |||
| 5d1ae91419 | |||
| 68083a2840 | |||
| ff14251371 | |||
| a98429c02a | |||
| 7fa99a5669 | |||
| cdd28b8b88 | |||
| a8e825ec13 | |||
| 630f925725 | |||
| 9827b46c49 | |||
| 828ca36d7f | |||
| 9be81222cc | |||
| 158fffaba0 | |||
| dd8f2a466c | |||
| d10bc19e8e | |||
| 8e06b7e149 | |||
| 37dd83c56d | |||
| cbb4b806be | |||
| 4a319b4378 | |||
| 1dbd73b30b | |||
| 7095d9ebec | |||
| 261d342fa1 | |||
| 108074feb0 | |||
| 7a18eb670a | |||
| 0be8c7952e | |||
| 3a4689e950 | |||
| 778bf77437 | |||
| 4b747c74ca | |||
| 2b03995e8a | |||
| da1a264d98 | |||
| a0ee132687 | |||
| 63da2f34c0 | |||
| 77e3fdd1f6 | |||
| 15655a531a | |||
| 9fe623cb9a | |||
| 8824638efb | |||
| 7bd6bcfb1b | |||
| 0f30c34463 | |||
| d5cf5fb4b2 |
@@ -0,0 +1,63 @@
|
||||
.git
|
||||
.github
|
||||
.gitignore
|
||||
.dockerignore
|
||||
README.md
|
||||
CONTRIBUTING.md
|
||||
LICENSE
|
||||
docs/
|
||||
data/
|
||||
cache/
|
||||
logs/
|
||||
verify-data/
|
||||
verify-cache/
|
||||
verify-media/
|
||||
verify-downloads/
|
||||
.codex-*
|
||||
.tmp/
|
||||
.tmp_*
|
||||
.tmp-deploy-*
|
||||
.tmp-deploy-data/
|
||||
.mmtl.pid
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
config.yaml
|
||||
config/*.yaml
|
||||
!config.example.yaml
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
*.p12
|
||||
*.pfx
|
||||
.jwt_secret
|
||||
*.secret
|
||||
secrets.*
|
||||
secret.*
|
||||
api_keys.*
|
||||
apikey.*
|
||||
tokens.*
|
||||
token.*
|
||||
password.*
|
||||
.idea/
|
||||
.vscode/
|
||||
.workbuddy/
|
||||
.dev-cache/
|
||||
.dev-data/
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
web/dist/
|
||||
**/dist/
|
||||
web/.vite/
|
||||
**/.vite/
|
||||
web/coverage/
|
||||
bin/
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*~
|
||||
@@ -0,0 +1,20 @@
|
||||
* text=auto
|
||||
|
||||
.gitattributes text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
|
||||
*.ps1 text eol=crlf
|
||||
|
||||
# GitHub Linguist: keep repository language stats focused on product code
|
||||
# (Go backend + React/TypeScript frontend + Docker packaging). Deployment
|
||||
# helpers, generated lock files, and static brand assets are still tracked but
|
||||
# should not appear as primary project languages.
|
||||
scripts/** linguist-vendored
|
||||
docker-entrypoint.sh linguist-vendored
|
||||
web/package-lock.json linguist-generated
|
||||
web/*.config.js linguist-vendored
|
||||
web/public/** linguist-vendored
|
||||
web/src/**/*.css linguist-vendored
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: Bug 反馈
|
||||
about: 报告可复现的问题、报错、功能异常或回归
|
||||
title: "[Bug] "
|
||||
labels: bug
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
## 问题现象
|
||||
|
||||
请描述实际发生了什么。
|
||||
|
||||
## 期望行为
|
||||
|
||||
请描述你认为正确结果应该是什么。
|
||||
|
||||
## 复现步骤
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## 部署方式
|
||||
|
||||
- 部署方式:Docker 第一档 / 第二档 / 第三档 / 裸机 / 其他
|
||||
- 镜像或版本:
|
||||
- NAS / 系统:
|
||||
- Docker 版本:
|
||||
- Docker Compose 版本:
|
||||
- 浏览器:
|
||||
|
||||
## 关键配置
|
||||
|
||||
请贴出相关配置片段,例如路径映射、媒体库路径、下载器保存路径、站点类型等。
|
||||
|
||||
请务必隐藏 Cookie、API Key、Passkey、密码、Token 和私有下载链接。
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml 相关片段
|
||||
```
|
||||
|
||||
## 日志 / 任务详情
|
||||
|
||||
请附上应用日志、任务队列详情、浏览器控制台错误或网络请求错误。
|
||||
|
||||
```text
|
||||
|
||||
```
|
||||
|
||||
## 补充信息
|
||||
|
||||
截图、录屏、相关 Issue、你已经尝试过的排查步骤。
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Telegram MMTL 交流群
|
||||
url: https://t.me/MMTL
|
||||
about: 适合快速交流部署经验、使用问题和排查线索。
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: 功能建议
|
||||
about: 提出新功能、体验改进或兼容性需求
|
||||
title: "[Feature] "
|
||||
labels: enhancement
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
## 需求背景
|
||||
|
||||
这个功能解决什么问题?当前使用流程哪里不方便?
|
||||
|
||||
## 期望方案
|
||||
|
||||
请描述你希望 MMTL 如何工作。
|
||||
|
||||
## 使用场景
|
||||
|
||||
- 部署环境:
|
||||
- 相关页面或模块:
|
||||
- 受影响用户:
|
||||
|
||||
## 可接受的替代方案
|
||||
|
||||
如果有其他实现方式或临时解决办法,也请写出来。
|
||||
|
||||
## 补充信息
|
||||
|
||||
截图、竞品参考、API 文档、相关讨论链接等。
|
||||
@@ -0,0 +1,4 @@
|
||||
name: MMTL CodeQL
|
||||
|
||||
paths-ignore:
|
||||
- internal/service/fileid_other.go
|
||||
@@ -0,0 +1,40 @@
|
||||
## 背景
|
||||
|
||||
说明这个 PR 解决的问题、关联 Issue 或用户场景。
|
||||
|
||||
> 请确认本 PR 基于本仓库最新 `main` 的独立分支或 fork 分支提交,未直接向 `main` 推送,也未夹带个人部署魔改配置。
|
||||
|
||||
## 改动摘要
|
||||
|
||||
-
|
||||
|
||||
## 验证
|
||||
|
||||
- [ ] `go test ./...`
|
||||
- [ ] `npm --prefix web run build`
|
||||
- [ ] `git diff --check`
|
||||
- [ ] 其他:
|
||||
|
||||
## 风险与兼容性
|
||||
|
||||
- 是否影响 Docker / NAS 路径映射:
|
||||
- 是否影响数据库迁移或数据结构:
|
||||
- 是否影响下载器、订阅、站点 API 或限流:
|
||||
- 是否包含敏感信息脱敏:
|
||||
|
||||
## 截图 / 日志
|
||||
|
||||
涉及 UI、任务队列、错误提示、设置页时请附截图或日志片段。
|
||||
|
||||
```text
|
||||
|
||||
```
|
||||
|
||||
## 提交前检查
|
||||
|
||||
- [ ] 分支已同步最新 `main`,不是直接在 `main` 上提交。
|
||||
- [ ] PR 范围聚焦,没有夹带无关重构。
|
||||
- [ ] 未提交个人部署配置、私有路径、API Key、Cookie、Token 或私有镜像标签。
|
||||
- [ ] 用户可见错误有清晰提示或日志。
|
||||
- [ ] 新行为有测试覆盖,或已说明无法覆盖的原因。
|
||||
- [ ] 文档、示例配置、README 已按需同步。
|
||||
@@ -0,0 +1,266 @@
|
||||
name: AuTo Docker Image
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
# 保留手动触发作为备选
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_type:
|
||||
description: '版本递增类型'
|
||||
required: true
|
||||
default: 'patch'
|
||||
type: choice
|
||||
options:
|
||||
- patch # 0.0.x
|
||||
- minor # 0.x.0
|
||||
- major # x.0.0
|
||||
|
||||
permissions:
|
||||
contents: write # 需要写入权限来更新版本文件
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
version-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
new_version: ${{ steps.bump_version.outputs.new_version }}
|
||||
tag: ${{ steps.bump_version.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # 获取完整历史以便版本计算
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# 1. 获取或初始化版本号
|
||||
- name: Get current version
|
||||
id: get_version
|
||||
run: |
|
||||
# 从文件读取版本号,或使用默认值
|
||||
if [ -f VERSION ]; then
|
||||
CURRENT_VERSION=$(cat VERSION)
|
||||
else
|
||||
CURRENT_VERSION="0.0.0"
|
||||
echo $CURRENT_VERSION > VERSION
|
||||
fi
|
||||
echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# 分离版本组成部分
|
||||
MAJOR=$(echo $CURRENT_VERSION | cut -d. -f1)
|
||||
MINOR=$(echo $CURRENT_VERSION | cut -d. -f2)
|
||||
PATCH=$(echo $CURRENT_VERSION | cut -d. -f3)
|
||||
echo "major=$MAJOR" >> $GITHUB_OUTPUT
|
||||
echo "minor=$MINOR" >> $GITHUB_OUTPUT
|
||||
echo "patch=$PATCH" >> $GITHUB_OUTPUT
|
||||
|
||||
# 2. 计算新版本号
|
||||
- name: Bump version
|
||||
id: bump_version
|
||||
run: |
|
||||
MAJOR=${{ steps.get_version.outputs.major }}
|
||||
MINOR=${{ steps.get_version.outputs.minor }}
|
||||
PATCH=${{ steps.get_version.outputs.patch }}
|
||||
|
||||
# 手动触发时根据选择递增
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
TYPE="${{ github.event.inputs.version_type }}"
|
||||
if [ "$TYPE" = "major" ]; then
|
||||
MAJOR=$((MAJOR + 1))
|
||||
MINOR=0
|
||||
PATCH=0
|
||||
elif [ "$TYPE" = "minor" ]; then
|
||||
MINOR=$((MINOR + 1))
|
||||
PATCH=0
|
||||
else # patch
|
||||
PATCH=$((PATCH + 1))
|
||||
fi
|
||||
else
|
||||
# 自动触发时默认 patch 递增
|
||||
PATCH=$((PATCH + 1))
|
||||
fi
|
||||
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=MMTL-v${NEW_VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "tag=mmtl-v${NEW_VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
# 3. 更新 VERSION 文件
|
||||
- name: Update version file
|
||||
run: |
|
||||
echo "${{ steps.bump_version.outputs.new_version }}" > VERSION
|
||||
|
||||
# 如果存在 go.mod,也更新其中的版本(可选)
|
||||
# if [ -f go.mod ]; then
|
||||
# sed -i "s/^version .*/version ${{ steps.bump_version.outputs.new_version }}/" go.mod
|
||||
# fi
|
||||
|
||||
# 4. 提交版本变更
|
||||
- name: Commit version bump
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add VERSION
|
||||
git commit -m "chore: bump version to ${{ steps.bump_version.outputs.new_version }} [skip ci]"
|
||||
git push
|
||||
|
||||
# 5. 创建 Git Tag
|
||||
- name: Create and push tag
|
||||
run: |
|
||||
TAG="${{ steps.bump_version.outputs.tag }}"
|
||||
git tag $TAG
|
||||
git push origin $TAG
|
||||
|
||||
# 6. 设置 Docker QEMU 和 Buildx
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
# 7. 登录 GHCR
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# 8. 提取镜像元数据
|
||||
- name: Extract image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/mmtl
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=raw,value=${{ steps.bump_version.outputs.tag }}
|
||||
type=raw,value=${{ steps.bump_version.outputs.new_version }}
|
||||
|
||||
# 9. 构建并推送
|
||||
- name: Build & push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
provenance: false
|
||||
sbom: false
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.bump_version.outputs.new_version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# 单文件可执行构建:把前端打包进二进制(go:embed),交叉编译 Windows /
|
||||
# Linux / macOS 的 amd64 / arm64 产物,作为 GitHub Release 附件发布。
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Install
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
- name: Build SPA
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
- name: Upload web/dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: web-dist
|
||||
path: web/dist
|
||||
retention-days: 1
|
||||
|
||||
# 先创建(幂等)空的 GitHub Release,供后续 build-binaries 并行上传附件,
|
||||
# 也避免矩阵各 job 并发 upload 时 release 尚不存在而互相竞争。
|
||||
publish-create-release:
|
||||
needs: [version-and-publish]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ needs.version-and-publish.outputs.tag }}
|
||||
run: |
|
||||
set -eux
|
||||
# tag 已由 version-and-publish 推送;若 release 已存在则忽略(--verify-tag 幂等)
|
||||
gh release create "$RELEASE_TAG" \
|
||||
--title "MMTL ${{ needs.version-and-publish.outputs.new_version }}" \
|
||||
--notes "自动化发布 ${{ needs.version-and-publish.outputs.new_version }}" \
|
||||
--verify-tag --latest || true
|
||||
|
||||
build-binaries:
|
||||
needs: [version-and-publish, build-frontend, publish-create-release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
ext: ""
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
ext: ""
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
ext: .exe
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
ext: .exe
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
ext: ""
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
ext: ""
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
cache: true
|
||||
- name: Download web/dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: web-dist
|
||||
path: web/dist
|
||||
- name: Build binary
|
||||
run: |
|
||||
CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \
|
||||
go build -trimpath -ldflags="-s -w -X main.version=${{ needs.version-and-publish.outputs.tag }}" \
|
||||
-o "dist/mmtl-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" ./cmd/server
|
||||
- name: Package
|
||||
run: |
|
||||
mkdir -p package/mmtl
|
||||
cp "dist/mmtl-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" package/mmtl/mmtl${{ matrix.ext }}
|
||||
cp README.md package/mmtl/ 2>/dev/null || true
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
(cd package && zip -r "../mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.zip" mmtl)
|
||||
else
|
||||
tar -czf "mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz" -C package mmtl
|
||||
fi
|
||||
- name: Upload to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ needs.version-and-publish.outputs.tag }}
|
||||
run: |
|
||||
set -eux
|
||||
PKG="mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.zip"
|
||||
TAR="mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz"
|
||||
# 并发上传到同一 release 各自文件,--clobber 幂等覆盖
|
||||
if [ -f "$PKG" ]; then
|
||||
for i in 1 2 3; do gh release upload "$RELEASE_TAG" "$PKG" --clobber && break || sleep 5; done
|
||||
fi
|
||||
if [ -f "$TAR" ]; then
|
||||
for i in 1 2 3; do gh release upload "$RELEASE_TAG" "$TAR" --clobber && break || sleep 5; done
|
||||
fi
|
||||
@@ -0,0 +1,133 @@
|
||||
# Beta 分支自动构建流水线
|
||||
#
|
||||
# 触发:push 到 beta 分支 / PR 到 beta / 手动触发。
|
||||
# 产出:
|
||||
# 1. 前端 + 后端编译验证(go vet / go test / go build)
|
||||
# 2. 多平台可执行二进制 artifact(linux/amd64、linux/arm64、windows/amd64)
|
||||
# 3. ghcr.io/{owner}/mmtl:beta 多架构 Docker 镜像(linux/amd64 + linux/arm64)
|
||||
#
|
||||
# 与 main 分支的发布流(Auto-docker-publish.yml)隔离:beta 不做版本递增、
|
||||
# 不打 release tag,只构建带 -beta 标识的产物供测试。
|
||||
|
||||
name: Beta Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [beta]
|
||||
pull_request:
|
||||
branches: [beta]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
BETA_VERSION_PREFIX: beta
|
||||
|
||||
jobs:
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 1) 编译验证 + 多平台二进制产物
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
test-and-build:
|
||||
name: Test & build artifacts
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve beta version
|
||||
id: version
|
||||
run: |
|
||||
BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
|
||||
SHA_SHORT=${GITHUB_SHA:0:7}
|
||||
echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The binary embeds the SPA (web/dist) via go:embed, so dist must exist
|
||||
# before the Go toolchain touches the web package.
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Build SPA
|
||||
working-directory: web
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
cache: true
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: go test
|
||||
run: go test ./...
|
||||
|
||||
- name: go build (host)
|
||||
run: go build ./...
|
||||
|
||||
# 多平台可执行文件(嵌入刚构建的 web/dist)
|
||||
- name: Build linux/amd64
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mmtl-beta-linux-amd64 ./cmd/server
|
||||
- name: Build linux/arm64
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mmtl-beta-linux-arm64 ./cmd/server
|
||||
- name: Build windows/amd64
|
||||
run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mmtl-beta-windows-amd64.exe ./cmd/server
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mmtl-beta-binaries
|
||||
path: dist/*
|
||||
if-no-files-found: error
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 2) Beta Docker 镜像(ghcr.io/{owner}/mmtl:beta)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
docker-beta:
|
||||
name: Build & push beta Docker image
|
||||
needs: test-and-build
|
||||
runs-on: ubuntu-latest
|
||||
# PR 事件不推送镜像,仅 push beta / 手动触发时推送
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve beta version
|
||||
id: version
|
||||
run: |
|
||||
BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
|
||||
SHA_SHORT=${GITHUB_SHA:0:7}
|
||||
echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build & push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
provenance: false
|
||||
sbom: false
|
||||
tags: ghcr.io/${{ github.repository_owner }}/mmtl:beta
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.source=${{ github.repository }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.version.outputs.full_version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -0,0 +1,84 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend (Go)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
cache: true
|
||||
|
||||
# The binary embeds the SPA (web/dist) via go:embed, so the dist must exist
|
||||
# before the Go toolchain touches the `web` package.
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Build SPA
|
||||
working-directory: web
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: go build
|
||||
run: go build ./...
|
||||
|
||||
- name: go test
|
||||
run: go test ./...
|
||||
|
||||
frontend:
|
||||
name: Frontend (Node)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Install
|
||||
run: npm ci
|
||||
|
||||
- name: Type-check & build
|
||||
run: npm run build
|
||||
|
||||
smoke:
|
||||
name: Deployment smoke test
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Validate single-image compose
|
||||
run: docker compose -f docker-compose.simple.yml config --quiet
|
||||
- name: Validate tier 1 compose
|
||||
run: docker compose -f docker-compose.yml config --quiet
|
||||
- name: Validate tier 2 compose
|
||||
run: docker compose -f docker-compose.standard.yml config --quiet
|
||||
- name: Validate tier 3 compose
|
||||
run: docker compose -f docker-compose.search.yml config --quiet
|
||||
ci-success:
|
||||
name: CI Success
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend, smoke]
|
||||
if: success()
|
||||
steps:
|
||||
- run: echo "CI passed, ready for release"
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Publish Docker image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Image tag to publish, for example MMTL-v0.0.32'
|
||||
required: true
|
||||
type: string
|
||||
ref:
|
||||
description: 'Git ref to build from'
|
||||
required: false
|
||||
default: main
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RELEASE_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.ref }}
|
||||
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
# 自动使用当前仓库所有者
|
||||
images: ghcr.io/${{ github.repository_owner }}/mmtl
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=raw,value=${{ env.RELEASE_VERSION }}
|
||||
|
||||
- name: Build & push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
provenance: false
|
||||
sbom: false
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ env.RELEASE_VERSION }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
# Binaries
|
||||
bin/
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# Go workspace
|
||||
go.work
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
|
||||
# Build artifacts
|
||||
web/dist/
|
||||
web/.vite/
|
||||
web/coverage/
|
||||
web/tsconfig.tsbuildinfo
|
||||
dist-release/
|
||||
|
||||
# Data / runtime
|
||||
data/
|
||||
cache/
|
||||
logs/
|
||||
.tmp-deploy-data/
|
||||
.tmp-deploy-smoke-data/
|
||||
.tmp-deploy-smoke-cache/
|
||||
.tmp-deploy-cache/
|
||||
.tmp-deploy-server.*
|
||||
.tmp-live-server.*
|
||||
.mmtl.pid
|
||||
*.log
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Editor / OS
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Local configs (keep examples)
|
||||
config/secrets.yaml
|
||||
config.yaml
|
||||
|
||||
# WorkBuddy workspace (local AI assistant memory)
|
||||
.workbuddy/
|
||||
|
||||
# Editor backups
|
||||
*~
|
||||
.tmp_*
|
||||
|
||||
# Runtime / local-only artifacts (清理补充)
|
||||
.tmp/
|
||||
.tmp-live-backups/
|
||||
.tmp-*
|
||||
.codex-*
|
||||
downloads/
|
||||
media/
|
||||
*.pid
|
||||
|
||||
# 本地开发运行产物
|
||||
.agents/
|
||||
.claude/
|
||||
.dev-cache/
|
||||
.dev-data/
|
||||
.dev-logs/
|
||||
tools/
|
||||
verify-cache/
|
||||
verify-data/
|
||||
.zcode/
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
# 贡献规范
|
||||
|
||||
感谢你愿意帮助 MMTL 变得更稳定。这个项目主要面向 NAS、Docker 部署、媒体库整理、订阅下载和多端播放场景;提交 Issue 或 Pull Request 时,请尽量提供可复现、可验证的信息。
|
||||
|
||||
## Issue 提交规范
|
||||
|
||||
提交 Issue 前,请先确认:
|
||||
|
||||
- 已搜索现有 Issues,避免重复提交同一个问题。
|
||||
- 使用的是最新镜像、最新主分支,或已说明当前版本号 / 镜像摘要。
|
||||
- 如果是部署或运行问题,已附上部署方式和关键配置。
|
||||
|
||||
### Bug Report 必填信息
|
||||
|
||||
- 问题现象:实际发生了什么,是否稳定复现。
|
||||
- 期望行为:你认为正确结果应该是什么。
|
||||
- 复现步骤:从哪个页面、点击什么、填写什么、触发什么任务。
|
||||
- 部署方式:Docker 第一档 / 第二档 / 第三档、裸机运行、反代方式等。
|
||||
- 环境信息:NAS 型号或系统、Docker / Compose 版本、浏览器、MMTL 镜像版本。
|
||||
- 相关配置:路径映射、下载器保存路径、媒体库路径、站点类型等。请隐藏 Cookie、API Key、密码和 Token。
|
||||
- 日志和任务信息:优先提供应用日志、任务队列详情、浏览器控制台错误、网络请求错误。
|
||||
|
||||
### 日志建议
|
||||
|
||||
排查订阅、站点搜索、下载器、整理入库、网盘扫描时,建议临时把日志级别调整为 `info` 或 `debug`,复现后再恢复。
|
||||
|
||||
Docker 部署常用命令:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs --tail=300 mmtl
|
||||
docker compose exec mmtl sh -lc 'ls -la /data/logs || true'
|
||||
```
|
||||
|
||||
PostgreSQL 部署查询示例:
|
||||
|
||||
```bash
|
||||
docker compose exec postgres psql -U mmtl -d mmtl -c "select key,value,updated_at from settings order by updated_at desc limit 30;"
|
||||
```
|
||||
|
||||
请勿公开粘贴以下敏感信息:
|
||||
|
||||
- 站点 Cookie、Passkey、API Key、YemaPT Auth Key、M-Team API Key。
|
||||
- qBittorrent / Transmission / Aria2 密码。
|
||||
- Telegram Bot Token。
|
||||
- JWT、数据库密码、私有下载链接。
|
||||
|
||||
## Pull Request 提交规范
|
||||
|
||||
所有非紧急变更都应通过 Pull Request 合入 `main`,不要直接向主分支推送。贡献者可以从 fork 或本仓库的独立分支发起 PR;维护者只有在紧急安全修复、发布流水线修复等特殊场景下,才可以短暂绕过 PR 流程,并需要在提交说明或后续 Issue 中补充原因。
|
||||
|
||||
PR 应该尽量小而清晰。一次 PR 聚焦一个问题或一组强相关改动,避免把无关重构、格式化和功能混在一起。
|
||||
|
||||
### 分支要求
|
||||
|
||||
- 分支从最新 `main` 创建,提交前先同步远端主分支。
|
||||
- 分支名建议使用 `fix/...`、`feat/...`、`docs/...` 或 `test/...`。
|
||||
- 不要在 `main` 上直接开发和提交 PR 内容。
|
||||
- 不要把个人部署配置、NAS 本地路径、私有镜像标签或测试数据提交进 PR。
|
||||
- 如果基于魔改版、私有部署版或临时补丁开发,请先确认改动能在本仓库最新 `main` 上复现和应用,再提交 PR。
|
||||
|
||||
### PR 描述应包含
|
||||
|
||||
- 背景:修复哪个 Issue / 哪个用户场景 / 哪个回归。
|
||||
- 改动摘要:后端、前端、配置、文档分别改了什么。
|
||||
- 验证结果:运行过哪些命令,是否有无法运行的测试。
|
||||
- 风险说明:数据迁移、Docker 配置、路径映射、下载器行为、站点 API 限流等是否受影响。
|
||||
- 截图或录屏:涉及 UI、任务队列、错误提示、设置页时请附上。
|
||||
|
||||
### 推荐验证命令
|
||||
|
||||
根据改动范围选择运行:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
npm --prefix web run build
|
||||
git diff --check
|
||||
```
|
||||
|
||||
如果只改动某个模块,可以先跑定向测试,例如:
|
||||
|
||||
```bash
|
||||
go test ./internal/service -run "TestOrganize|TestSubscription|TestMTeam" -count=1
|
||||
go test ./cmd/server -count=1
|
||||
```
|
||||
|
||||
### 代码要求
|
||||
|
||||
- Go 代码使用 `gofmt`。
|
||||
- 前端 TypeScript 需要通过 `npm --prefix web run build`。
|
||||
- 用户可见错误需要可操作:说明失败原因、下一步怎么查或怎么修。
|
||||
- 后台任务失败不要静默吞掉,应进入任务队列、日志或 API 响应。
|
||||
- Docker/NAS 路径相关改动必须考虑宿主机路径与容器路径映射。
|
||||
- 站点 API、订阅和下载器改动需要注意去重、限流、敏感信息脱敏。
|
||||
|
||||
## Commit Message 建议
|
||||
|
||||
优先使用简短清晰的动词开头:
|
||||
|
||||
```text
|
||||
fix organizer hardlink diagnostics
|
||||
feat(yemapt): add auth-only site adapter
|
||||
docs: add issue and pull request guidelines
|
||||
test: cover subscription restore matching
|
||||
```
|
||||
|
||||
## 维护者合并检查
|
||||
|
||||
合并前建议确认:
|
||||
|
||||
- PR 范围清楚,没有夹带无关改动。
|
||||
- 自动化检查通过,或失败原因已说明。
|
||||
- 修改涉及的用户路径已有日志、错误提示或回归测试。
|
||||
- 文档、示例配置和 README 是否需要同步更新。
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
# syntax=docker/dockerfile:1.6
|
||||
# =============================================================================
|
||||
# Multi-architecture build for MMTL.
|
||||
#
|
||||
# Stage 1 (frontend) : Node 20 -> static SPA bundle
|
||||
# Stage 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0)
|
||||
# Stage 3 (runtime) : Alpine 3.23 -> ffmpeg + tzdata + non-root user
|
||||
#
|
||||
# Build:
|
||||
# docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
# --build-arg VERSION=MMTL-v0.1.16 -t mmtl:latest --push .
|
||||
#
|
||||
# Optional Intel VAAPI/QSV runtime packages:
|
||||
# docker buildx build --build-arg WITH_VAAPI=true ...
|
||||
# =============================================================================
|
||||
|
||||
# ---- Stage 1: frontend (always build on the host architecture) -------------
|
||||
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend
|
||||
ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/
|
||||
WORKDIR /app/web
|
||||
COPY web/package*.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci --registry="${NPM_CONFIG_REGISTRY}"
|
||||
COPY web/ .
|
||||
RUN npm run build
|
||||
|
||||
# ---- Stage 2: backend (cross-compiled to TARGETPLATFORM) -------------------
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS backend
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG GOPROXY=https://proxy.golang.org,direct
|
||||
ARG VERSION=dev
|
||||
ENV GOPROXY=${GOPROXY}
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
COPY . .
|
||||
COPY --from=frontend /app/web/dist ./web/dist
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o mmtl ./cmd/server
|
||||
|
||||
# ---- Stage 3: runtime ------------------------------------------------------
|
||||
FROM alpine:3.23
|
||||
ARG WITH_VAAPI=false
|
||||
# Default runtime keeps only the packages needed by normal deployments.
|
||||
# VAAPI/mesa drivers pull a large graphics dependency tree, so they are opt-in
|
||||
# for users who explicitly build an Intel hardware-acceleration image.
|
||||
# NVENC requires the proprietary NVIDIA Container Toolkit on the host only.
|
||||
RUN apk add --no-cache \
|
||||
ffmpeg \
|
||||
docker-cli \
|
||||
tzdata \
|
||||
ca-certificates \
|
||||
su-exec \
|
||||
&& if [ "$WITH_VAAPI" = "true" ]; then \
|
||||
if [ "$(apk --print-arch)" = "x86_64" ]; then \
|
||||
apk add --no-cache intel-media-driver libva-utils mesa-va-gallium; \
|
||||
else \
|
||||
apk add --no-cache libva-utils mesa-va-gallium || true; \
|
||||
fi; \
|
||||
fi \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
# Non-root user for the long-running process.
|
||||
RUN addgroup -S mmtl && adduser -S mmtl -G mmtl
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=backend /app/mmtl /usr/local/bin/mmtl
|
||||
COPY --from=frontend /app/web/dist /app/web/dist
|
||||
|
||||
RUN mkdir -p /data /cache /media \
|
||||
&& chown -R mmtl:mmtl /data /cache /media
|
||||
|
||||
# Default environment (overridable via docker-compose / `docker run -e`).
|
||||
ENV MMTL_APP_PORT=8080 \
|
||||
MMTL_APP_DATA_DIR=/data \
|
||||
MMTL_APP_WEB_DIR=/app/web/dist \
|
||||
MMTL_DATABASE_DB_PATH=/data/mmtl.db \
|
||||
MMTL_CACHE_CACHE_DIR=/cache \
|
||||
MMTL_LOGGING_LEVEL=info \
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD busybox wget -q --spider http://127.0.0.1:8080/api/health || exit 1
|
||||
|
||||
# Tiny entrypoint that lets us run as a NAS host UID/GID via PUID/PGID without
|
||||
# rewriting /etc/passwd or /etc/group on every container start.
|
||||
COPY docker-entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
CMD ["/entrypoint.sh"]
|
||||
@@ -0,0 +1,9 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
This project is licensed under the GNU GPL v3.0.
|
||||
For the full license text, see https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
@@ -0,0 +1,253 @@
|
||||
# MMTL (My Movie and TV Library)
|
||||
|
||||
<p align="center">
|
||||
<img src="web/public/brand/logo-192.png" width="96" height="96" alt="MMTL Logo" />
|
||||
</p>
|
||||
|
||||
<h3 align="center">面向 NAS 与家庭影音场景的私人媒体中心</h3>
|
||||
|
||||
<p align="center">
|
||||
<strong>媒体库 · 刮削整理 · 网盘 STRM · Emby 协议 · 远程 Emby 挂载 · 多用户权限 · Docker 一键部署</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#项目简介">项目简介</a> ·
|
||||
<a href="#快速开始">快速开始</a> ·
|
||||
<a href="#部署档位">部署档位</a> ·
|
||||
<a href="#鸣谢">鸣谢</a> ·
|
||||
<a href="#开发构建">开发构建</a> ·
|
||||
<a href="README_EN.md">English</a> ·
|
||||
<a href="CONTRIBUTING.md">贡献规范</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go&logoColor=white" />
|
||||
<img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111827" />
|
||||
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
|
||||
<img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 项目简介
|
||||
|
||||
**MMTL** 是一个自托管私人媒体管理系统,适合 NAS、小主机、家庭共享和多端播放场景。本项目由 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) fork 并持续二开维护,在保留「一套服务覆盖网页、手机、电视与第三方播放器」思路的同时,围绕网盘播放、任务队列、远程挂载和权限体系做了大量增强。
|
||||
|
||||
你可以把 MMTL 理解为:
|
||||
|
||||
- 一个带现代 Web UI 的**媒体库后台**
|
||||
- 一个兼容 Emby/Jellyfin 客户端的**协议网关**
|
||||
- 一个连接本地硬盘、下载目录与网盘存储的**整理与播放入口**
|
||||
|
||||
### 核心能力
|
||||
|
||||
| 模块 | 说明 |
|
||||
| --- | --- |
|
||||
| **媒体库** | 电影、电视剧、动漫、综艺、音乐与自定义库;多根目录、扫库、海报墙、继续观看 |
|
||||
| **元数据刮削** | TMDb、Bangumi、Douban、TheTVDB、Fanart 等;支持 NFO、手动匹配、刮削队列 |
|
||||
| **播放** | 网页播放器、HLS 转码、弹幕、字幕、播放配置档、观看历史与收藏 |
|
||||
| **Emby 协议** | Infuse、SenPlayer、Fileball 等客户端可直接添加本服务,使用 MMTL 账号登录 |
|
||||
| **远程 Emby 挂载** | 将远程 Emby 媒体库挂载到本地界面统一浏览(无需单独开 Emby 客户端) |
|
||||
| **网盘与 STRM** | OpenList、CloudDrive2、115、WebDAV 等;STRM 同步、上传/下载队列、直链/302 播放 |
|
||||
| **下载与整理** | qBittorrent 接入、站点搜索与订阅、下载后自动整理、文件管理器(复制/移动/硬链/软链) |
|
||||
| **用户与权限** | 管理员/普通用户、有效期、成人内容开关、播放配置 PIN、细粒度操作权限 |
|
||||
| **运维能力** | 统一任务队列、回收站、存储统计、DLNA 投屏、系统设置与日志 |
|
||||
|
||||
### 技术栈
|
||||
|
||||
- **后端**:Go · Gin · GORM · SQLite / PostgreSQL · 可选 Redis · 可选 OpenSearch
|
||||
- **前端**:React 18 · Vite · TypeScript · Tailwind CSS · Zustand
|
||||
- **部署**:Docker Compose 多档模板,支持 amd64 / arm64 镜像与单文件可执行发布
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
推荐使用 Docker Compose。仓库提供四份**互相独立**的完整模板,无需 `.env` 即可起步。
|
||||
|
||||
```bash
|
||||
mkdir -p MMTL && cd MMTL
|
||||
|
||||
# 最省心:单镜像 + 内置 SQLite
|
||||
curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.simple.yml -o docker-compose.yml
|
||||
|
||||
# 或多用户场景:PostgreSQL 第一档
|
||||
# curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.yml -o docker-compose.yml
|
||||
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
浏览器访问:
|
||||
|
||||
```text
|
||||
http://服务器IP:18080
|
||||
```
|
||||
|
||||
默认账号:`admin` / `admin123`(首次登录后请立即修改密码)
|
||||
|
||||
镜像地址:
|
||||
|
||||
```text
|
||||
ghcr.io/truewhile/mmtl:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 部署档位
|
||||
|
||||
按机器资源选择档位。每份 Compose 文件均可单独使用,**不要**叠加多个 `-f`。
|
||||
|
||||
| 档位 | 配置文件 | 组件 | 适合场景 |
|
||||
| --- | --- | --- | --- |
|
||||
| 单镜像档 | `docker-compose.simple.yml` | MMTL + SQLite | 新手、单人、低配 NAS,只想一个容器跑起来 |
|
||||
| 第一档 | `docker-compose.yml` | MMTL + PostgreSQL | 大多数家庭 NAS,多用户更稳 |
|
||||
| 第二档 | `docker-compose.standard.yml` | + Redis | 多用户、Emby 客户端频繁刷新、首页/列表访问多 |
|
||||
| 第三档 | `docker-compose.search.yml` | + OpenSearch | 超大媒体库、复杂全文搜索(内存占用更高) |
|
||||
|
||||
### 单镜像档要点
|
||||
|
||||
- 只启动 **一个** MMTL 容器,数据在 `./data/mmtl.db`
|
||||
- 通常只需改端口与媒体目录挂载
|
||||
- **不要**设置 `MMTL_DATABASE_DSN`,否则会切到 PostgreSQL
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "18080:8080"
|
||||
volumes:
|
||||
- ./data:/data # 必须备份
|
||||
- ./cache:/cache # 可重建
|
||||
- ./media:/media # 改成你的媒体目录
|
||||
```
|
||||
|
||||
网页添加媒体库时填写容器内路径,例如 `/media`、`/media/电影`。
|
||||
|
||||
### PostgreSQL 档位要点
|
||||
|
||||
- 主库在 `./postgres`,配置与密钥在 `./data`
|
||||
- 若存在旧版 `./data/mmtl.db`,首次启动会自动迁移到 PostgreSQL
|
||||
- 迁移完成后可将 `MMTL_DATABASE_DB_PATH` 改为不存在路径,避免重复检查:
|
||||
|
||||
```yaml
|
||||
MMTL_DATABASE_DB_PATH: /data/no-sqlite-migration.db
|
||||
```
|
||||
|
||||
### 必须备份与可重建
|
||||
|
||||
| 路径 | 说明 |
|
||||
| --- | --- |
|
||||
| `./data` | JWT 密钥、运行配置、SQLite 主库或迁移源 |
|
||||
| `./postgres` | PostgreSQL 主库(PG 档位) |
|
||||
| `./cache` | 海报/转码缓存,可重建 |
|
||||
| `./redis` | 热缓存,可重建 |
|
||||
| `./opensearch` | 搜索索引,可重建 |
|
||||
|
||||
### 更新镜像
|
||||
|
||||
```bash
|
||||
docker compose pull mmtl
|
||||
docker compose up -d --no-deps mmtl
|
||||
```
|
||||
|
||||
日常更新只拉 `mmtl` 服务即可,不要随意 `docker compose pull` 升级 PostgreSQL/Redis/OpenSearch 基础镜像。
|
||||
|
||||
---
|
||||
|
||||
## 路径映射
|
||||
|
||||
Docker 部署最常见的问题是路径填错。记住:
|
||||
|
||||
- `volumes` **左侧**是宿主机真实路径,**右侧**是容器内路径
|
||||
- 网页后台添加媒体库时,应填写**容器内**路径(如 `/media/电影`)
|
||||
- 若使用自动整理/下载入库,`MMTL_MEDIA_DIR` 与 `MMTL_DOWNLOAD_DIR` 需与挂载一致
|
||||
|
||||
NAS 示例:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /vol1/1000/Media:/media
|
||||
- /vol1/1000/Downloads:/downloads
|
||||
environment:
|
||||
MMTL_MEDIA_DIR: /vol1/1000/Media
|
||||
MMTL_MEDIA_CONTAINER_DIR: /media
|
||||
MMTL_DOWNLOAD_DIR: /vol1/1000/Downloads
|
||||
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 首次使用建议
|
||||
|
||||
1. **创建媒体库** → 填写 `/media/...` → 执行扫库
|
||||
2. **配置元数据源** → 系统设置中添加 TMDb、Bangumi 等 API
|
||||
3. **(可选)连接 qBittorrent** → 下载客户端设置,宿主机可用 `http://host.docker.internal:8085`
|
||||
4. **(可选)配置网盘账号** → STRM 管理中添加 OpenList / 115 / WebDAV 等
|
||||
5. **第三方播放器** → 以 Emby 服务器添加 `http://服务器IP:18080`,使用 MMTL 账号登录
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
**扫库或入库很慢?**
|
||||
先确认路径映射与数据库档位。网盘扫描还受接口限速与目录规模影响;大库可考虑第二档 Redis 或第三档 OpenSearch。
|
||||
|
||||
**qBittorrent 下载后无法整理?**
|
||||
确认下载目录已通过 `volumes` 挂进容器,且 `MMTL_DOWNLOAD_*` 环境变量对应正确。
|
||||
|
||||
**硬链接失败(cross-device link)?**
|
||||
硬链接要求源与目标在同一文件系统/子卷;跨盘、跨 btrfs 子卷或网盘挂载时请改用复制或软链接。
|
||||
|
||||
**第三方播放器连不上?**
|
||||
确认地址为 `http://IP:18080`,使用 MMTL 用户账号;反代部署需正确配置外部 URL 与 HTTPS 头。
|
||||
|
||||
---
|
||||
|
||||
## 开发构建
|
||||
|
||||
后端通过 `go:embed` 嵌入 `web/dist`,**编译前必须先构建前端**。
|
||||
|
||||
```bash
|
||||
npm --prefix web ci
|
||||
npm --prefix web run build
|
||||
|
||||
go test ./...
|
||||
go run ./cmd/server # http://127.0.0.1:8080
|
||||
npm --prefix web run dev # http://127.0.0.1:3000
|
||||
```
|
||||
|
||||
CI 会在 Release 中提供 Windows / Linux / macOS 的 amd64、arm64 单文件可执行程序。
|
||||
|
||||
---
|
||||
|
||||
## 鸣谢
|
||||
|
||||
MMTL 在 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) 的基础上 fork 并持续演进。感谢上游项目在媒体库架构、Emby 协议兼容和自托管体验上的奠基工作。
|
||||
|
||||
项目中许多网盘同步、STRM 与媒体整理相关的设计与实现,也参考了 [qmediasync](https://github.com/qicfan/qmediasync)。感谢该项目的思路与实践经验。
|
||||
|
||||
---
|
||||
|
||||
## 贡献与反馈
|
||||
|
||||
提交 Issue 或 Pull Request 前,请阅读 [贡献规范](CONTRIBUTING.md) 与 [安全策略](SECURITY.md)。
|
||||
|
||||
- Bug 请附部署方式、复现步骤与相关日志
|
||||
- 功能建议请说明使用场景与期望行为
|
||||
- PR 请从独立分支发起,提交前运行 `go test ./...` 与 `npm --prefix web run build`
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=truewhile%2FMMTL&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=truewhile/MMTL&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=truewhile/MMTL&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=truewhile/MMTL&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 [GPL-3.0](LICENSE) 许可证。
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
# MMTL (My Movie and TV Library)
|
||||
|
||||
<p align="center">
|
||||
<img src="web/public/brand/logo-192.png" width="96" height="96" alt="MMTL Logo" />
|
||||
</p>
|
||||
|
||||
<h3 align="center">A self-hosted media center for NAS and home theater</h3>
|
||||
|
||||
<p align="center">
|
||||
<strong>Libraries · Metadata · Cloud STRM · Emby protocol · Remote Emby mounts · Multi-user · Docker-first</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">中文</a> ·
|
||||
<a href="#overview">Overview</a> ·
|
||||
<a href="#quick-start">Quick Start</a> ·
|
||||
<a href="#deployment-tiers">Deployment</a> ·
|
||||
<a href="#acknowledgements">Acknowledgements</a> ·
|
||||
<a href="#development">Development</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go&logoColor=white" />
|
||||
<img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111827" />
|
||||
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
|
||||
<img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
**MMTL** is a self-hosted private media management system for NAS, mini PCs, family sharing, and multi-device playback. This repository is a maintained fork of [MediaStationGo](https://github.com/ShukeBta/MediaStationGo), extended with stronger cloud playback, task queues, remote mounts, and permission controls.
|
||||
|
||||
In practice, MMTL gives you:
|
||||
|
||||
- A modern **web media library**
|
||||
- An **Emby/Jellyfin-compatible protocol gateway** for third-party players
|
||||
- A single panel for **local disks, download folders, and cloud storage**
|
||||
|
||||
### Key capabilities
|
||||
|
||||
| Area | Highlights |
|
||||
| --- | --- |
|
||||
| **Libraries** | Movies, TV, anime, variety, music, custom libraries; multi-root scanning; poster wall; continue watching |
|
||||
| **Metadata** | TMDb, Bangumi, Douban, TheTVDB, Fanart, NFO import, manual matching, scrape queue |
|
||||
| **Playback** | Web player, HLS transcoding, danmaku, subtitles, play profiles, history and favourites |
|
||||
| **Emby protocol** | Add MMTL in Infuse, SenPlayer, Fileball, etc. and sign in with MMTL accounts |
|
||||
| **Remote Emby mounts** | Browse remote Emby libraries inside MMTL without a separate Emby client |
|
||||
| **Cloud & STRM** | OpenList, CloudDrive2, 115, WebDAV; STRM sync; upload/download queues; direct or 302 playback |
|
||||
| **Downloads & organize** | qBittorrent, site search/subscriptions, post-download organization, file manager |
|
||||
| **Users & permissions** | Admin/regular users, expiry, NSFW toggle, play-profile PIN, granular permissions |
|
||||
| **Operations** | Unified task queue, recycle bin, storage stats, DLNA casting, settings and logs |
|
||||
|
||||
### Tech stack
|
||||
|
||||
- **Backend**: Go, Gin, GORM, SQLite or PostgreSQL, optional Redis and OpenSearch
|
||||
- **Frontend**: React 18, Vite, TypeScript, Tailwind CSS, Zustand
|
||||
- **Deployment**: Standalone Docker Compose templates, amd64/arm64 images, single-binary releases
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
Docker Compose is the recommended path. The repo ships four **standalone** templates; no `.env` is required.
|
||||
|
||||
```bash
|
||||
mkdir -p MMTL && cd MMTL
|
||||
|
||||
# Simplest: one container with built-in SQLite
|
||||
curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.simple.yml -o docker-compose.yml
|
||||
|
||||
# Or PostgreSQL tier for multi-user setups
|
||||
# curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.yml -o docker-compose.yml
|
||||
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
```text
|
||||
http://SERVER_IP:18080
|
||||
```
|
||||
|
||||
Default login: `admin` / `admin123` — change the password immediately.
|
||||
|
||||
Image:
|
||||
|
||||
```text
|
||||
ghcr.io/truewhile/mmtl:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment tiers
|
||||
|
||||
Pick one compose file. Do **not** stack multiple `-f` files.
|
||||
|
||||
| Tier | File | Stack | Best for |
|
||||
| --- | --- | --- | --- |
|
||||
| Single image | `docker-compose.simple.yml` | MMTL + SQLite | Beginners, single-user, low-resource NAS |
|
||||
| Tier 1 | `docker-compose.yml` | MMTL + PostgreSQL | Most home NAS deployments |
|
||||
| Tier 2 | `docker-compose.standard.yml` | + Redis | Multi-user, frequent Emby client refreshes |
|
||||
| Tier 3 | `docker-compose.search.yml` | + OpenSearch | Very large libraries, advanced full-text search |
|
||||
|
||||
### Single-image notes
|
||||
|
||||
- Only one MMTL container; database lives in `./data/mmtl.db`
|
||||
- Do **not** set `MMTL_DATABASE_DSN` or it switches to PostgreSQL
|
||||
- Back up `./data`; `./cache` can be rebuilt
|
||||
|
||||
### PostgreSQL notes
|
||||
|
||||
- Primary DB: `./postgres`; secrets and runtime files: `./data`
|
||||
- Existing `./data/mmtl.db` migrates automatically on first start
|
||||
- After migration, point `MMTL_DATABASE_DB_PATH` at a non-existent file to disable re-checks
|
||||
|
||||
### Backup
|
||||
|
||||
| Path | Notes |
|
||||
| --- | --- |
|
||||
| `./data` | JWT secret, config, SQLite DB or migration source |
|
||||
| `./postgres` | PostgreSQL primary DB |
|
||||
| `./cache`, `./redis`, `./opensearch` | Rebuildable |
|
||||
|
||||
### Update
|
||||
|
||||
```bash
|
||||
docker compose pull mmtl
|
||||
docker compose up -d --no-deps mmtl
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Path mapping
|
||||
|
||||
The most common Docker mistake is mixing host paths with container paths.
|
||||
|
||||
- Left side of `volumes` = real host/NAS path
|
||||
- Right side = container path; use `/media/...` in the web UI
|
||||
- Keep `MMTL_MEDIA_DIR` / `MMTL_DOWNLOAD_DIR` aligned with mounts when organizing or ingesting downloads
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /vol1/1000/Media:/media
|
||||
- /vol1/1000/Downloads:/downloads
|
||||
environment:
|
||||
MMTL_MEDIA_DIR: /vol1/1000/Media
|
||||
MMTL_MEDIA_CONTAINER_DIR: /media
|
||||
MMTL_DOWNLOAD_DIR: /vol1/1000/Downloads
|
||||
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## First-time setup
|
||||
|
||||
1. Create a library with a container path such as `/media/Movies`, then scan
|
||||
2. Add metadata providers (TMDb, Bangumi, etc.) in system settings
|
||||
3. Optionally connect qBittorrent (`http://host.docker.internal:8085` when qB runs on the host)
|
||||
4. Optionally configure cloud accounts under STRM management
|
||||
5. Add the server in Emby-compatible players at `http://SERVER_IP:18080` using MMTL credentials
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
**Library scan is slow**
|
||||
Check path mapping and DB tier. Cloud scans also depend on API limits and folder size.
|
||||
|
||||
**qBittorrent downloads are not organized**
|
||||
Ensure the download directory is mounted into the container and env vars match.
|
||||
|
||||
**Hardlink fails with cross-device link**
|
||||
Hardlinks require the same filesystem/subvolume; use copy or symlink across disks or cloud mounts.
|
||||
|
||||
**External player cannot connect**
|
||||
Use `http://IP:18080` and a MMTL user account; reverse proxies need correct external URL and HTTPS headers.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
The backend embeds `web/dist` via `go:embed`. Build the frontend first.
|
||||
|
||||
```bash
|
||||
npm --prefix web ci
|
||||
npm --prefix web run build
|
||||
|
||||
go test ./...
|
||||
go run ./cmd/server
|
||||
npm --prefix web run dev
|
||||
```
|
||||
|
||||
Release builds ship single-file binaries for Windows, Linux, and macOS on amd64 and arm64.
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
MMTL is forked from and continues to evolve [MediaStationGo](https://github.com/ShukeBta/MediaStationGo). Thank you to the upstream project for the media-library architecture, Emby-protocol compatibility, and self-hosted foundation.
|
||||
|
||||
Many cloud sync, STRM, and media-organization ideas in this project were also informed by [qmediasync](https://github.com/qicfan/qmediasync). Thank you for the reference implementation and design patterns.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md) before opening issues or pull requests.
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=truewhile%2FMMTL&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=truewhile/MMTL&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=truewhile/MMTL&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=truewhile/MMTL&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under [GPL-3.0](LICENSE).
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
# 安全策略
|
||||
|
||||
MMTL 是自托管媒体系统,常部署在 NAS、家庭网络、Docker、反向代理和第三方下载器环境中。安全问题通常会同时涉及应用代码、容器配置、路径映射、站点 Cookie / API Key、下载器凭据和外部访问入口。请按本策略报告和处理安全问题。
|
||||
|
||||
## 支持范围
|
||||
|
||||
我们优先支持以下版本和部署方式的安全修复:
|
||||
|
||||
- 当前 `main` 分支。
|
||||
- 最新发布镜像:`ghcr.io/shukebta/mmtl:latest`。
|
||||
- README 中推荐的 Docker Compose 第一档、第二档、第三档部署方式。
|
||||
|
||||
历史版本、私有魔改镜像、未公开补丁分支和非标准部署仍可报告,但维护者可能要求先在最新 `main` 或最新镜像中复现。
|
||||
|
||||
## 如何报告安全漏洞
|
||||
|
||||
请不要在公开 Issue、PR、讨论区或群聊中披露可利用细节。优先使用 GitHub Security Advisory 私密报告:
|
||||
|
||||
<https://github.com/ShukeBta/MMTL/security/advisories/new>
|
||||
|
||||
如果无法使用 GitHub 私密报告,可以先通过项目 README 中的社区入口联系维护者,说明“需要私下报告安全问题”,不要直接贴出利用细节、密钥或完整日志。
|
||||
|
||||
报告时请尽量提供:
|
||||
|
||||
- 影响范围:认证绕过、权限提升、敏感信息泄露、任意文件读写、命令执行、SSRF、路径穿越、下载器凭据泄露等。
|
||||
- 复现环境:部署方式、镜像版本或 commit、NAS / 系统、Docker / Compose 版本、是否有反向代理。
|
||||
- 复现步骤:最小可复现路径、请求、页面操作或配置条件。
|
||||
- 影响证明:截图、脱敏日志、请求响应、数据库字段名等。
|
||||
- 缓解建议:如果你已经验证过可行修复或临时规避方式,请一并说明。
|
||||
|
||||
请务必脱敏:
|
||||
|
||||
- 站点 Cookie、Passkey、API Key、YemaPT Auth Key、M-Team API Key。
|
||||
- qBittorrent / Transmission / Aria2 用户名密码。
|
||||
- Telegram Bot Token、JWT、数据库密码、反代访问 Token。
|
||||
- 私有下载链接、媒体库真实敏感路径、用户个人信息。
|
||||
|
||||
## 响应流程
|
||||
|
||||
维护者会尽力按以下节奏处理:
|
||||
|
||||
- 3 个工作日内确认收到报告。
|
||||
- 7 个工作日内给出初步影响判断、复现状态或需要补充的信息。
|
||||
- 高危问题优先修复,并在可行时提供临时缓解建议。
|
||||
- 修复发布后,再公开披露必要信息;公开内容会避免包含可直接滥用的细节。
|
||||
|
||||
如果问题需要更长时间修复,例如涉及数据迁移、权限模型、第三方站点 API 或下载器协议,我们会在私密报告中同步进展。
|
||||
|
||||
## 安全问题范围
|
||||
|
||||
欢迎报告:
|
||||
|
||||
- 未授权访问管理接口、媒体库、下载任务、站点配置或用户数据。
|
||||
- 普通用户越权执行管理员操作。
|
||||
- 读取或写入容器可访问范围外的文件。
|
||||
- 通过路径映射、整理入库、STRM、图片代理、字幕、备份恢复等功能触发路径穿越。
|
||||
- 泄露 Cookie、API Key、下载器密码、Telegram Token、JWT 或数据库凭据。
|
||||
- SSRF、任意重定向、反代信任边界错误。
|
||||
- Docker Compose 示例中可能导致默认暴露敏感服务的问题。
|
||||
- 日志中输出敏感信息或无法脱敏的问题。
|
||||
|
||||
通常不按安全漏洞处理:
|
||||
|
||||
- 需要管理员主动填写恶意配置才能触发、且不会突破管理员已有权限的问题。
|
||||
- 只影响个人私有魔改版、无法在最新主分支复现的问题。
|
||||
- 已经失效的依赖告警,且没有可达利用路径。
|
||||
- 没有安全影响的 UI 显示问题、普通功能 Bug 或性能问题。
|
||||
|
||||
## 自托管安全基线
|
||||
|
||||
部署 MMTL 时建议:
|
||||
|
||||
- 首次登录后立即修改默认 `admin / admin123`。
|
||||
- 不要把 PostgreSQL、Redis、OpenSearch、qBittorrent WebUI 暴露到公网。
|
||||
- 反向代理公网访问时启用 HTTPS,并限制管理后台访问来源。
|
||||
- 使用强随机的 JWT / 加密密钥,妥善备份 `./data` 和数据库。
|
||||
- 不要在 Issue、PR、截图或日志中公开站点 Cookie、API Key、Passkey、下载器密码。
|
||||
- Docker `volumes` 只挂载 MMTL 需要访问的目录,媒体库目录需要写入时再授予写权限。
|
||||
- 定期更新镜像,并在升级前备份 `./postgres` 和 `./data`。
|
||||
|
||||
## 安全修复 PR
|
||||
|
||||
安全修复 PR 请遵循 [贡献规范](CONTRIBUTING.md),但不要在公开 PR 中暴露可利用细节。必要时先通过私密安全报告确认修复方案,再提交脱敏后的补丁。
|
||||
@@ -0,0 +1,140 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
)
|
||||
|
||||
const defaultLogMaxSizeMB = 20
|
||||
|
||||
type rotatingFileWriter struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
maxSize int64
|
||||
maxBackups int
|
||||
maxAge time.Duration
|
||||
file *os.File
|
||||
size int64
|
||||
}
|
||||
|
||||
func newRotatingFileWriter(path string, cfg config.LoggingConfig) (*rotatingFileWriter, error) {
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("log path required")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return nil, fmt.Errorf("create log dir: %w", err)
|
||||
}
|
||||
maxSizeMB := cfg.MaxSizeMB
|
||||
if maxSizeMB <= 0 {
|
||||
maxSizeMB = defaultLogMaxSizeMB
|
||||
}
|
||||
w := &rotatingFileWriter{
|
||||
path: path,
|
||||
maxBackups: cfg.MaxBackups,
|
||||
}
|
||||
if cfg.EnableRotation {
|
||||
w.maxSize = int64(maxSizeMB) * 1024 * 1024
|
||||
}
|
||||
if w.maxBackups < 0 {
|
||||
w.maxBackups = 0
|
||||
}
|
||||
if cfg.MaxAgeDays > 0 {
|
||||
w.maxAge = time.Duration(cfg.MaxAgeDays) * 24 * time.Hour
|
||||
}
|
||||
if err := w.open(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (w *rotatingFileWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.file == nil {
|
||||
if err := w.open(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if w.maxSize > 0 && w.size > 0 && w.size+int64(len(p)) > w.maxSize {
|
||||
if err := w.rotate(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
n, err := w.file.Write(p)
|
||||
w.size += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (w *rotatingFileWriter) Sync() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.file == nil {
|
||||
return nil
|
||||
}
|
||||
return w.file.Sync()
|
||||
}
|
||||
|
||||
func (w *rotatingFileWriter) Close() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.file == nil {
|
||||
return nil
|
||||
}
|
||||
err := w.file.Close()
|
||||
w.file = nil
|
||||
w.size = 0
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *rotatingFileWriter) open() error {
|
||||
file, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o640)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open log file %s: %w", w.path, err)
|
||||
}
|
||||
w.file = file
|
||||
if stat, err := file.Stat(); err == nil {
|
||||
w.size = stat.Size()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *rotatingFileWriter) rotate() error {
|
||||
if w.file != nil {
|
||||
_ = w.file.Close()
|
||||
w.file = nil
|
||||
}
|
||||
if w.maxBackups == 0 {
|
||||
_ = os.Remove(w.path)
|
||||
return w.open()
|
||||
}
|
||||
for i := w.maxBackups - 1; i >= 1; i-- {
|
||||
oldPath := fmt.Sprintf("%s.%d", w.path, i)
|
||||
newPath := fmt.Sprintf("%s.%d", w.path, i+1)
|
||||
if _, err := os.Stat(oldPath); err == nil {
|
||||
_ = os.Rename(oldPath, newPath)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(w.path); err == nil {
|
||||
_ = os.Rename(w.path, fmt.Sprintf("%s.1", w.path))
|
||||
}
|
||||
w.pruneByAge()
|
||||
return w.open()
|
||||
}
|
||||
|
||||
func (w *rotatingFileWriter) pruneByAge() {
|
||||
if w.maxAge <= 0 {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().Add(-w.maxAge)
|
||||
for i := 1; i <= w.maxBackups; i++ {
|
||||
path := fmt.Sprintf("%s.%d", w.path, i)
|
||||
if stat, err := os.Stat(path); err == nil && stat.ModTime().Before(cutoff) {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
)
|
||||
|
||||
// newLogger 根据 cfg.Logging 构建 Zap。
|
||||
func newLogger(cfg *config.Config) (*zap.Logger, error) {
|
||||
log, _, err := newLoggerWithCloser(cfg)
|
||||
return log, err
|
||||
}
|
||||
|
||||
func newLoggerWithCloser(cfg *config.Config) (*zap.Logger, func(), error) {
|
||||
if cfg.App.Debug {
|
||||
log, err := zap.NewDevelopment()
|
||||
return log, func() {}, err
|
||||
}
|
||||
level := configuredLogLevel(cfg.Logging.Level)
|
||||
encoderCfg := zap.NewProductionEncoderConfig()
|
||||
encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||
var encoder zapcore.Encoder
|
||||
if strings.EqualFold(strings.TrimSpace(cfg.Logging.Format), "console") {
|
||||
encoder = zapcore.NewConsoleEncoder(encoderCfg)
|
||||
} else {
|
||||
encoder = zapcore.NewJSONEncoder(encoderCfg)
|
||||
}
|
||||
cores := []zapcore.Core{
|
||||
zapcore.NewCore(encoder, zapcore.Lock(os.Stdout), level),
|
||||
}
|
||||
var closers []func() error
|
||||
appPath, warnPath, errorPath := logFilePaths(cfg)
|
||||
if appPath != "" {
|
||||
appWriter, err := newRotatingFileWriter(appPath, cfg.Logging)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cores = append(cores, zapcore.NewCore(encoder, appWriter, level))
|
||||
closers = append(closers, appWriter.Close)
|
||||
}
|
||||
if warnPath != "" {
|
||||
warnWriter, err := newRotatingFileWriter(warnPath, cfg.Logging)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cores = append(cores, zapcore.NewCore(encoder, warnWriter, zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
|
||||
return lvl == zapcore.WarnLevel && level.Enabled(lvl)
|
||||
})))
|
||||
closers = append(closers, warnWriter.Close)
|
||||
}
|
||||
if errorPath != "" {
|
||||
errorWriter, err := newRotatingFileWriter(errorPath, cfg.Logging)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cores = append(cores, zapcore.NewCore(encoder, errorWriter, zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
|
||||
return lvl >= zapcore.ErrorLevel && level.Enabled(lvl)
|
||||
})))
|
||||
closers = append(closers, errorWriter.Close)
|
||||
}
|
||||
closeFn := func() {
|
||||
for _, c := range closers {
|
||||
_ = c()
|
||||
}
|
||||
}
|
||||
return zap.New(zapcore.NewTee(cores...), zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel), zap.ErrorOutput(zapcore.Lock(os.Stderr))), closeFn, nil
|
||||
}
|
||||
|
||||
func configuredLogLevel(raw string) zapcore.Level {
|
||||
level := zapcore.WarnLevel
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw != "" {
|
||||
var parsed zapcore.Level
|
||||
if err := parsed.UnmarshalText([]byte(raw)); err == nil {
|
||||
level = parsed
|
||||
}
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
func logFilePaths(cfg *config.Config) (string, string, string) {
|
||||
out := strings.TrimSpace(cfg.Logging.OutputPath)
|
||||
if strings.EqualFold(out, "stdout") || strings.EqualFold(out, "stderr") {
|
||||
return "", "", ""
|
||||
}
|
||||
if out == "" {
|
||||
out = filepath.Join(cfg.App.DataDir, "logs")
|
||||
}
|
||||
if ext := filepath.Ext(out); ext != "" {
|
||||
base := strings.TrimSuffix(out, ext)
|
||||
return out, base + ".warn" + ext, base + ".error" + ext
|
||||
}
|
||||
return filepath.Join(out, "app.log"), filepath.Join(out, "warn.log"), filepath.Join(out, "error.log")
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
)
|
||||
|
||||
func TestProductionLoggerWritesConfiguredInfoToAppLogAndSplitsWarnError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := &config.Config{}
|
||||
cfg.App.DataDir = dir
|
||||
cfg.Logging.Level = "info"
|
||||
cfg.Logging.Format = "json"
|
||||
cfg.Logging.OutputPath = filepath.Join(dir, "logs")
|
||||
cfg.Logging.EnableRotation = true
|
||||
cfg.Logging.MaxSizeMB = 1
|
||||
cfg.Logging.MaxBackups = 2
|
||||
|
||||
log, closeFn, err := newLoggerWithCloser(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer closeFn()
|
||||
log.Info("info should be stored")
|
||||
log.Warn("warning only", zap.String("kind", "warn"))
|
||||
log.Error("error only", zap.String("kind", "error"))
|
||||
_ = log.Sync()
|
||||
|
||||
appBytes, err := os.ReadFile(filepath.Join(dir, "logs", "app.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
warnBytes, err := os.ReadFile(filepath.Join(dir, "logs", "warn.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
errorBytes, err := os.ReadFile(filepath.Join(dir, "logs", "error.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appLog := string(appBytes)
|
||||
warnLog := string(warnBytes)
|
||||
errorLog := string(errorBytes)
|
||||
if !strings.Contains(appLog, "info should be stored") ||
|
||||
!strings.Contains(appLog, "warning only") ||
|
||||
!strings.Contains(appLog, "error only") {
|
||||
t.Fatalf("app log should contain all enabled levels: %s", appLog)
|
||||
}
|
||||
if strings.Contains(warnLog, "info should be stored") || strings.Contains(errorLog, "info should be stored") {
|
||||
t.Fatal("split warn/error logs should not contain info")
|
||||
}
|
||||
if !strings.Contains(warnLog, "warning only") || strings.Contains(warnLog, "error only") {
|
||||
t.Fatalf("warn log not isolated: %s", warnLog)
|
||||
}
|
||||
if !strings.Contains(errorLog, "error only") || strings.Contains(errorLog, "warning only") {
|
||||
t.Fatalf("error log not isolated: %s", errorLog)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionLoggerDefaultsToWarnInAppLog(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := &config.Config{}
|
||||
cfg.App.DataDir = dir
|
||||
cfg.Logging.Format = "json"
|
||||
cfg.Logging.OutputPath = filepath.Join(dir, "logs")
|
||||
cfg.Logging.EnableRotation = true
|
||||
|
||||
log, closeFn, err := newLoggerWithCloser(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer closeFn()
|
||||
log.Info("info should stay quiet by default")
|
||||
log.Warn("warning should be stored")
|
||||
_ = log.Sync()
|
||||
|
||||
appBytes, err := os.ReadFile(filepath.Join(dir, "logs", "app.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appLog := string(appBytes)
|
||||
if strings.Contains(appLog, "info should stay quiet by default") {
|
||||
t.Fatalf("default logger should not store info: %s", appLog)
|
||||
}
|
||||
if !strings.Contains(appLog, "warning should be stored") {
|
||||
t.Fatalf("default logger should store warn: %s", appLog)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotatingFileWriterCapsFileSize(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "app.log")
|
||||
writer, err := newRotatingFileWriter(path, config.LoggingConfig{
|
||||
EnableRotation: true,
|
||||
MaxSizeMB: 1,
|
||||
MaxBackups: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer writer.Close()
|
||||
chunk := strings.Repeat("x", 700*1024)
|
||||
if _, err := writer.Write([]byte(chunk)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := writer.Write([]byte(chunk)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(path + ".1"); err != nil {
|
||||
t.Fatalf("expected rotated backup: %v", err)
|
||||
}
|
||||
_ = writer.Sync()
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Package main is the MMTL HTTP server entry point.
|
||||
//
|
||||
// MMTL is a Go rewrite of the legacy Python implementation,
|
||||
// adopting the same tech stack as cropflre/nowen-video:
|
||||
//
|
||||
// Backend: Go 1.25 + Gin + GORM + PostgreSQL/SQLite + Viper + Zap + JWT
|
||||
// Frontend: React 18 + Vite + Tailwind + Zustand + HLS.js
|
||||
//
|
||||
// The binary embeds the SPA build artifacts at /app/web/dist and serves them
|
||||
// alongside the JSON REST API at /api/* and the WebSocket hub at /api/ws.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/database"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
// version is overwritten at build time via -ldflags="-X main.version=...".
|
||||
var version = "dev"
|
||||
|
||||
func effectiveVersion(buildVersion string) string {
|
||||
buildVersion = strings.TrimSpace(buildVersion)
|
||||
if buildVersion != "" && buildVersion != "dev" {
|
||||
return buildVersion
|
||||
}
|
||||
if envVersion := strings.TrimSpace(os.Getenv("MMTL_VERSION")); envVersion != "" {
|
||||
return envVersion
|
||||
}
|
||||
if buildVersion == "" {
|
||||
return "dev"
|
||||
}
|
||||
return buildVersion
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "config load failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger, err := newLogger(cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "logger init failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = logger.Sync() }()
|
||||
|
||||
appVersion := effectiveVersion(version)
|
||||
logger.Info("starting MMTL",
|
||||
zap.String("version", appVersion),
|
||||
zap.Int("port", cfg.App.Port),
|
||||
zap.String("data_dir", cfg.App.DataDir),
|
||||
)
|
||||
|
||||
// Ensure data / cache / web dirs exist.
|
||||
for _, d := range []string{cfg.App.DataDir, cfg.Cache.CacheDir} {
|
||||
if err := os.MkdirAll(d, 0o750); err != nil {
|
||||
logger.Fatal("create dir failed", zap.String("dir", d), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
db, err := database.Open(cfg, logger)
|
||||
if err != nil {
|
||||
logger.Fatal("database open failed", zap.Error(err))
|
||||
}
|
||||
if err := waitForDatabase(db, logger); err != nil {
|
||||
logger.Fatal("database not ready", zap.Error(err))
|
||||
}
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
logger.Fatal("auto-migrate failed", zap.Error(err))
|
||||
}
|
||||
if err := database.MigrateSQLiteToCurrentIfNeeded(cfg, db, logger); err != nil {
|
||||
logger.Fatal("sqlite to postgres migration failed", zap.Error(err))
|
||||
}
|
||||
|
||||
repos := repository.New(db)
|
||||
service.ApplyRuntimeSettings(context.Background(), cfg, repos, logger)
|
||||
applyCPUThreadLimit(cfg, logger)
|
||||
services := service.NewWithVersion(cfg, logger, repos, appVersion)
|
||||
|
||||
// 一次性清洗历史脏数据: 老版本把单集 episode id / 单集名写进整剧字段, 导致
|
||||
// 同一部剧被拆成多张单集卡。清空被污染的字段并重置为 pending(借后续重刮修正)。
|
||||
if cleaned, err := services.NormalizePollutedEpisodeMetadata(context.Background()); err != nil {
|
||||
logger.Warn("polluted episode metadata cleanup failed", zap.Error(err))
|
||||
} else if cleaned > 0 {
|
||||
logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned))
|
||||
}
|
||||
|
||||
if err := services.Auth.SeedAdmin(context.Background()); err != nil {
|
||||
logger.Warn("seed admin failed", zap.Error(err))
|
||||
}
|
||||
|
||||
router := buildRouter(cfg, logger, services)
|
||||
|
||||
serverMgr := newServerManager(cfg, logger, router)
|
||||
services.ReloadHTTPServer = serverMgr.Reload
|
||||
if err := serverMgr.Start(); err != nil {
|
||||
logger.Fatal("listen failed", zap.Error(err))
|
||||
}
|
||||
go func() {
|
||||
scheme := "http"
|
||||
if cfg.App.HTTPSEnabled {
|
||||
scheme = "https"
|
||||
}
|
||||
if publicIP := getPublicIP(3 * time.Second); publicIP != "" {
|
||||
logger.Info("server public endpoint",
|
||||
zap.String("public", fmt.Sprintf("%s://%s:%d", scheme, publicIP, cfg.App.Port)),
|
||||
)
|
||||
}
|
||||
}()
|
||||
go services.Boot()
|
||||
|
||||
// Graceful shutdown.
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-stop
|
||||
logger.Info("shutdown requested")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := serverMgr.Shutdown(ctx); err != nil {
|
||||
logger.Error("graceful shutdown failed", zap.Error(err))
|
||||
}
|
||||
services.Close()
|
||||
logger.Info("MMTL stopped")
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestEffectiveVersionPrefersBuildVersion(t *testing.T) {
|
||||
t.Setenv("MMTL_VERSION", "MMTL-v0.1.15")
|
||||
|
||||
if got := effectiveVersion("MMTL-v0.1.16"); got != "MMTL-v0.1.16" {
|
||||
t.Fatalf("effectiveVersion = %q, want MMTL-v0.1.16", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveVersionUsesEnvWhenBuildVersionIsDev(t *testing.T) {
|
||||
t.Setenv("MMTL_VERSION", " MMTL-v0.1.16 ")
|
||||
|
||||
if got := effectiveVersion("dev"); got != "MMTL-v0.1.16" {
|
||||
t.Fatalf("effectiveVersion = %q, want MMTL-v0.1.16", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveVersionDefaultsToDev(t *testing.T) {
|
||||
t.Setenv("MMTL_VERSION", "")
|
||||
|
||||
if got := effectiveVersion(""); got != "dev" {
|
||||
t.Fatalf("effectiveVersion = %q, want dev", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeSPANoCachesIndexAndServesRoutes(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
webDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(webDir, "assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(webDir, "index.html"), []byte("<html><div id=\"root\"></div></html>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(webDir, "assets", "app.js"), []byte("console.log('ok')"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(webDir, "favicon.svg"), []byte("<svg></svg>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
serveSPA(router, os.DirFS(webDir))
|
||||
|
||||
for _, path := range []string{"/", "/login", "/library/e1c3507e-2878-40ae-a0e1-6b6e44b7fa7a", "/media/abc"} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("%s status = %d, want 200", path, w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
|
||||
t.Fatalf("%s Cache-Control = %q, want no-store", path, got)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "root") {
|
||||
t.Fatalf("%s did not serve index.html: %q", path, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
webDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(webDir, "assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(webDir, "brand"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(webDir, "index.html"), []byte("index"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(webDir, "assets", "app.js"), []byte("console.log('ok')"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(webDir, "brand", "mmtl-logo.svg"), []byte("<svg></svg>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(webDir, "artwork-cache-sw.js"), []byte("self.addEventListener('fetch', () => {})"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
serveSPA(router, os.DirFS(webDir))
|
||||
|
||||
assetReq := httptest.NewRequest(http.MethodGet, "/assets/app.js", nil)
|
||||
assetResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(assetResp, assetReq)
|
||||
if assetResp.Code != http.StatusOK {
|
||||
t.Fatalf("asset status = %d, want 200", assetResp.Code)
|
||||
}
|
||||
if got := assetResp.Header().Get("Cache-Control"); !strings.Contains(got, "immutable") {
|
||||
t.Fatalf("asset Cache-Control = %q, want immutable", got)
|
||||
}
|
||||
|
||||
brandReq := httptest.NewRequest(http.MethodGet, "/brand/mmtl-logo.svg", nil)
|
||||
brandResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(brandResp, brandReq)
|
||||
if brandResp.Code != http.StatusOK {
|
||||
t.Fatalf("brand asset status = %d, want 200", brandResp.Code)
|
||||
}
|
||||
if got := brandResp.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
|
||||
t.Fatalf("brand asset Cache-Control = %q, want no-store", got)
|
||||
}
|
||||
if strings.Contains(brandResp.Body.String(), "index") {
|
||||
t.Fatalf("brand asset should not serve SPA index: %q", brandResp.Body.String())
|
||||
}
|
||||
|
||||
swReq := httptest.NewRequest(http.MethodGet, "/artwork-cache-sw.js", nil)
|
||||
swResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(swResp, swReq)
|
||||
if swResp.Code != http.StatusOK {
|
||||
t.Fatalf("service worker status = %d, want 200", swResp.Code)
|
||||
}
|
||||
if got := swResp.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
|
||||
t.Fatalf("service worker Cache-Control = %q, want no-store", got)
|
||||
}
|
||||
if strings.Contains(swResp.Body.String(), "index") {
|
||||
t.Fatalf("service worker should not serve SPA index: %q", swResp.Body.String())
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
"/api/missing",
|
||||
"/emby",
|
||||
"/emby/missing",
|
||||
"/Library/VirtualFolders",
|
||||
"/Startup/Configuration",
|
||||
"/QuickConnect/Enabled",
|
||||
"/embywebsocket",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("%s fallback status = %d, want 404", path, resp.Code)
|
||||
}
|
||||
if strings.Contains(resp.Body.String(), "index") {
|
||||
t.Fatalf("%s should not serve SPA index: %q", path, resp.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeSPAMissingIndexReportsExplicit404(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
serveSPA(router, os.DirFS(t.TempDir()))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "web UI not found") {
|
||||
t.Fatalf("body = %q, want explicit missing UI message", w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// getLocalIP returns the first non-loopback IPv4 address of the machine.
|
||||
// Falls back to "localhost" if no suitable interface is found.
|
||||
func getLocalIP() string {
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return "localhost"
|
||||
}
|
||||
for _, iface := range interfaces {
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
if ip := v.IP.To4(); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "localhost"
|
||||
}
|
||||
|
||||
// getPublicIP tries to detect the public-facing IP by querying ipify.org.
|
||||
// Returns empty string if detection fails (e.g. no internet, timeout).
|
||||
func getPublicIP(timeout time.Duration) string {
|
||||
client := &http.Client{Timeout: timeout}
|
||||
resp, err := client.Get("https://api.ipify.org")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return ""
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 64))
|
||||
if err != nil || len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/handler"
|
||||
"github.com/ShukeBta/MMTL/internal/middleware"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
|
||||
"github.com/ShukeBta/MMTL/web"
|
||||
)
|
||||
|
||||
func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container) *gin.Engine {
|
||||
if !cfg.App.Debug {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(middleware.RequestLogger(logger))
|
||||
if !cfg.App.Debug && len(cfg.App.CORSOrigins) == 0 {
|
||||
logger.Warn("CORS: no origins configured in production — CORS headers will be omitted (same-origin enforced). Set app.cors_origins for cross-origin access.")
|
||||
}
|
||||
r.Use(middleware.CORS(cfg.App.CORSOrigins, cfg.App.Debug))
|
||||
|
||||
handler.Register(r, cfg, logger, svc)
|
||||
|
||||
// Prefer a directory on disk when configured explicitly (e.g. the Docker image
|
||||
// mounts web/dist from the build stage, or an operator overrides app.web_dir
|
||||
// with a custom skin). Otherwise fall back to the SPA embedded into the binary,
|
||||
// which is what makes the cross-platform single-file artifacts work.
|
||||
uiFS := webui.DistFS()
|
||||
if dir := cfg.App.WebDir; dir != "" {
|
||||
disk := os.DirFS(dir)
|
||||
if index, err := fs.Stat(disk, "index.html"); err == nil && !index.IsDir() {
|
||||
uiFS = disk
|
||||
}
|
||||
}
|
||||
serveSPA(r, uiFS)
|
||||
return r
|
||||
}
|
||||
|
||||
// serveSPA serves the React build artifacts and falls back to index.html for
|
||||
// non-API, non-asset paths so client-side routing keeps working. The UI tree
|
||||
// comes from root, which is either the compiled-in SPA or an on-disk web dir.
|
||||
func serveSPA(r *gin.Engine, root fs.FS) {
|
||||
assets := r.Group("/assets")
|
||||
assets.Use(func(c *gin.Context) {
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
c.Next()
|
||||
})
|
||||
assets.GET("/*filepath", serveFSDir(root, "assets"))
|
||||
brand := r.Group("/brand")
|
||||
brand.Use(func(c *gin.Context) {
|
||||
setNoCacheHeaders(c)
|
||||
c.Next()
|
||||
})
|
||||
brand.GET("/*filepath", serveFSDir(root, "brand"))
|
||||
for _, rootFile := range []string{"/favicon.ico", "/favicon.svg", "/artwork-cache-sw.js"} {
|
||||
name := strings.TrimPrefix(rootFile, "/")
|
||||
r.GET(rootFile, serveFSFile(root, name))
|
||||
r.HEAD(rootFile, serveFSFile(root, name))
|
||||
}
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
if shouldBypassSPAFallback(path) {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
setNoCacheHeaders(c)
|
||||
data, err := fs.ReadFile(root, "index.html")
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "MMTL web UI not found")
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
|
||||
})
|
||||
}
|
||||
|
||||
// serveFSDir serves a static subdirectory of root. A missing asset returns 404.
|
||||
func serveFSDir(root fs.FS, dir string) gin.HandlerFunc {
|
||||
sub, err := fs.Sub(root, dir)
|
||||
if err != nil {
|
||||
return func(c *gin.Context) { c.Status(http.StatusNotFound) }
|
||||
}
|
||||
handler := http.StripPrefix("/"+dir, http.FileServerFS(sub))
|
||||
return func(c *gin.Context) {
|
||||
handler.ServeHTTP(c.Writer, c.Request)
|
||||
}
|
||||
}
|
||||
|
||||
// serveFSFile serves a single root-level file (favicon / service worker) with
|
||||
// no-cache headers. It reads from root, which may be the embedded SPA or disk.
|
||||
func serveFSFile(root fs.FS, name string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
setNoCacheHeaders(c)
|
||||
data, err := fs.ReadFile(root, name)
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, mimeTypeByName(name), data)
|
||||
}
|
||||
}
|
||||
|
||||
// mimeTypeByName returns an HTTP content type guessed from a file extension.
|
||||
func mimeTypeByName(name string) string {
|
||||
switch mime.TypeByExtension(filepath.Ext(name)) {
|
||||
case "":
|
||||
return "application/octet-stream"
|
||||
default:
|
||||
return mime.TypeByExtension(filepath.Ext(name))
|
||||
}
|
||||
}
|
||||
|
||||
func setNoCacheHeaders(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
c.Header("Pragma", "no-cache")
|
||||
c.Header("Expires", "0")
|
||||
}
|
||||
|
||||
func shouldBypassSPAFallback(path string) bool {
|
||||
if isFrontendLibraryRoute(path) {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(path)
|
||||
for _, exact := range []string{
|
||||
"/emby",
|
||||
} {
|
||||
if lower == exact {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, prefix := range []string{
|
||||
"/api/",
|
||||
"/emby/",
|
||||
"/system/",
|
||||
"/users/",
|
||||
"/items/",
|
||||
"/shows/",
|
||||
"/library/",
|
||||
"/videos/",
|
||||
"/sessions/",
|
||||
"/displaypreferences/",
|
||||
"/branding/",
|
||||
"/localization/",
|
||||
"/startup/",
|
||||
"/quickconnect/",
|
||||
"/socket",
|
||||
"/embywebsocket",
|
||||
} {
|
||||
if strings.HasPrefix(lower, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isFrontendLibraryRoute(path string) bool {
|
||||
const prefix = "/library/"
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return false
|
||||
}
|
||||
id := strings.TrimPrefix(path, prefix)
|
||||
if strings.Contains(id, "/") {
|
||||
return false
|
||||
}
|
||||
// 远程 Emby 挂载库的伪装 ID(embyremote~account~remote)也是前端库路由,
|
||||
// 需要交给 SPA 而非当作 Emby API 路径 404。
|
||||
if strings.HasPrefix(id, "embyremote~") {
|
||||
return true
|
||||
}
|
||||
if len(id) != 36 {
|
||||
return false
|
||||
}
|
||||
for i, ch := range id {
|
||||
switch i {
|
||||
case 8, 13, 18, 23:
|
||||
if ch != '-' {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
// tlsPair 记录当前正在服务的证书,用于判断是否需要重新绑定监听。
|
||||
type tlsPair struct {
|
||||
cert tls.Certificate
|
||||
certPEM string
|
||||
keyPEM string
|
||||
// version 是解析后的证书/私钥指纹;内容或磁盘文件变化都会导致其改变,
|
||||
// 据此决定是否需要重新绑定监听。
|
||||
version string
|
||||
}
|
||||
|
||||
// serverManager 负责 MMTL 的 HTTP/HTTPS 监听。HTTPS 设置保存后调用 Reload,
|
||||
// 在同一个端口上把明文 HTTP 与 TLS 监听热切换,无需重启进程:
|
||||
//
|
||||
// - 关闭旧监听释放端口(同一进程内 Windows 不允许重复绑定同一端口);
|
||||
// - 按最新配置重新绑定并立即对外服务;
|
||||
// - 旧服务器随后优雅退出,正在进行的播放/请求不会被立刻掐断。
|
||||
//
|
||||
// 任何校验失败都会中止切换并保留旧监听,保证用户不会被锁在服务外面。
|
||||
type serverManager struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
handler http.Handler
|
||||
addr string
|
||||
|
||||
mu sync.Mutex
|
||||
srv *http.Server
|
||||
ln net.Listener
|
||||
pair *tlsPair
|
||||
stopCh chan struct{}
|
||||
autoReloadStarted bool
|
||||
}
|
||||
|
||||
func newServerManager(cfg *config.Config, log *zap.Logger, handler http.Handler) *serverManager {
|
||||
return &serverManager{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
handler: handler,
|
||||
addr: fmt.Sprintf(":%d", cfg.App.Port),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动监听。即使 HTTPS 配置损坏也退回明文 HTTP 继续启动,避免服务冷启动失败。
|
||||
func (m *serverManager) Start() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
pair, err := m.desiredPair()
|
||||
if err != nil {
|
||||
m.log.Error("invalid HTTPS config at startup, serving plain HTTP instead", zap.Error(err))
|
||||
pair = nil
|
||||
}
|
||||
if err := m.bind(pair); err != nil {
|
||||
return err
|
||||
}
|
||||
m.logServerReady()
|
||||
m.maybeStartAutoReloadLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload 依据最新配置热切换监听。返回的错误会带给调用它的设置接口;若新监听
|
||||
// 绑定失败会自动回滚到旧配置继续服务。
|
||||
func (m *serverManager) Reload() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
pair, err := m.desiredPair()
|
||||
if err != nil {
|
||||
m.log.Error("server reload aborted", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
if m.pairEquals(pair) {
|
||||
return nil
|
||||
}
|
||||
|
||||
oldSrv, oldLn, oldPair := m.srv, m.ln, m.pair
|
||||
if oldLn != nil {
|
||||
_ = oldLn.Close() // 释放端口后再绑定新监听
|
||||
}
|
||||
m.srv, m.ln, m.pair = nil, nil, nil
|
||||
|
||||
firstErr := m.bind(pair)
|
||||
if firstErr != nil {
|
||||
m.log.Error("bind new listener failed, rolling back to previous", zap.Error(firstErr))
|
||||
if rbErr := m.bind(oldPair); rbErr != nil {
|
||||
return fmt.Errorf("reload failed: %v; rollback failed: %v", firstErr, rbErr)
|
||||
}
|
||||
}
|
||||
// 新监听已就绪,让旧服务器在新连接切换到新监听后优雅退出。
|
||||
m.drain(oldSrv)
|
||||
m.logServerReady()
|
||||
m.maybeStartAutoReloadLocked()
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// Shutdown 优雅停止当前服务器(用于进程退出)。
|
||||
func (m *serverManager) Shutdown(ctx context.Context) error {
|
||||
select {
|
||||
case <-m.stopCh:
|
||||
default:
|
||||
close(m.stopCh)
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.srv == nil {
|
||||
return nil
|
||||
}
|
||||
return m.srv.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// desiredPair 根据当前配置计算目标监听形态:nil 表示明文 HTTP,非 nil 表示 TLS。
|
||||
// 证书/私钥按"路径优先、内容兜底"解析,并校验是否匹配。
|
||||
func (m *serverManager) desiredPair() (*tlsPair, error) {
|
||||
if m.cfg == nil || !m.cfg.App.HTTPSEnabled {
|
||||
return nil, nil
|
||||
}
|
||||
certPEM, err := service.ResolveSSLMaterial(m.cfg.App.SSLCert, m.cfg.App.SSLCertPath, "证书")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyPEM, err := service.ResolveSSLMaterial(m.cfg.App.SSLKey, m.cfg.App.SSLKeyPath, "私钥")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := service.ValidateSSLKeyPair(certPEM, keyPEM); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSL 证书/私钥无效:%v", err)
|
||||
}
|
||||
return &tlsPair{
|
||||
cert: cert,
|
||||
certPEM: certPEM,
|
||||
keyPEM: keyPEM,
|
||||
version: certPEM + "\x00" + keyPEM,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// maybeStartAutoReloadLocked 在证书/私钥通过文件路径配置时,幂等地启动后台轮询,
|
||||
// 便于运行中切换到路径方式(或换证)后无需重启也能热更新。调用方需持有 m.mu。
|
||||
func (m *serverManager) maybeStartAutoReloadLocked() {
|
||||
if m.autoReloadStarted {
|
||||
return
|
||||
}
|
||||
if !m.pathBased() {
|
||||
return
|
||||
}
|
||||
m.autoReloadStarted = true
|
||||
m.startAutoReload()
|
||||
}
|
||||
|
||||
// pathBased 是否至少有一侧证书/私钥通过文件路径配置。
|
||||
func (m *serverManager) pathBased() bool {
|
||||
return strings.TrimSpace(m.cfg.App.SSLCertPath) != "" || strings.TrimSpace(m.cfg.App.SSLKeyPath) != ""
|
||||
}
|
||||
|
||||
// startAutoReload 后台轮询文件变更并自动热更新,方便换证。
|
||||
func (m *serverManager) startAutoReload() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
go func() {
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-m.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if !m.pathBased() {
|
||||
continue // 路径已清空(改回内容配置),不再轮询
|
||||
}
|
||||
if err := m.Reload(); err != nil {
|
||||
m.log.Warn("periodic https reload failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// pairEquals 判断目标配置与当前监听是否一致,一致则无需重新绑定。
|
||||
func (m *serverManager) pairEquals(pair *tlsPair) bool {
|
||||
if pair == nil && m.pair == nil {
|
||||
return true
|
||||
}
|
||||
if pair == nil || m.pair == nil {
|
||||
return false
|
||||
}
|
||||
return pair.version == m.pair.version
|
||||
}
|
||||
|
||||
// bind 创建并按需启用 TLS 的监听,异步开始服务。
|
||||
func (m *serverManager) bind(pair *tlsPair) error {
|
||||
ln, err := net.Listen("tcp", m.addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen %s: %w", m.addr, err)
|
||||
}
|
||||
srv := &http.Server{
|
||||
Handler: m.handler,
|
||||
ReadHeaderTimeout: 15 * time.Second,
|
||||
}
|
||||
if pair != nil {
|
||||
ln = tls.NewListener(ln, &tls.Config{
|
||||
Certificates: []tls.Certificate{pair.cert},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
})
|
||||
}
|
||||
m.srv, m.ln, m.pair = srv, ln, pair
|
||||
go m.serve(srv, ln)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *serverManager) serve(s *http.Server, ln net.Listener) {
|
||||
if err := s.Serve(ln); err != nil &&
|
||||
!errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
|
||||
m.log.Fatal("listen failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// drain 让旧服务器在后台优雅退出(等待进行中的连接完成或在超时后强制关闭)。
|
||||
func (m *serverManager) drain(s *http.Server) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
go func(s *http.Server) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := s.Shutdown(ctx); err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
m.log.Warn("drain old server failed", zap.Error(err))
|
||||
}
|
||||
}(s)
|
||||
}
|
||||
|
||||
func (m *serverManager) logServerReady() {
|
||||
scheme := "http"
|
||||
if m.pair != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
localIP := getLocalIP()
|
||||
m.log.Info("server is ready",
|
||||
zap.String("scheme", scheme),
|
||||
zap.String("local", fmt.Sprintf("%s://%s:%d", scheme, localIP, m.cfg.App.Port)),
|
||||
zap.String("listen", m.addr),
|
||||
)
|
||||
if m.pair != nil {
|
||||
m.log.Info("HTTPS is enabled; plain HTTP is no longer served on this port",
|
||||
zap.String("addr", m.addr),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
)
|
||||
|
||||
func makeTestPairPEM(t *testing.T) (certPEM, keyPEM string) {
|
||||
t.Helper()
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "localhost"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
DNSNames: []string{"localhost"},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certPEM = strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})))
|
||||
keyPEM = strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})))
|
||||
return certPEM, keyPEM
|
||||
}
|
||||
|
||||
func newTestServerManager(t *testing.T) *serverManager {
|
||||
t.Helper()
|
||||
cfg := &config.Config{}
|
||||
cfg.App.Port = 18081
|
||||
return newServerManager(cfg, zap.NewNop(), http.NewServeMux())
|
||||
}
|
||||
|
||||
func TestDesiredPairModes(t *testing.T) {
|
||||
m := newTestServerManager(t)
|
||||
|
||||
if p, err := m.desiredPair(); err != nil || p != nil {
|
||||
t.Fatalf("disabled should be nil pair, got p=%v err=%v", p, err)
|
||||
}
|
||||
|
||||
certPEM, keyPEM := makeTestPairPEM(t)
|
||||
m.cfg.App.HTTPSEnabled = true
|
||||
m.cfg.App.SSLCert, m.cfg.App.SSLKey = certPEM, keyPEM
|
||||
p, err := m.desiredPair()
|
||||
if err != nil || p == nil || p.version == "" {
|
||||
t.Fatalf("content pair failed: p=%v err=%v", p, err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
certPath, keyPath := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
|
||||
if err := os.WriteFile(certPath, []byte(certPEM), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, []byte(keyPEM), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.cfg.App.SSLCert, m.cfg.App.SSLKey = "", ""
|
||||
m.cfg.App.SSLCertPath, m.cfg.App.SSLKeyPath = certPath, keyPath
|
||||
p2, err := m.desiredPair()
|
||||
if err != nil || p2 == nil {
|
||||
t.Fatalf("path pair failed: %v", err)
|
||||
}
|
||||
|
||||
m.cfg.App.SSLKeyPath = filepath.Join(dir, "nope.pem")
|
||||
if _, err := m.desiredPair(); err == nil {
|
||||
t.Fatal("expected error when key file missing")
|
||||
}
|
||||
m.cfg.App.SSLKeyPath = keyPath
|
||||
|
||||
// 替换文件(换一套新的有效证书)后版本号应变化,触发热更新。
|
||||
newCert, newKey := makeTestPairPEM(t)
|
||||
if err := os.WriteFile(certPath, []byte(newCert), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, []byte(newKey), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p3, err := m.desiredPair()
|
||||
if err != nil {
|
||||
t.Fatalf("replace: %v", err)
|
||||
}
|
||||
if p3.version == p2.version {
|
||||
t.Fatal("version should change after files replaced")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
)
|
||||
|
||||
func applyCPUThreadLimit(cfg *config.Config, logger *zap.Logger) {
|
||||
if cfg == nil || cfg.App.MaxCPUThreads < 1 {
|
||||
return
|
||||
}
|
||||
prev := runtime.GOMAXPROCS(cfg.App.MaxCPUThreads)
|
||||
if logger != nil {
|
||||
logger.Info("runtime CPU thread limit applied",
|
||||
zap.Int("max_cpu_threads", cfg.App.MaxCPUThreads),
|
||||
zap.Int("previous", prev))
|
||||
}
|
||||
}
|
||||
|
||||
func waitForDatabase(db interface{ DB() (*sql.DB, error) }, logger *zap.Logger) error {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= 30; attempt++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
err = sqlDB.PingContext(ctx)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
if logger != nil {
|
||||
logger.Warn("database not ready; retrying", zap.Int("attempt", attempt), zap.Error(err))
|
||||
}
|
||||
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
# MMTL 第三档完整 Docker Compose 部署文件
|
||||
#
|
||||
# 组件:
|
||||
# MMTL + PostgreSQL + Redis + OpenSearch
|
||||
#
|
||||
# 使用方式二选一:
|
||||
# 1. 保存为 docker-compose.yml 后执行:
|
||||
# docker compose up -d
|
||||
# 2. 保留本文件名时执行:
|
||||
# docker compose -f docker-compose.search.yml up -d
|
||||
#
|
||||
# 适合:
|
||||
# 超大媒体库、复杂全文搜索、后续需要独立搜索索引的部署。
|
||||
#
|
||||
# 注意:
|
||||
# OpenSearch 常驻内存明显高于 Redis/PostgreSQL。低配 NAS 不建议开启。
|
||||
#
|
||||
# 默认账号:
|
||||
# admin / admin123
|
||||
|
||||
services:
|
||||
mmtl:
|
||||
# 镜像二选一:
|
||||
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
|
||||
image: ghcr.io/shukebta/mmtl:latest
|
||||
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
|
||||
# image: shukbet/mmtl:latest
|
||||
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
opensearch:
|
||||
condition: service_healthy
|
||||
|
||||
ports:
|
||||
- "18080:8080"
|
||||
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
volumes:
|
||||
# 程序运行数据:JWT 密钥、运行配置、旧 SQLite 迁移源。
|
||||
- ./data:/data
|
||||
|
||||
# 缓存目录:海报缓存、临时文件等。通常不用备份。
|
||||
- ./cache:/cache
|
||||
|
||||
# 媒体库目录。自动整理/重命名/入库需要读写权限。
|
||||
# NAS 示例:source: /vol1/1000/Media
|
||||
# Windows Docker Desktop 示例:source: D:/Media
|
||||
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
|
||||
- type: bind
|
||||
source: ./media
|
||||
target: /media
|
||||
bind:
|
||||
create_host_path: false
|
||||
|
||||
# 下载目录。需要和 qBittorrent 保存路径保持一致。
|
||||
# NAS 示例:source: /vol1/1000/Downloads
|
||||
# Windows Docker Desktop 示例:source: D:/Downloads
|
||||
- type: bind
|
||||
source: ./downloads
|
||||
target: /downloads
|
||||
bind:
|
||||
create_host_path: false
|
||||
|
||||
# 管理面板「系统更新」需要访问 Docker 引擎。
|
||||
# 需要一键更新 Docker 镜像时取消下一行注释。
|
||||
# - /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
|
||||
PUID: "1000"
|
||||
PGID: "1000"
|
||||
|
||||
MMTL_APP_HOST: 0.0.0.0
|
||||
MMTL_APP_PORT: 8080
|
||||
MMTL_APP_WEB_DIR: /app/web/dist
|
||||
MMTL_APP_DATA_DIR: /data
|
||||
MMTL_LOGGING_LEVEL: info
|
||||
MMTL_LOGGING_FORMAT: console
|
||||
MMTL_LOGGING_OUTPUT_PATH: /data/logs
|
||||
MMTL_LOGGING_MAX_SIZE_MB: "50"
|
||||
MMTL_LOGGING_MAX_BACKUPS: "20"
|
||||
MMTL_LOGGING_MAX_AGE_DAYS: "30"
|
||||
|
||||
MMTL_DATABASE_TYPE: postgres
|
||||
MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable
|
||||
MMTL_DATABASE_DB_PATH: /data/mmtl.db
|
||||
|
||||
MMTL_CACHE_REDIS_URL: redis://redis:6379/0
|
||||
MMTL_CACHE_CACHE_DIR: /cache
|
||||
|
||||
# OpenSearch 只做搜索索引,主数据仍以 PostgreSQL 为准。
|
||||
MMTL_SEARCH_BACKEND: opensearch
|
||||
MMTL_SEARCH_OPENSEARCH_URL: http://opensearch:9200
|
||||
MMTL_SEARCH_INDEX: mmtl_media
|
||||
|
||||
MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest
|
||||
|
||||
# 默认推荐在网页里使用容器路径 /media。
|
||||
# 如果旧媒体库已经保存了宿主机路径 /vol1/1000/Media,
|
||||
# 再把这里改成同一个宿主机真实路径用于旧路径换算。
|
||||
MMTL_MEDIA_DIR: /media
|
||||
MMTL_MEDIA_CONTAINER_DIR: /media
|
||||
MMTL_DOWNLOAD_DIR: /downloads
|
||||
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
|
||||
|
||||
MMTL_TRANSCODER_ENABLED: "true"
|
||||
MMTL_TRANSCODER_HARDWARE_ACCEL: "false"
|
||||
MMTL_TRANSCODER_REALTIME: "true"
|
||||
MMTL_TRANSCODER_THREADS: "2"
|
||||
MMTL_TRANSCODER_MAX_CONCURRENT: "1"
|
||||
MMTL_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
# 首次部署允许拉取;日常更新请只 pull mmtl。
|
||||
pull_policy: missing
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: mmtl
|
||||
POSTGRES_USER: mmtl
|
||||
POSTGRES_PASSWORD: mmtl
|
||||
TZ: Asia/Shanghai
|
||||
volumes:
|
||||
- ./postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mmtl -d mmtl"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
pull_policy: missing
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- redis-server
|
||||
- --appendonly
|
||||
- "yes"
|
||||
- --maxmemory
|
||||
- 256mb
|
||||
- --maxmemory-policy
|
||||
- allkeys-lru
|
||||
volumes:
|
||||
- ./redis:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
|
||||
opensearch:
|
||||
image: opensearchproject/opensearch:2
|
||||
pull_policy: missing
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
discovery.type: single-node
|
||||
plugins.security.disabled: "true"
|
||||
OPENSEARCH_JAVA_OPTS: "-Xms512m -Xmx512m"
|
||||
DISABLE_INSTALL_DEMO_CONFIG: "true"
|
||||
bootstrap.memory_lock: "false"
|
||||
volumes:
|
||||
- ./opensearch:/usr/share/opensearch/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:9200 >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 10s
|
||||
retries: 15
|
||||
start_period: 60s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
@@ -0,0 +1,106 @@
|
||||
# MMTL 单镜像部署模板(SQLite)
|
||||
#
|
||||
# 适合:新手、单人使用、低配 NAS / 小主机。
|
||||
# 特点:只有一个镜像,不需要 PostgreSQL / Redis / .env。
|
||||
#
|
||||
# 使用:
|
||||
# 1. 按需修改 ports 和 volumes 左侧的宿主机目录。
|
||||
# 2. docker compose -f docker-compose.simple.yml up -d
|
||||
# 3. 浏览器打开 http://服务器IP:18080
|
||||
#
|
||||
# 默认账号:admin / admin123
|
||||
# 首次登录后请立刻修改密码。
|
||||
|
||||
services:
|
||||
mmtl:
|
||||
image: ghcr.io/shukebta/mmtl:latest
|
||||
# Docker Hub 备用:
|
||||
# image: shukbet/mmtl:latest
|
||||
|
||||
container_name: mmtl
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
|
||||
ports:
|
||||
# 左边是宿主机访问端口,右边固定为容器内 8080。
|
||||
- "18080:8080"
|
||||
|
||||
volumes:
|
||||
# 必须备份:SQLite 数据库、系统配置、JWT 密钥、日志。
|
||||
- ./data:/data
|
||||
|
||||
# 可重建:海报缓存、临时文件、转码缓存。
|
||||
- ./cache:/cache
|
||||
|
||||
# 媒体库。网页里添加媒体库时填写 /media 或 /media/子目录。
|
||||
# NAS 示例:source: /vol1/1000/Media
|
||||
# Windows Docker Desktop 示例:source: D:/Media
|
||||
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
|
||||
- type: bind
|
||||
source: ./media
|
||||
target: /media
|
||||
bind:
|
||||
create_host_path: false
|
||||
|
||||
# 下载目录。qBittorrent / Transmission 的保存目录建议也对齐到 /downloads。
|
||||
# NAS 示例:source: /vol1/1000/Downloads
|
||||
# Windows Docker Desktop 示例:source: D:/Downloads
|
||||
- type: bind
|
||||
source: ./downloads
|
||||
target: /downloads
|
||||
bind:
|
||||
create_host_path: false
|
||||
|
||||
# 可选:Intel 核显硬解/转码。需要时取消注释,并在后台开启硬件加速。
|
||||
# - /dev/dri:/dev/dri
|
||||
|
||||
# 可选:管理面板一键更新需要访问 Docker 引擎。需要时取消注释。
|
||||
# - /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
|
||||
# Linux/NAS 文件权限。写入文件权限异常时,改成宿主机实际 uid/gid。
|
||||
PUID: "1000"
|
||||
PGID: "1000"
|
||||
|
||||
MMTL_APP_HOST: 0.0.0.0
|
||||
MMTL_APP_PORT: 8080
|
||||
MMTL_APP_WEB_DIR: /app/web/dist
|
||||
MMTL_APP_DATA_DIR: /data
|
||||
|
||||
# 单镜像档固定使用 SQLite。主数据库文件:./data/mmtl.db。
|
||||
MMTL_DATABASE_TYPE: sqlite
|
||||
MMTL_DATABASE_DB_PATH: /data/mmtl.db
|
||||
MMTL_CACHE_CACHE_DIR: /cache
|
||||
|
||||
# 路径映射保持容器内统一,网页和下载器里优先使用 /media、/downloads。
|
||||
MMTL_MEDIA_DIR: /media
|
||||
MMTL_MEDIA_CONTAINER_DIR: /media
|
||||
MMTL_DOWNLOAD_DIR: /downloads
|
||||
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
|
||||
|
||||
# 完整应用日志默认写入 ./data/logs/app.log;排查复杂问题时可临时改成 debug。
|
||||
MMTL_LOGGING_LEVEL: info
|
||||
MMTL_LOGGING_FORMAT: console
|
||||
MMTL_LOGGING_OUTPUT_PATH: /data/logs
|
||||
MMTL_LOGGING_MAX_SIZE_MB: "50"
|
||||
MMTL_LOGGING_MAX_BACKUPS: "20"
|
||||
MMTL_LOGGING_MAX_AGE_DAYS: "30"
|
||||
|
||||
extra_hosts:
|
||||
# 容器访问宿主机服务用,例如 qBittorrent: http://host.docker.internal:8085
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
@@ -0,0 +1,169 @@
|
||||
# MMTL 第二档完整 Docker Compose 部署文件
|
||||
#
|
||||
# 组件:
|
||||
# MMTL + PostgreSQL + Redis
|
||||
#
|
||||
# 使用方式二选一:
|
||||
# 1. 保存为 docker-compose.yml 后执行:
|
||||
# docker compose up -d
|
||||
# 2. 保留本文件名时执行:
|
||||
# docker compose -f docker-compose.standard.yml up -d
|
||||
#
|
||||
# 适合:
|
||||
# 多用户、第三方 Emby 客户端频繁刷新、媒体列表/首页访问较多的 NAS。
|
||||
#
|
||||
# 默认账号:
|
||||
# admin / admin123
|
||||
|
||||
services:
|
||||
mmtl:
|
||||
# 镜像二选一:
|
||||
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
|
||||
image: ghcr.io/shukebta/mmtl:latest
|
||||
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
|
||||
# image: shukbet/mmtl:latest
|
||||
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
ports:
|
||||
- "18080:8080"
|
||||
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
volumes:
|
||||
# 程序运行数据:JWT 密钥、运行配置、旧 SQLite 迁移源。
|
||||
- ./data:/data
|
||||
|
||||
# 缓存目录:海报缓存、临时文件等。通常不用备份。
|
||||
- ./cache:/cache
|
||||
|
||||
# 媒体库目录。自动整理/重命名/入库需要读写权限。
|
||||
# NAS 示例:source: /vol1/1000/Media
|
||||
# Windows Docker Desktop 示例:source: D:/Media
|
||||
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
|
||||
- type: bind
|
||||
source: ./media
|
||||
target: /media
|
||||
bind:
|
||||
create_host_path: false
|
||||
|
||||
# 下载目录。需要和 qBittorrent 保存路径保持一致。
|
||||
# NAS 示例:source: /vol1/1000/Downloads
|
||||
# Windows Docker Desktop 示例:source: D:/Downloads
|
||||
- type: bind
|
||||
source: ./downloads
|
||||
target: /downloads
|
||||
bind:
|
||||
create_host_path: false
|
||||
|
||||
# 管理面板「系统更新」需要访问 Docker 引擎。
|
||||
# 需要一键更新 Docker 镜像时取消下一行注释。
|
||||
# - /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
|
||||
PUID: "1000"
|
||||
PGID: "1000"
|
||||
|
||||
MMTL_APP_HOST: 0.0.0.0
|
||||
MMTL_APP_PORT: 8080
|
||||
MMTL_APP_WEB_DIR: /app/web/dist
|
||||
MMTL_APP_DATA_DIR: /data
|
||||
MMTL_LOGGING_LEVEL: info
|
||||
MMTL_LOGGING_FORMAT: console
|
||||
MMTL_LOGGING_OUTPUT_PATH: /data/logs
|
||||
MMTL_LOGGING_MAX_SIZE_MB: "50"
|
||||
MMTL_LOGGING_MAX_BACKUPS: "20"
|
||||
MMTL_LOGGING_MAX_AGE_DAYS: "30"
|
||||
|
||||
MMTL_DATABASE_TYPE: postgres
|
||||
MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable
|
||||
MMTL_DATABASE_DB_PATH: /data/mmtl.db
|
||||
|
||||
# Redis 只做热缓存,源数据仍在 PostgreSQL;Redis 丢失可自动重建。
|
||||
MMTL_CACHE_REDIS_URL: redis://redis:6379/0
|
||||
MMTL_CACHE_CACHE_DIR: /cache
|
||||
|
||||
MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest
|
||||
|
||||
# 路径换算配置。左边宿主机真实路径要和 volumes 左边保持一致。
|
||||
MMTL_MEDIA_DIR: /media
|
||||
MMTL_MEDIA_CONTAINER_DIR: /media
|
||||
MMTL_DOWNLOAD_DIR: /downloads
|
||||
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
|
||||
|
||||
MMTL_TRANSCODER_ENABLED: "true"
|
||||
MMTL_TRANSCODER_HARDWARE_ACCEL: "false"
|
||||
MMTL_TRANSCODER_REALTIME: "true"
|
||||
MMTL_TRANSCODER_THREADS: "2"
|
||||
MMTL_TRANSCODER_MAX_CONCURRENT: "1"
|
||||
MMTL_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
# 首次部署允许拉取;日常更新请只 pull mmtl。
|
||||
pull_policy: missing
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: mmtl
|
||||
POSTGRES_USER: mmtl
|
||||
POSTGRES_PASSWORD: mmtl
|
||||
TZ: Asia/Shanghai
|
||||
volumes:
|
||||
- ./postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mmtl -d mmtl"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
pull_policy: missing
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- redis-server
|
||||
- --appendonly
|
||||
- "yes"
|
||||
- --maxmemory
|
||||
- 256mb
|
||||
- --maxmemory-policy
|
||||
- allkeys-lru
|
||||
volumes:
|
||||
- ./redis:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
@@ -0,0 +1,174 @@
|
||||
# MMTL 最简单 Docker Compose 部署文件
|
||||
#
|
||||
# 新手建议:
|
||||
# 1. 不用 .env。
|
||||
# 2. 直接改本文件。
|
||||
# 3. 第一次可以不改路径,先用当前目录下的 ./media 和 ./downloads 体验。
|
||||
#
|
||||
# 启动:
|
||||
# docker compose up -d
|
||||
#
|
||||
# 访问:
|
||||
# http://服务器IP:18080
|
||||
#
|
||||
# 默认账号:
|
||||
# admin / admin123
|
||||
|
||||
services:
|
||||
mmtl:
|
||||
# 镜像二选一:
|
||||
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
|
||||
image: ghcr.io/shukebta/mmtl:latest
|
||||
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
|
||||
# image: shukbet/mmtl:latest
|
||||
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
# 浏览器访问端口。
|
||||
# 如果 18080 被占用,可以改成 "19011:8080" 之类。
|
||||
ports:
|
||||
- "18080:8080"
|
||||
|
||||
# 让容器可以访问宿主机上的 qBittorrent。
|
||||
# qB 地址通常可填:http://host.docker.internal:8085
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
volumes:
|
||||
# 程序运行数据:JWT 密钥、运行配置、旧 SQLite 迁移源。
|
||||
# 主数据库在 ./postgres,升级/备份时 ./data 和 ./postgres 都要保留。
|
||||
- ./data:/data
|
||||
|
||||
# 缓存目录:海报缓存、临时文件等。通常不用备份。
|
||||
- ./cache:/cache
|
||||
|
||||
# 媒体库目录。
|
||||
# 如果要使用自动整理/重命名/入库,这里必须保持读写,不能加 :ro。
|
||||
# 只有完全不整理、只扫描/播放已有媒体时,才建议手动改成只读。
|
||||
# 新手可先创建当前目录的 ./media 并把影片放进去。
|
||||
# NAS 用户把左边改成真实路径,例如:
|
||||
# source: /vol1/1000/Media
|
||||
# Windows Docker Desktop 示例:
|
||||
# source: D:/Media
|
||||
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
|
||||
- type: bind
|
||||
source: ./media
|
||||
target: /media
|
||||
bind:
|
||||
create_host_path: false
|
||||
|
||||
# 下载目录。
|
||||
# qB 下载目录、手动整理、自动整理会经常用到。
|
||||
# NAS 示例:
|
||||
# source: /vol1/1000/Downloads
|
||||
# Windows Docker Desktop 示例:
|
||||
# source: D:/Downloads
|
||||
- type: bind
|
||||
source: ./downloads
|
||||
target: /downloads
|
||||
bind:
|
||||
create_host_path: false
|
||||
|
||||
# 管理面板「系统更新」需要访问 Docker 引擎。
|
||||
# 需要一键更新 Docker 镜像时取消下一行注释;如果提示权限不足,
|
||||
# 请确认 PUID/PGID 对 /var/run/docker.sock 有读写权限。
|
||||
# - /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
|
||||
# Linux/NAS 用户权限。一般 1000 就可以。
|
||||
# 如果写入文件权限不对,再改成宿主机实际用户的 uid/gid。
|
||||
PUID: "1000"
|
||||
PGID: "1000"
|
||||
|
||||
# 程序基础配置,通常不用改。
|
||||
MMTL_APP_HOST: 0.0.0.0
|
||||
MMTL_APP_PORT: 8080
|
||||
MMTL_APP_WEB_DIR: /app/web/dist
|
||||
MMTL_APP_DATA_DIR: /data
|
||||
# 详细应用日志会保存在 ./data/logs/app.log,warn/error 也会拆分保存。
|
||||
# 排查复杂问题时可临时改成 debug。
|
||||
MMTL_LOGGING_LEVEL: info
|
||||
MMTL_LOGGING_FORMAT: console
|
||||
MMTL_LOGGING_OUTPUT_PATH: /data/logs
|
||||
MMTL_LOGGING_MAX_SIZE_MB: "50"
|
||||
MMTL_LOGGING_MAX_BACKUPS: "20"
|
||||
MMTL_LOGGING_MAX_AGE_DAYS: "30"
|
||||
|
||||
# 轻量模式默认只使用 PostgreSQL,适合大多数 NAS。
|
||||
# 旧版 ./data/mmtl.db 存在时,首次启动会自动迁移到 PostgreSQL。
|
||||
MMTL_DATABASE_TYPE: postgres
|
||||
MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable
|
||||
|
||||
# SQLite 旧库迁移源:
|
||||
# - 首次从旧版 ./data/mmtl.db 导入时保持此路径。
|
||||
# - 确认迁移完成后,建议改成 /data/no-sqlite-migration.db 这类不存在的路径。
|
||||
MMTL_DATABASE_DB_PATH: /data/mmtl.db
|
||||
MMTL_CACHE_CACHE_DIR: /cache
|
||||
|
||||
# 管理面板热更新默认拉取此镜像,并用 Watchtower 一次性重建当前容器。
|
||||
MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest
|
||||
|
||||
# 路径换算配置。
|
||||
# 默认推荐在网页里使用容器路径 /media。
|
||||
# 如果旧媒体库已经保存了宿主机路径 /vol1/1000/Media,
|
||||
# 再把这里改成同一个宿主机真实路径用于旧路径换算。
|
||||
MMTL_MEDIA_DIR: /media
|
||||
MMTL_MEDIA_CONTAINER_DIR: /media
|
||||
|
||||
# 默认推荐下载器保存路径使用 /downloads。
|
||||
# 如果下载器只能返回宿主机路径,再改成同一个宿主机真实路径。
|
||||
MMTL_DOWNLOAD_DIR: /downloads
|
||||
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
|
||||
|
||||
# NAS 友好的低负载默认值。
|
||||
MMTL_TRANSCODER_ENABLED: "true"
|
||||
MMTL_TRANSCODER_HARDWARE_ACCEL: "false"
|
||||
MMTL_TRANSCODER_REALTIME: "true"
|
||||
MMTL_TRANSCODER_THREADS: "2"
|
||||
MMTL_TRANSCODER_MAX_CONCURRENT: "1"
|
||||
MMTL_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
# 限制 Docker 日志大小,避免长期运行把磁盘写满。
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
# 首次部署允许拉取;日常更新请只 pull mmtl。
|
||||
# 如需升级 PostgreSQL,请先备份 ./postgres 后再手动调整镜像版本并拉取。
|
||||
pull_policy: missing
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: mmtl
|
||||
POSTGRES_USER: mmtl
|
||||
POSTGRES_PASSWORD: mmtl
|
||||
TZ: Asia/Shanghai
|
||||
volumes:
|
||||
# PostgreSQL 主数据目录。升级/重建容器时必须保留。
|
||||
- ./postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mmtl -d mmtl"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
run_uid="${PUID:-$(id -u mmtl 2>/dev/null || echo 1000)}"
|
||||
run_gid="${PGID:-$(id -g mmtl 2>/dev/null || echo 1000)}"
|
||||
|
||||
case "$run_uid" in
|
||||
''|*[!0-9]*)
|
||||
echo "PUID must be a numeric uid, got: $run_uid" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$run_gid" in
|
||||
''|*[!0-9]*)
|
||||
echo "PGID must be a numeric gid, got: $run_gid" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$run_uid" = "0" ]; then
|
||||
exec mmtl
|
||||
fi
|
||||
|
||||
chown -R "$run_uid:$run_gid" /data /cache 2>/dev/null || true
|
||||
chown "$run_uid:$run_gid" /media 2>/dev/null || true
|
||||
|
||||
exec su-exec "$run_uid:$run_gid" mmtl
|
||||
+68506
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
module github.com/ShukeBta/MMTL
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
github.com/shirou/gopsutil/v3 v3.24.5
|
||||
github.com/spf13/viper v1.18.2
|
||||
github.com/stretchr/testify v1.9.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.21.0
|
||||
golang.org/x/sys v0.20.0
|
||||
golang.org/x/time v0.15.0
|
||||
gorm.io/driver/postgres v1.5.7
|
||||
gorm.io/gorm v1.30.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/pgx/v5 v5.4.3 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
github.com/ulikunitz/xz v0.5.12 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||
golang.org/x/net v0.21.0 // indirect
|
||||
golang.org/x/text v0.20.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,210 @@
|
||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2 h1:40yUSXwdkWN851BHCq6uiDhleh7A4+0yIBS+IUAqZVY=
|
||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
|
||||
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
||||
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM=
|
||||
gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
|
||||
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
|
||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
@@ -0,0 +1,78 @@
|
||||
// Package config 加载分层配置:默认值、配置文件和环境变量。
|
||||
//
|
||||
// 优先级(低 -> 高):
|
||||
// 1. 内置默认值
|
||||
// 2. 工作目录中的 config.yaml(嵌套格式)
|
||||
// 3. config/*.yaml 分片文件(按模块)
|
||||
// 4. 以 MMTL_ 为前缀的环境变量
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// EnvPrefix 是所有环境变量驱动的覆盖使用的前缀。
|
||||
const EnvPrefix = "MMTL"
|
||||
|
||||
// Load 从默认值 / 文件 / 环境读取配置。
|
||||
//
|
||||
// 即使没有文件也始终返回可用的 Config。
|
||||
func Load() (*Config, error) {
|
||||
v := viper.New()
|
||||
setDefaults(v)
|
||||
|
||||
v.SetConfigName("config")
|
||||
v.SetConfigType("yaml")
|
||||
v.AddConfigPath(".")
|
||||
v.AddConfigPath("./config")
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
var notFound viper.ConfigFileNotFoundError
|
||||
if !asConfigFileNotFound(err, ¬Found) {
|
||||
return nil, fmt.Errorf("read main config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 合并 ./config/*.yaml 下的分片文件。
|
||||
if entries, err := os.ReadDir("config"); err == nil {
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") {
|
||||
continue
|
||||
}
|
||||
s := viper.New()
|
||||
s.SetConfigFile(filepath.Join("config", e.Name()))
|
||||
if err := s.ReadInConfig(); err == nil {
|
||||
_ = v.MergeConfigMap(s.AllSettings())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
v.SetEnvPrefix(EnvPrefix)
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
|
||||
cfg := &Config{}
|
||||
if err := v.Unmarshal(cfg); err != nil {
|
||||
return nil, fmt.Errorf("decode config: %w", err)
|
||||
}
|
||||
if err := cfg.normalize(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// asConfigFileNotFound 是 errors.As 的小辅助函数,避免在这个短文件中导入 errors。
|
||||
func asConfigFileNotFound(err error, target *viper.ConfigFileNotFoundError) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if v, ok := err.(viper.ConfigFileNotFoundError); ok {
|
||||
*target = v
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLoadDefaults asserts that a Load on a clean working directory yields
|
||||
// usable, normalized defaults.
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
|
||||
wd, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(wd) }()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
if cfg.App.Port != 8080 {
|
||||
t.Fatalf("expected default port 8080, got %d", cfg.App.Port)
|
||||
}
|
||||
if cfg.App.MaxCPUThreads != 2 {
|
||||
t.Fatalf("expected default MaxCPUThreads 2, got %d", cfg.App.MaxCPUThreads)
|
||||
}
|
||||
if cfg.Database.DBPath == "" {
|
||||
t.Fatalf("expected non-empty DBPath")
|
||||
}
|
||||
if cfg.Database.Type != "auto" {
|
||||
t.Fatalf("expected default database type auto, got %q", cfg.Database.Type)
|
||||
}
|
||||
if cfg.Logging.Level != "warn" || !cfg.Logging.EnableRotation || cfg.Logging.MaxSizeMB != 20 {
|
||||
t.Fatalf("expected warn rotating logs by default, got level=%q rotation=%v max=%d", cfg.Logging.Level, cfg.Logging.EnableRotation, cfg.Logging.MaxSizeMB)
|
||||
}
|
||||
if cfg.Database.MaxOpenConns != defaultDatabaseMaxOpenConns {
|
||||
t.Fatalf("expected default MaxOpenConns %d, got %d", defaultDatabaseMaxOpenConns, cfg.Database.MaxOpenConns)
|
||||
}
|
||||
if cfg.Cache.RedisPrefix != "mmtl" {
|
||||
t.Fatalf("expected default redis prefix, got %q", cfg.Cache.RedisPrefix)
|
||||
}
|
||||
if cfg.Cache.MediaTTLSeconds != 15 {
|
||||
t.Fatalf("expected default media cache ttl 15, got %d", cfg.Cache.MediaTTLSeconds)
|
||||
}
|
||||
if cfg.Search.Index != "mmtl_media" {
|
||||
t.Fatalf("expected default search index, got %q", cfg.Search.Index)
|
||||
}
|
||||
if cfg.Database.MaxIdleConns != defaultDatabaseMaxIdleConns {
|
||||
t.Fatalf("expected default MaxIdleConns %d, got %d", defaultDatabaseMaxIdleConns, cfg.Database.MaxIdleConns)
|
||||
}
|
||||
if cfg.Secrets.JWTSecret == "" {
|
||||
t.Fatalf("expected auto-generated JWT secret")
|
||||
}
|
||||
if !cfg.Organizer.SmartClassify {
|
||||
t.Fatalf("expected organizer smart classify enabled by default")
|
||||
}
|
||||
if cfg.License.ServerURL != defaultLicenseServerURL || cfg.License.PublicKey != defaultLicensePublicKey || cfg.License.HMACSecret != "" {
|
||||
t.Fatalf("expected bundled license bridge defaults, got url=%q public_key=%q hmac=%q", cfg.License.ServerURL, cfg.License.PublicKey, cfg.License.HMACSecret)
|
||||
}
|
||||
// Re-loading must reuse the persisted secret on disk.
|
||||
cfg2, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("second Load() error: %v", err)
|
||||
}
|
||||
if cfg.Secrets.JWTSecret != cfg2.Secrets.JWTSecret {
|
||||
t.Fatalf("expected JWT secret to persist across Load() calls")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cfg.App.DataDir, ".jwt_secret")); err != nil {
|
||||
t.Fatalf("expected jwt secret file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnvOverride checks that MMTL_* env vars override the defaults.
|
||||
func TestEnvOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
wd, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(wd) }()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("MMTL_APP_PORT", "9090")
|
||||
t.Setenv("MMTL_DATABASE_TYPE", "postgres")
|
||||
t.Setenv("MMTL_DATABASE_DSN", "postgres://mmtl:secret@postgres:5432/mmtl?sslmode=disable")
|
||||
t.Setenv("MMTL_CACHE_REDIS_URL", "redis://redis:6379/0")
|
||||
t.Setenv("MMTL_CACHE_MEDIA_TTL_SECONDS", "30")
|
||||
t.Setenv("MMTL_SEARCH_BACKEND", "opensearch")
|
||||
t.Setenv("MMTL_SEARCH_OPENSEARCH_URL", "http://opensearch:9200")
|
||||
t.Setenv("MMTL_LICENSE_SERVER_URL", "https://license.example.com")
|
||||
t.Setenv("MMTL_LICENSE_HMAC_SECRET", "override-secret")
|
||||
t.Setenv("MMTL_LICENSE_PUBLIC_KEY", "override-public-key")
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
if cfg.App.Port != 9090 {
|
||||
t.Fatalf("expected port 9090 from env, got %d", cfg.App.Port)
|
||||
}
|
||||
if cfg.Database.Type != "postgres" || cfg.Database.DSN == "" {
|
||||
t.Fatalf("expected postgres database config from env, got type=%q dsn=%q", cfg.Database.Type, cfg.Database.DSN)
|
||||
}
|
||||
if cfg.Cache.RedisURL != "redis://redis:6379/0" || cfg.Cache.MediaTTLSeconds != 30 {
|
||||
t.Fatalf("expected redis cache config from env, got url=%q ttl=%d", cfg.Cache.RedisURL, cfg.Cache.MediaTTLSeconds)
|
||||
}
|
||||
if cfg.Search.Backend != "opensearch" || cfg.Search.OpenSearchURL != "http://opensearch:9200" {
|
||||
t.Fatalf("expected opensearch config from env, got backend=%q url=%q", cfg.Search.Backend, cfg.Search.OpenSearchURL)
|
||||
}
|
||||
if cfg.License.ServerURL != "https://license.example.com" || cfg.License.HMACSecret != "override-secret" || cfg.License.PublicKey != "override-public-key" {
|
||||
t.Fatalf("expected license config from env, got url=%q secret=%q public_key=%q", cfg.License.ServerURL, cfg.License.HMACSecret, cfg.License.PublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAllowsExplicitSingleConnectionDatabaseConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
wd, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(wd) }()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("config.yaml", []byte("database:\n max_open_conns: 1\n max_idle_conns: 1\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
if cfg.Database.MaxOpenConns != 1 {
|
||||
t.Fatalf("expected explicit MaxOpenConns=1 to be preserved, got %d", cfg.Database.MaxOpenConns)
|
||||
}
|
||||
if cfg.Database.MaxIdleConns != 1 {
|
||||
t.Fatalf("expected explicit MaxIdleConns=1 to be preserved, got %d", cfg.Database.MaxIdleConns)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package config
|
||||
|
||||
import "github.com/spf13/viper"
|
||||
|
||||
const (
|
||||
defaultDatabaseMaxOpenConns = 16
|
||||
defaultDatabaseMaxIdleConns = 4
|
||||
defaultLicenseServerURL = "https://mgosever.3jzs.com"
|
||||
defaultLicensePublicKey = "MCowBQYDK2VwAyEABRXnXy+urjrbKit6Yu/HiezWgP0NdsZW3tsegJWRrtI="
|
||||
)
|
||||
|
||||
func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("app.port", 8080)
|
||||
v.SetDefault("app.debug", false)
|
||||
v.SetDefault("app.env", "production")
|
||||
v.SetDefault("app.data_dir", "./data")
|
||||
v.SetDefault("app.web_dir", "./web/dist")
|
||||
v.SetDefault("app.ffmpeg_path", "ffmpeg")
|
||||
v.SetDefault("app.ffprobe_path", "ffprobe")
|
||||
v.SetDefault("app.ffprobe_max_concurrent", 2)
|
||||
v.SetDefault("app.cloud_scan_max_concurrent", 8)
|
||||
v.SetDefault("app.max_cpu_threads", 2)
|
||||
v.SetDefault("app.vaapi_device", "/dev/dri/renderD128")
|
||||
v.SetDefault("app.cors_origins", []string{})
|
||||
v.SetDefault("app.server_url", "")
|
||||
|
||||
v.SetDefault("database.type", "auto")
|
||||
v.SetDefault("database.db_path", "./data/mmtl.db")
|
||||
v.SetDefault("database.dsn", "")
|
||||
v.SetDefault("database.wal_mode", true)
|
||||
v.SetDefault("database.busy_timeout", 5000)
|
||||
v.SetDefault("database.cache_size", -40000)
|
||||
v.SetDefault("database.max_open_conns", defaultDatabaseMaxOpenConns)
|
||||
v.SetDefault("database.max_idle_conns", defaultDatabaseMaxIdleConns)
|
||||
|
||||
v.SetDefault("secrets.jwt_secret", "")
|
||||
|
||||
v.SetDefault("logging.level", "warn")
|
||||
v.SetDefault("logging.format", "console")
|
||||
v.SetDefault("logging.enable_rotation", true)
|
||||
v.SetDefault("logging.max_size_mb", 20)
|
||||
v.SetDefault("logging.max_age_days", 30)
|
||||
v.SetDefault("logging.max_backups", 10)
|
||||
|
||||
v.SetDefault("cache.cache_dir", "./cache")
|
||||
v.SetDefault("cache.images_max_size_mb", 500)
|
||||
v.SetDefault("cache.cleanup_interval_min", 60)
|
||||
v.SetDefault("cache.redis_url", "")
|
||||
v.SetDefault("cache.redis_prefix", "mmtl")
|
||||
v.SetDefault("cache.media_ttl_seconds", 15)
|
||||
|
||||
v.SetDefault("search.backend", "")
|
||||
v.SetDefault("search.opensearch_url", "")
|
||||
v.SetDefault("search.index", "mmtl_media")
|
||||
v.SetDefault("search.username", "")
|
||||
v.SetDefault("search.password", "")
|
||||
|
||||
v.SetDefault("ai.enabled", false)
|
||||
v.SetDefault("ai.provider", "openai")
|
||||
v.SetDefault("ai.api_base", "https://api.openai.com/v1")
|
||||
v.SetDefault("ai.model", "gpt-4o-mini")
|
||||
v.SetDefault("ai.timeout", 30)
|
||||
v.SetDefault("ai.max_concurrent", 3)
|
||||
|
||||
v.SetDefault("flaresolverr.enabled", false)
|
||||
v.SetDefault("flaresolverr.url", "http://localhost:8191")
|
||||
v.SetDefault("flaresolverr.session", "mmtl")
|
||||
v.SetDefault("flaresolverr.timeout", 60)
|
||||
|
||||
v.SetDefault("downloads.smart_classify", true)
|
||||
v.SetDefault("organizer.smart_classify", true)
|
||||
v.SetDefault("organizer.auto_after_download", false)
|
||||
v.SetDefault("organize.scrape_after", true)
|
||||
v.SetDefault("scrape.delay_min_ms", 250)
|
||||
v.SetDefault("scrape.delay_max_ms", 500)
|
||||
v.SetDefault("organizer.categories.concert_movie", "演唱会")
|
||||
v.SetDefault("organizer.categories.documentary_movie", "纪录片")
|
||||
v.SetDefault("organizer.categories.chinese_movie", "华语电影")
|
||||
v.SetDefault("organizer.categories.animation_movie", "动画电影")
|
||||
v.SetDefault("organizer.categories.euus_movie", "欧美电影")
|
||||
v.SetDefault("organizer.categories.jk_movie", "日韩电影")
|
||||
v.SetDefault("organizer.categories.domestic_tv", "国产剧")
|
||||
v.SetDefault("organizer.categories.euus_tv", "欧美剧")
|
||||
v.SetDefault("organizer.categories.jk_tv", "日韩剧")
|
||||
v.SetDefault("organizer.categories.unclassified_tv", "未分类")
|
||||
v.SetDefault("organizer.categories.jp_anime", "日番")
|
||||
v.SetDefault("organizer.categories.cn_anime", "国漫")
|
||||
v.SetDefault("organizer.categories.kr_anime", "韩漫")
|
||||
v.SetDefault("organizer.categories.us_anime", "美漫")
|
||||
v.SetDefault("organizer.categories.other_anime", "其他")
|
||||
v.SetDefault("organizer.categories.variety", "综艺")
|
||||
v.SetDefault("organizer.categories.documentary", "纪录片")
|
||||
v.SetDefault("organizer.categories.children", "儿童")
|
||||
v.SetDefault("organizer.categories.adult", "成人")
|
||||
v.SetDefault("recognition_words.enabled", true)
|
||||
v.SetDefault("recognition_words.shared_urls", []string{
|
||||
"https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/general.txt",
|
||||
"https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/TV.txt",
|
||||
"https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/anime.txt",
|
||||
})
|
||||
|
||||
v.SetDefault("transcoder.encoder", "")
|
||||
v.SetDefault("transcoder.enabled", true)
|
||||
v.SetDefault("transcoder.hardware_accel", false)
|
||||
v.SetDefault("transcoder.preset", "veryfast")
|
||||
v.SetDefault("transcoder.video_bitrate", "1500k")
|
||||
v.SetDefault("transcoder.max_rate", "1800k")
|
||||
v.SetDefault("transcoder.buf_size", "3000k")
|
||||
v.SetDefault("transcoder.max_height", 720)
|
||||
v.SetDefault("transcoder.segment_seconds", 4)
|
||||
v.SetDefault("transcoder.realtime", true)
|
||||
v.SetDefault("transcoder.threads", 2)
|
||||
v.SetDefault("transcoder.max_concurrent", 1)
|
||||
v.SetDefault("transcoder.idle_timeout_seconds", 120)
|
||||
|
||||
// API Config 默认设置
|
||||
v.SetDefault("api_config.auto_encrypt", true)
|
||||
v.SetDefault("api_config.default_timeout", 30)
|
||||
|
||||
v.SetDefault("license.server_url", defaultLicenseServerURL)
|
||||
v.SetDefault("license.hmac_secret", "")
|
||||
v.SetDefault("license.public_key", defaultLicensePublicKey)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// normalize 填充派生默认值并自愈空的关键字段。
|
||||
func (c *Config) normalize() error {
|
||||
if c.App.DataDir == "" {
|
||||
c.App.DataDir = "./data"
|
||||
}
|
||||
if c.Database.DBPath == "" {
|
||||
c.Database.DBPath = filepath.Join(c.App.DataDir, "mmtl.db")
|
||||
}
|
||||
if c.Database.Type == "" {
|
||||
c.Database.Type = "auto"
|
||||
}
|
||||
if c.App.MaxCPUThreads < 1 {
|
||||
c.App.MaxCPUThreads = 1
|
||||
}
|
||||
if c.App.MaxCPUThreads > 8 {
|
||||
c.App.MaxCPUThreads = 8
|
||||
}
|
||||
if c.App.CloudScanMaxConcurrent < 1 {
|
||||
c.App.CloudScanMaxConcurrent = 1
|
||||
}
|
||||
if c.App.CloudScanMaxConcurrent > 16 {
|
||||
c.App.CloudScanMaxConcurrent = 16
|
||||
}
|
||||
if c.Database.MaxOpenConns <= 0 {
|
||||
c.Database.MaxOpenConns = defaultDatabaseMaxOpenConns
|
||||
}
|
||||
if c.Database.MaxIdleConns <= 0 || c.Database.MaxIdleConns > c.Database.MaxOpenConns {
|
||||
c.Database.MaxIdleConns = defaultDatabaseMaxIdleConns
|
||||
if c.Database.MaxIdleConns > c.Database.MaxOpenConns {
|
||||
c.Database.MaxIdleConns = c.Database.MaxOpenConns
|
||||
}
|
||||
}
|
||||
if c.Cache.CacheDir == "" {
|
||||
c.Cache.CacheDir = filepath.Join(c.App.DataDir, "cache")
|
||||
}
|
||||
if c.Cache.ImagesMaxSizeMB < 0 {
|
||||
c.Cache.ImagesMaxSizeMB = 0
|
||||
}
|
||||
if c.Cache.RedisPrefix == "" {
|
||||
c.Cache.RedisPrefix = "mmtl"
|
||||
}
|
||||
if c.Cache.MediaTTLSeconds < 1 {
|
||||
c.Cache.MediaTTLSeconds = 15
|
||||
}
|
||||
c.Search.Backend = strings.ToLower(strings.TrimSpace(c.Search.Backend))
|
||||
if c.Search.Index == "" {
|
||||
c.Search.Index = "mmtl_media"
|
||||
}
|
||||
if c.Secrets.JWTSecret == "" {
|
||||
// 持久化自动生成的密钥以在操作员忘记配置时保持会话稳定。
|
||||
path := filepath.Join(c.App.DataDir, ".jwt_secret")
|
||||
if data, err := os.ReadFile(path); err == nil && len(data) > 0 { // #nosec G304 -- path is fixed to .jwt_secret under configured DataDir.
|
||||
c.Secrets.JWTSecret = strings.TrimSpace(string(data))
|
||||
} else {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return fmt.Errorf("generate jwt secret: %w", err)
|
||||
}
|
||||
c.Secrets.JWTSecret = hex.EncodeToString(buf)
|
||||
_ = os.MkdirAll(c.App.DataDir, 0o750)
|
||||
_ = os.WriteFile(path, []byte(c.Secrets.JWTSecret), 0o600)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// SaveDatabaseConfig updates or creates config.yaml with the specified database configuration.
|
||||
func SaveDatabaseConfig(dbType, dsn string) error {
|
||||
configPath := "config.yaml"
|
||||
data := make(map[string]any)
|
||||
|
||||
content, err := os.ReadFile(configPath)
|
||||
if err == nil {
|
||||
if err := yaml.Unmarshal(content, &data); err != nil {
|
||||
data = make(map[string]any)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("read config.yaml: %w", err)
|
||||
}
|
||||
|
||||
dbSection, ok := data["database"].(map[string]any)
|
||||
if !ok {
|
||||
dbSection = make(map[string]any)
|
||||
}
|
||||
dbSection["type"] = dbType
|
||||
dbSection["dsn"] = dsn
|
||||
data["database"] = dbSection
|
||||
|
||||
out, err := yaml.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config.yaml: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(configPath, out, 0644); err != nil {
|
||||
return fmt.Errorf("write config.yaml: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveDatabaseConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
wd, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(wd) }()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
|
||||
dsn := "postgres://admin:pass@127.0.0.1:5432/mmtl?sslmode=disable"
|
||||
if err := SaveDatabaseConfig("postgres", dsn); err != nil {
|
||||
t.Fatalf("SaveDatabaseConfig error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, "config.yaml")); err != nil {
|
||||
t.Fatalf("expected config.yaml to exist: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load error: %v", err)
|
||||
}
|
||||
if loaded.Database.Type != "postgres" {
|
||||
t.Fatalf("expected database.type=postgres, got %s", loaded.Database.Type)
|
||||
}
|
||||
if loaded.Database.DSN != dsn {
|
||||
t.Fatalf("expected dsn=%s, got %s", dsn, loaded.Database.DSN)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package config
|
||||
|
||||
// Config 是根配置聚合。
|
||||
type Config struct {
|
||||
App AppConfig `mapstructure:"app"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
Secrets SecretsConfig `mapstructure:"secrets"`
|
||||
Logging LoggingConfig `mapstructure:"logging"`
|
||||
Cache CacheConfig `mapstructure:"cache"`
|
||||
Search SearchConfig `mapstructure:"search"`
|
||||
Media MediaConfig `mapstructure:"media"`
|
||||
Transcoder TranscoderConfig `mapstructure:"transcoder"`
|
||||
AI AIConfig `mapstructure:"ai"`
|
||||
FlareSolverr FlareSolverrConfig `mapstructure:"flaresolverr"`
|
||||
ApiConfig ApiConfigConfig `mapstructure:"api_config"`
|
||||
Organizer OrganizerConfig `mapstructure:"organizer"`
|
||||
License LicenseConfig `mapstructure:"license"`
|
||||
}
|
||||
|
||||
// ApiConfigConfig API 配置相关设置。
|
||||
type ApiConfigConfig struct {
|
||||
// AutoEncrypt 是否自动加密敏感字段
|
||||
AutoEncrypt bool `mapstructure:"auto_encrypt"`
|
||||
// DefaultTimeout 默认请求超时(秒)
|
||||
DefaultTimeout int `mapstructure:"default_timeout"`
|
||||
}
|
||||
|
||||
// TranscoderConfig 控制 HLS / ffmpeg 后端。
|
||||
type TranscoderConfig struct {
|
||||
Encoder string `mapstructure:"encoder"` // "" / nvenc / qsv / vaapi
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
HardwareAccel bool `mapstructure:"hardware_accel"`
|
||||
Preset string `mapstructure:"preset"`
|
||||
VideoBitrate string `mapstructure:"video_bitrate"`
|
||||
MaxRate string `mapstructure:"max_rate"`
|
||||
BufSize string `mapstructure:"buf_size"`
|
||||
MaxHeight int `mapstructure:"max_height"`
|
||||
SegmentSeconds int `mapstructure:"segment_seconds"`
|
||||
Realtime bool `mapstructure:"realtime"`
|
||||
Threads int `mapstructure:"threads"`
|
||||
MaxConcurrent int `mapstructure:"max_concurrent"`
|
||||
IdleTimeoutSeconds int `mapstructure:"idle_timeout_seconds"`
|
||||
}
|
||||
|
||||
// AppConfig 保存运行时应用参数。
|
||||
type AppConfig struct {
|
||||
Port int `mapstructure:"port"`
|
||||
Debug bool `mapstructure:"debug"`
|
||||
Env string `mapstructure:"env"`
|
||||
DataDir string `mapstructure:"data_dir"`
|
||||
WebDir string `mapstructure:"web_dir"`
|
||||
// HTTPSEnabled 是否仅通过 HTTPS 提供访问。启用时必须同时配置
|
||||
// SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS。
|
||||
HTTPSEnabled bool `mapstructure:"https_enabled"`
|
||||
// SSLCert 是 PEM 编码的 SSL 证书内容。
|
||||
SSLCert string `mapstructure:"ssl_cert"`
|
||||
// SSLKey 是 PEM 编码的 SSL 私钥内容。
|
||||
SSLKey string `mapstructure:"ssl_key"`
|
||||
// SSLCertPath 是 SSL 证书文件路径;非空时优先于 SSLCert 从文件读取。
|
||||
SSLCertPath string `mapstructure:"ssl_cert_path"`
|
||||
// SSLKeyPath 是 SSL 私钥文件路径;非空时优先于 SSLKey 从文件读取。
|
||||
SSLKeyPath string `mapstructure:"ssl_key_path"`
|
||||
FFmpegPath string `mapstructure:"ffmpeg_path"`
|
||||
FFprobePath string `mapstructure:"ffprobe_path"`
|
||||
// FFprobeMaxConcurrent limits concurrent ffprobe/ffmpeg metadata probes.
|
||||
// NAS devices can become unresponsive when a scan starts many probe
|
||||
// processes at once, so the default is deliberately conservative.
|
||||
FFprobeMaxConcurrent int `mapstructure:"ffprobe_max_concurrent"`
|
||||
// CloudScanMaxConcurrent limits concurrent cloud directory list requests
|
||||
// inside one mounted cloud library scan.
|
||||
CloudScanMaxConcurrent int `mapstructure:"cloud_scan_max_concurrent"`
|
||||
MaxCPUThreads int `mapstructure:"max_cpu_threads"`
|
||||
VAAPIDevice string `mapstructure:"vaapi_device"`
|
||||
CORSOrigins []string `mapstructure:"cors_origins"`
|
||||
ServerURL string `mapstructure:"server_url"`
|
||||
}
|
||||
|
||||
// DatabaseConfig 配置 GORM 数据库。默认 auto:
|
||||
// Docker Compose 主线会注入 PostgreSQL DSN;裸机/旧部署没有 DSN 时回退 SQLite。
|
||||
type DatabaseConfig struct {
|
||||
Type string `mapstructure:"type"`
|
||||
DBPath string `mapstructure:"db_path"`
|
||||
DSN string `mapstructure:"dsn"`
|
||||
WALMode bool `mapstructure:"wal_mode"`
|
||||
BusyTimeout int `mapstructure:"busy_timeout"`
|
||||
CacheSize int `mapstructure:"cache_size"`
|
||||
MaxOpenConns int `mapstructure:"max_open_conns"`
|
||||
MaxIdleConns int `mapstructure:"max_idle_conns"`
|
||||
}
|
||||
|
||||
// SecretsConfig 保存 JWT / 第三方 API 密钥(不要提交值)。
|
||||
type SecretsConfig struct {
|
||||
JWTSecret string `mapstructure:"jwt_secret"`
|
||||
TMDbAPIKey string `mapstructure:"tmdb_api_key"`
|
||||
TMDbAPIProxy string `mapstructure:"tmdb_api_proxy"`
|
||||
TMDbImageProxy string `mapstructure:"tmdb_image_proxy"`
|
||||
BangumiToken string `mapstructure:"bangumi_access_token"`
|
||||
TheTVDBAPIKey string `mapstructure:"thetvdb_api_key"`
|
||||
FanartAPIKey string `mapstructure:"fanart_tv_api_key"`
|
||||
DoubanCookie string `mapstructure:"douban_cookie"`
|
||||
// 用于加密的密钥,如果为空则使用 JWTSecret
|
||||
EncryptionKey string `mapstructure:"encryption_key"`
|
||||
}
|
||||
|
||||
// LoggingConfig 配置 Zap。
|
||||
type LoggingConfig struct {
|
||||
Level string `mapstructure:"level"`
|
||||
Format string `mapstructure:"format"`
|
||||
OutputPath string `mapstructure:"output_path"`
|
||||
EnableRotation bool `mapstructure:"enable_rotation"`
|
||||
MaxSizeMB int `mapstructure:"max_size_mb"`
|
||||
MaxAgeDays int `mapstructure:"max_age_days"`
|
||||
MaxBackups int `mapstructure:"max_backups"`
|
||||
}
|
||||
|
||||
// CacheConfig 控制磁盘转码/刮削缓存。
|
||||
type CacheConfig struct {
|
||||
CacheDir string `mapstructure:"cache_dir"`
|
||||
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
|
||||
MaxDiskUsageMB int `mapstructure:"max_disk_usage_mb"`
|
||||
TTLHours int `mapstructure:"ttl_hours"`
|
||||
AutoCleanup bool `mapstructure:"auto_cleanup"`
|
||||
CleanupIntervalMin int `mapstructure:"cleanup_interval_min"`
|
||||
RedisURL string `mapstructure:"redis_url"`
|
||||
RedisPrefix string `mapstructure:"redis_prefix"`
|
||||
MediaTTLSeconds int `mapstructure:"media_ttl_seconds"`
|
||||
}
|
||||
|
||||
type SearchConfig struct {
|
||||
Backend string `mapstructure:"backend"`
|
||||
OpenSearchURL string `mapstructure:"opensearch_url"`
|
||||
Index string `mapstructure:"index"`
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
}
|
||||
|
||||
// MediaConfig 保存默认库位置(用于引导库)。
|
||||
type MediaConfig struct {
|
||||
MoviesDir string `mapstructure:"movies_dir"`
|
||||
TVDir string `mapstructure:"tv_dir"`
|
||||
AnimeDir string `mapstructure:"anime_dir"`
|
||||
}
|
||||
|
||||
// AIConfig 配置可选的 LLM 提供者。
|
||||
type AIConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Provider string `mapstructure:"provider"`
|
||||
APIBase string `mapstructure:"api_base"`
|
||||
APIKey string `mapstructure:"api_key"`
|
||||
Model string `mapstructure:"model"`
|
||||
Timeout int `mapstructure:"timeout"`
|
||||
MaxConcurrent int `mapstructure:"max_concurrent"`
|
||||
}
|
||||
|
||||
// LicenseConfig configures the optional MMTL license server bridge.
|
||||
type LicenseConfig struct {
|
||||
ServerURL string `mapstructure:"server_url"`
|
||||
HMACSecret string `mapstructure:"hmac_secret"`
|
||||
PublicKey string `mapstructure:"public_key"`
|
||||
}
|
||||
|
||||
// OrganizerConfig 配置媒体文件智能分类整理。
|
||||
type OrganizerConfig struct {
|
||||
SmartClassify bool `mapstructure:"smart_classify"`
|
||||
AutoAfterDownload bool `mapstructure:"auto_after_download"`
|
||||
Categories map[string]string `mapstructure:"categories"`
|
||||
}
|
||||
|
||||
// FlareSolverrConfig 配置 FlareSolverr 服务(用于绕过 Cloudflare/WAF)。
|
||||
type FlareSolverrConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
URL string `mapstructure:"url"`
|
||||
Session string `mapstructure:"session"`
|
||||
Timeout int `mapstructure:"timeout"`
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Package database wires up GORM against the configured database and exposes
|
||||
// startup migration helpers.
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
)
|
||||
|
||||
// Open initialises the configured GORM database. database.type=auto chooses
|
||||
// PostgreSQL when database.dsn is present and otherwise falls back to SQLite.
|
||||
func Open(cfg *config.Config, log *zap.Logger) (*gorm.DB, error) {
|
||||
if cfg == nil {
|
||||
return nil, errors.New("database config is required")
|
||||
}
|
||||
dialect := normalizeDatabaseType(cfg.Database.Type)
|
||||
if dialect == "auto" {
|
||||
dialect = effectiveAutoDatabaseType(cfg)
|
||||
}
|
||||
dialector, err := databaseDialector(cfg, dialect)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := gorm.Open(dialector, &gorm.Config{
|
||||
Logger: newGormLogger(log),
|
||||
PrepareStmt: true,
|
||||
DisableForeignKeyConstraintWhenMigrating: false,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gorm open: %w", err)
|
||||
}
|
||||
if dialect == "sqlite" {
|
||||
installSQLiteWriteGate(db)
|
||||
}
|
||||
if err := configureConnectionPool(db, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func newGormLogger(log *zap.Logger) logger.Interface {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
return logger.New(
|
||||
zapStdLogger{log: log},
|
||||
logger.Config{
|
||||
SlowThreshold: 0,
|
||||
LogLevel: logger.Warn,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
Colorful: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func configureConnectionPool(db *gorm.DB, cfg *config.Config) error {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("gorm sqldb: %w", err)
|
||||
}
|
||||
if cfg.Database.MaxOpenConns > 0 {
|
||||
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns)
|
||||
}
|
||||
if cfg.Database.MaxIdleConns > 0 {
|
||||
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeDatabaseType(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "auto":
|
||||
return "auto"
|
||||
case "sqlite", "sqlite3":
|
||||
return "sqlite"
|
||||
case "postgres", "postgresql", "pg":
|
||||
return "postgres"
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
}
|
||||
|
||||
func effectiveAutoDatabaseType(cfg *config.Config) string {
|
||||
if cfg != nil && strings.TrimSpace(cfg.Database.DSN) != "" {
|
||||
return "postgres"
|
||||
}
|
||||
return "sqlite"
|
||||
}
|
||||
|
||||
func databaseDialector(cfg *config.Config, dialect string) (gorm.Dialector, error) {
|
||||
switch dialect {
|
||||
case "sqlite":
|
||||
return sqlite.Open(buildSQLiteDSN(cfg)), nil
|
||||
case "postgres":
|
||||
dsn := strings.TrimSpace(cfg.Database.DSN)
|
||||
if dsn == "" {
|
||||
return nil, fmt.Errorf("database.dsn is required when database.type=postgres")
|
||||
}
|
||||
return postgres.Open(dsn), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported database.type %q (supported: sqlite, postgres)", cfg.Database.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// zapStdLogger adapts a *zap.Logger to GORM's tiny logger interface.
|
||||
type zapStdLogger struct{ log *zap.Logger }
|
||||
|
||||
func (z zapStdLogger) Printf(format string, args ...interface{}) {
|
||||
if z.log == nil {
|
||||
return
|
||||
}
|
||||
z.log.Sugar().Infof(format, args...)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
// DatabaseStatus describes the currently active database engine and runtime metrics.
|
||||
type DatabaseStatus struct {
|
||||
Type string `json:"type"`
|
||||
DSN string `json:"dsn,omitempty"`
|
||||
DBPath string `json:"db_path,omitempty"`
|
||||
OpenConns int `json:"open_conns"`
|
||||
InUse int `json:"in_use"`
|
||||
Idle int `json:"idle"`
|
||||
MaxOpenConns int `json:"max_open_conns"`
|
||||
TableCounts map[string]int64 `json:"table_counts"`
|
||||
}
|
||||
|
||||
// PostgresTestResult returns latency and version info after testing connection.
|
||||
type PostgresTestResult struct {
|
||||
Success bool `json:"success"`
|
||||
LatencyMS int64 `json:"latency_ms"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DatabaseMigrationResult returns row counts and execution duration of migration.
|
||||
type DatabaseMigrationResult struct {
|
||||
Success bool `json:"success"`
|
||||
TotalRows int64 `json:"total_rows"`
|
||||
TableRows map[string]int64 `json:"table_rows"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// InspectDatabaseStatus queries the currently active database for metrics and table rows.
|
||||
func InspectDatabaseStatus(db *gorm.DB, cfg *config.Config) *DatabaseStatus {
|
||||
st := &DatabaseStatus{
|
||||
Type: "sqlite",
|
||||
TableCounts: make(map[string]int64),
|
||||
}
|
||||
if cfg != nil {
|
||||
st.DBPath = cfg.Database.DBPath
|
||||
if cfg.Database.Type == "postgres" || (cfg.Database.Type == "auto" && strings.TrimSpace(cfg.Database.DSN) != "") {
|
||||
st.Type = "postgres"
|
||||
st.DSN = MaskDSN(cfg.Database.DSN)
|
||||
}
|
||||
}
|
||||
if isPostgres(db) {
|
||||
st.Type = "postgres"
|
||||
}
|
||||
|
||||
if db != nil {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
stats := sqlDB.Stats()
|
||||
st.OpenConns = stats.OpenConnections
|
||||
st.InUse = stats.InUse
|
||||
st.Idle = stats.Idle
|
||||
st.MaxOpenConns = stats.MaxOpenConnections
|
||||
}
|
||||
|
||||
// Count rows for major model tables
|
||||
for _, m := range model.AllModels() {
|
||||
if tbl, err := modelTableName(db, m); err == nil {
|
||||
if db.Migrator().HasTable(tbl) {
|
||||
var count int64
|
||||
if err := db.Raw("SELECT COUNT(1) FROM " + quoteIdent(tbl)).Scan(&count).Error; err == nil {
|
||||
st.TableCounts[tbl] = count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// TestPostgres establishes a temporary connection to verify reachability and permissions.
|
||||
func TestPostgres(dsn string) (*PostgresTestResult, error) {
|
||||
dsn = strings.TrimSpace(dsn)
|
||||
if dsn == "" {
|
||||
return &PostgresTestResult{
|
||||
Success: false,
|
||||
Error: "PostgreSQL DSN 不能为空",
|
||||
}, nil
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
testDB, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
return &PostgresTestResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("连接失败: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
sqlDB, err := testDB.DB()
|
||||
if err != nil {
|
||||
return &PostgresTestResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("获取底层连接失败: %v", err),
|
||||
}, nil
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := sqlDB.PingContext(ctx); err != nil {
|
||||
return &PostgresTestResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Ping 超时或失败: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var version string
|
||||
if err := testDB.WithContext(ctx).Raw("SELECT version()").Scan(&version).Error; err != nil {
|
||||
version = "PostgreSQL (unknown version)"
|
||||
}
|
||||
|
||||
latency := time.Since(start).Milliseconds()
|
||||
return &PostgresTestResult{
|
||||
Success: true,
|
||||
LatencyMS: latency,
|
||||
Version: version,
|
||||
Message: "连接成功",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MigrateCurrentToPostgres performs schema initialization and full table data copy into target PostgreSQL.
|
||||
func MigrateCurrentToPostgres(src *gorm.DB, targetDSN string, batchSize int, log *zap.Logger) (*DatabaseMigrationResult, error) {
|
||||
targetDSN = strings.TrimSpace(targetDSN)
|
||||
if targetDSN == "" {
|
||||
return nil, fmt.Errorf("target PostgreSQL DSN cannot be empty")
|
||||
}
|
||||
if src == nil {
|
||||
return nil, fmt.Errorf("current database is not available")
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
targetDB, err := gorm.Open(postgres.Open(targetDSN), &gorm.Config{
|
||||
Logger: newGormLogger(log),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open target PostgreSQL: %w", err)
|
||||
}
|
||||
targetSQLDB, err := targetDB.DB()
|
||||
if err == nil {
|
||||
defer targetSQLDB.Close()
|
||||
}
|
||||
|
||||
// 1. 初始化目标库 Schema、类型与索引
|
||||
if err := AutoMigrate(targetDB); err != nil {
|
||||
return nil, fmt.Errorf("auto migrate target PostgreSQL: %w", err)
|
||||
}
|
||||
|
||||
// 2. 安全重置目标数据库的初始默认数据
|
||||
if err := resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src, targetDB, log); err != nil {
|
||||
return nil, fmt.Errorf("reset target bootstrap data: %w", err)
|
||||
}
|
||||
|
||||
// 3. 执行数据批量复制
|
||||
tableRows, totalRows, err := copyModelTables(src, targetDB, batchSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("copy tables: %w", err)
|
||||
}
|
||||
|
||||
// 4. 标记迁移完成
|
||||
if err := markSQLiteMigrationComplete(targetDB); err != nil {
|
||||
return nil, fmt.Errorf("mark migration complete: %w", err)
|
||||
}
|
||||
|
||||
duration := time.Since(started).Milliseconds()
|
||||
return &DatabaseMigrationResult{
|
||||
Success: true,
|
||||
TotalRows: totalRows,
|
||||
TableRows: tableRows,
|
||||
DurationMS: duration,
|
||||
Message: fmt.Sprintf("成功迁移 %d 条记录至 PostgreSQL", totalRows),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MaskDSN masks the password in a connection string for safe API responses.
|
||||
func MaskDSN(rawDSN string) string {
|
||||
rawDSN = strings.TrimSpace(rawDSN)
|
||||
if rawDSN == "" {
|
||||
return ""
|
||||
}
|
||||
if u, err := url.Parse(rawDSN); err == nil && u.User != nil {
|
||||
if pass, hasPassword := u.User.Password(); hasPassword && pass != "" {
|
||||
rawUserPass := u.User.String()
|
||||
user := u.User.Username()
|
||||
maskedUserPass := user + ":******"
|
||||
return strings.Replace(rawDSN, rawUserPass+"@", maskedUserPass+"@", 1)
|
||||
}
|
||||
}
|
||||
// Fallback for keyword-style DSN (e.g. host=... password=...)
|
||||
if strings.Contains(rawDSN, "password=") {
|
||||
parts := strings.Fields(rawDSN)
|
||||
for i, p := range parts {
|
||||
if strings.HasPrefix(p, "password=") {
|
||||
parts[i] = "password=******"
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
return rawDSN
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
func TestMaskDSN(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
in: "postgres://admin:secret123@localhost:5432/mmtl?sslmode=disable",
|
||||
want: "postgres://admin:******@localhost:5432/mmtl?sslmode=disable",
|
||||
},
|
||||
{
|
||||
in: "host=localhost port=5432 user=admin password=secret dbname=mmtl sslmode=disable",
|
||||
want: "host=localhost port=5432 user=admin password=****** dbname=mmtl sslmode=disable",
|
||||
},
|
||||
{
|
||||
in: "sqlite://data/mmtl.db",
|
||||
want: "sqlite://data/mmtl.db",
|
||||
},
|
||||
{
|
||||
in: "",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got := MaskDSN(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("MaskDSN(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectDatabaseStatus(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = db.Create(&model.User{Username: "testuser", PasswordHash: "h", Role: "user"}).Error
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Database.Type = "sqlite"
|
||||
cfg.Database.DBPath = "./data/mmtl.db"
|
||||
|
||||
st := InspectDatabaseStatus(db, cfg)
|
||||
if st == nil {
|
||||
t.Fatal("expected non-nil DatabaseStatus")
|
||||
}
|
||||
if st.Type != "sqlite" {
|
||||
t.Fatalf("expected sqlite, got %s", st.Type)
|
||||
}
|
||||
if st.DBPath != "./data/mmtl.db" {
|
||||
t.Fatalf("expected db_path, got %s", st.DBPath)
|
||||
}
|
||||
if st.TableCounts["users"] != 1 {
|
||||
t.Fatalf("expected 1 user, got %d", st.TableCounts["users"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
func TestOpenRequiresConfig(t *testing.T) {
|
||||
db, err := Open(nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected nil config to return an error")
|
||||
}
|
||||
if db != nil {
|
||||
t.Fatal("db should be nil when config is missing")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "database config") {
|
||||
t.Fatalf("error = %v, want database config message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSQLiteWithNilLoggerConfiguresPool(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Database.Type = "sqlite"
|
||||
cfg.Database.DBPath = filepath.Join(t.TempDir(), "mmtl.db")
|
||||
cfg.Database.WALMode = true
|
||||
cfg.Database.BusyTimeout = 5000
|
||||
cfg.Database.CacheSize = -2000
|
||||
cfg.Database.MaxOpenConns = 3
|
||||
cfg.Database.MaxIdleConns = 2
|
||||
|
||||
db, err := Open(cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
if err := db.Exec("SELECT 1").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stats := sqlDB.Stats()
|
||||
if stats.MaxOpenConnections != 3 {
|
||||
t.Fatalf("MaxOpenConnections = %d, want 3", stats.MaxOpenConnections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePerformanceIndexesCreatesHotPathIndexes(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Media{}, &model.Favorite{}, &model.PlaybackHistory{}, &model.PlayProfile{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensurePerformanceIndexes(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{
|
||||
"idx_media_library_created_active",
|
||||
"idx_media_library_episode_active",
|
||||
"idx_favorites_user_media_active",
|
||||
"idx_playback_histories_user_media_active",
|
||||
"idx_play_profiles_user_created_active",
|
||||
} {
|
||||
var count int
|
||||
if err := db.Raw(`SELECT COUNT(1) FROM sqlite_master WHERE type = 'index' AND name = ?`, name).Scan(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("index %s count = %d, want 1", name, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureMediaSearchIndexCreatesVersionedTriggers(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensureMediaSearchIndex(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sqliteFTSTableExists(t, db, "media_search_fts") {
|
||||
t.Skip("SQLite FTS5 is unavailable in this build")
|
||||
}
|
||||
var version int
|
||||
if err := db.Raw(`SELECT version FROM media_search_meta WHERE id = 1`).Scan(&version).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version != mediaSearchIndexSchemaVersion {
|
||||
t.Fatalf("media search schema version = %d, want %d", version, mediaSearchIndexSchemaVersion)
|
||||
}
|
||||
for _, trigger := range []string{"media_search_fts_ai", "media_search_fts_au", "media_search_fts_ad"} {
|
||||
var count int
|
||||
if err := db.Raw(`SELECT COUNT(1) FROM sqlite_master WHERE type = 'trigger' AND name = ?`, trigger).Scan(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("trigger %s count = %d, want 1", trigger, count)
|
||||
}
|
||||
}
|
||||
media := model.Media{LibraryID: "lib-1", Title: "中文搜索电影", Path: "/media/movie.mkv", Genres: "动画,冒险"}
|
||||
if err := db.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var indexed int
|
||||
if err := db.Raw(`SELECT COUNT(1) FROM media_search_fts WHERE media_id = ?`, media.ID).Scan(&indexed).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if indexed != 1 {
|
||||
t.Fatalf("indexed rows = %d, want inserted media indexed", indexed)
|
||||
}
|
||||
}
|
||||
|
||||
func sqliteFTSTableExists(t *testing.T, db *gorm.DB, table string) bool {
|
||||
t.Helper()
|
||||
var count int
|
||||
if err := db.Raw(`SELECT COUNT(1) FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return count == 1
|
||||
}
|
||||
|
||||
func TestCopyModelTablesMigratesExistingSQLiteRows(t *testing.T) {
|
||||
src, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dst, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, db := range []*gorm.DB{src, dst} {
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Setting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
user := model.User{Username: "admin", PasswordHash: "hash", Role: "admin", IsActive: true}
|
||||
if err := src.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lib := model.Library{Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||
if err := src.Create(&lib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := src.Create(&model.Setting{Key: "organize.auto", Value: "false"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, copied, err := copyModelTables(src, dst, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if copied != 3 {
|
||||
t.Fatalf("copied rows = %d, want 3", copied)
|
||||
}
|
||||
var got model.User
|
||||
if err := dst.First(&got, "username = ?", "admin").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ID != user.ID || got.Role != "admin" {
|
||||
t.Fatalf("user not preserved: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyModelTablesResumesPartialSQLiteMigration(t *testing.T) {
|
||||
src, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dst, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, db := range []*gorm.DB{src, dst} {
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Media{}, &model.Setting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
user := model.User{Username: "admin", PasswordHash: "hash", Role: "admin", IsActive: true}
|
||||
if err := src.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := dst.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{
|
||||
LibraryID: "library-1",
|
||||
Title: "Resume Migration",
|
||||
Path: "/media/resume.mp4",
|
||||
Container: "mov,mp4,m4a,3gp,3g2,mj2",
|
||||
ScrapeStatus: "matched",
|
||||
OriginalName: "Resume Migration",
|
||||
PosterURL: "/media/poster.jpg",
|
||||
BackdropURL: "/media/backdrop.jpg",
|
||||
VideoCodec: "hevc",
|
||||
AudioCodec: "eac3",
|
||||
DurationSec: 120,
|
||||
Genres: "家庭,动画,冒险,喜剧,奇幻,Peter Del Vecho,Jeff Draheim,詹妮弗·李,克里斯·巴克,伊迪娜·门泽尔,克里斯汀·贝尔,乔什·盖德,乔纳森·格罗夫,埃文·蕾切尔·伍德,斯特林·K·布朗",
|
||||
SizeBytes: 1024,
|
||||
Width: 3840,
|
||||
Height: 2160,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
}
|
||||
if err := src.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := src.Create(&model.Setting{Key: "organize.auto", Value: "false"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, copied, err := copyModelTables(src, dst, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if copied != 2 {
|
||||
t.Fatalf("copied rows = %d, want 2", copied)
|
||||
}
|
||||
var got model.Media
|
||||
if err := dst.First(&got, "path = ?", media.Path).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Container != media.Container {
|
||||
t.Fatalf("container = %q, want %q", got.Container, media.Container)
|
||||
}
|
||||
if got.Genres != media.Genres {
|
||||
t.Fatalf("genres = %q, want %q", got.Genres, media.Genres)
|
||||
}
|
||||
|
||||
_, copied, err = copyModelTables(src, dst, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if copied != 0 {
|
||||
t.Fatalf("second copy rows = %d, want 0", copied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteMigrationCompleteMarker(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Setting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
complete, err := sqliteMigrationMarkedComplete(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if complete {
|
||||
t.Fatal("fresh database should not be marked migrated")
|
||||
}
|
||||
if err := markSQLiteMigrationComplete(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
complete, err = sqliteMigrationMarkedComplete(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !complete {
|
||||
t.Fatal("database should be marked migrated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteMigrationFallsBackToDataDirDefaultPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sqlitePath := filepath.Join(dir, "mmtl.db")
|
||||
src, err := gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := src.AutoMigrate(&model.User{}, &model.Library{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := model.User{Username: "real-admin", PasswordHash: "hash", Role: "admin", IsActive: true}
|
||||
if err := src.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lib := model.Library{Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||
if err := src.Create(&lib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := src.DB()
|
||||
_ = sqlDB.Close()
|
||||
|
||||
dst, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := dst.AutoMigrate(&model.User{}, &model.Library{}, &model.Setting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := dst.Create(&model.User{Username: "admin", PasswordHash: "bootstrap", Role: "admin", IsActive: true}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.App.DataDir = dir
|
||||
cfg.Database.DBPath = filepath.Join(dir, "disabled-sqlite-migration.db")
|
||||
sourcePath, err := sqliteMigrationSourcePath(cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sourcePath != sqlitePath {
|
||||
t.Fatalf("source path = %q, want fallback %q", sourcePath, sqlitePath)
|
||||
}
|
||||
|
||||
src2, err := gorm.Open(sqlite.Open(sourcePath), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB2, _ := src2.DB()
|
||||
defer func() {
|
||||
if sqlDB2 != nil {
|
||||
_ = sqlDB2.Close()
|
||||
}
|
||||
}()
|
||||
if err := resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src2, dst, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, copied, err := copyModelTables(src2, dst, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if copied != 2 {
|
||||
t.Fatalf("copied rows = %d, want 2", copied)
|
||||
}
|
||||
|
||||
var userCount int64
|
||||
if err := dst.Model(&model.User{}).Count(&userCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if userCount != 1 {
|
||||
t.Fatalf("user count = %d, want migrated source only", userCount)
|
||||
}
|
||||
var got model.User
|
||||
if err := dst.First(&got, "username = ?", "real-admin").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var libCount int64
|
||||
if err := dst.Model(&model.Library{}).Where("path = ?", "/media/movies").Count(&libCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if libCount != 1 {
|
||||
t.Fatalf("library count = %d, want 1", libCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSQLiteMigrationSourceUsesFallbackSourcePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sqlitePath := filepath.Join(dir, "mmtl.db")
|
||||
src, err := gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := src.AutoMigrate(&model.User{}, &model.Library{}, &model.Setting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := src.Create(&model.User{Username: "real-admin", PasswordHash: "hash", Role: "admin", IsActive: true}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := src.Create(&model.Library{Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := src.DB()
|
||||
_ = sqlDB.Close()
|
||||
|
||||
dst, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := dst.AutoMigrate(&model.User{}, &model.Library{}, &model.Setting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.App.DataDir = dir
|
||||
cfg.Database.DBPath = filepath.Join(dir, "disabled-sqlite-migration.db")
|
||||
sourcePath, err := sqliteMigrationSourcePath(cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sourcePath != sqlitePath {
|
||||
t.Fatalf("source path = %q, want %q", sourcePath, sqlitePath)
|
||||
}
|
||||
|
||||
src2, err := openSQLiteMigrationSource(cfg, sourcePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB2, _ := src2.DB()
|
||||
defer func() {
|
||||
if sqlDB2 != nil {
|
||||
_ = sqlDB2.Close()
|
||||
}
|
||||
}()
|
||||
_, copied, err := copyModelTables(src2, dst, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if copied != 2 {
|
||||
t.Fatalf("copied rows = %d, want 2", copied)
|
||||
}
|
||||
var userCount int64
|
||||
if err := dst.Model(&model.User{}).Where("username = ?", "real-admin").Count(&userCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if userCount != 1 {
|
||||
t.Fatalf("migrated user count = %d, want 1", userCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
func ensureLibraryRootsCompatibility(db *gorm.DB) error {
|
||||
var libraries []model.Library
|
||||
if err := db.Find(&libraries).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, lib := range libraries {
|
||||
if strings.TrimSpace(lib.Path) == "" {
|
||||
continue
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&model.LibraryRoot{}).Where("library_id = ?", lib.ID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
continue
|
||||
}
|
||||
root := model.LibraryRoot{
|
||||
LibraryID: lib.ID,
|
||||
Name: firstLibraryRootLabel(lib.Path),
|
||||
Path: lib.Path,
|
||||
Enabled: lib.Enabled,
|
||||
SortOrder: 0,
|
||||
}
|
||||
if err := db.Create(&root).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillLibraryRootMedia(db, lib, root); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func backfillLibraryRootMedia(db *gorm.DB, lib model.Library, root model.LibraryRoot) error {
|
||||
var rows []model.Media
|
||||
if err := db.Unscoped().
|
||||
Model(&model.Media{}).
|
||||
Select("id", "path").
|
||||
Where("library_id = ? AND (library_root_id = '' OR library_root_id IS NULL)", lib.ID).
|
||||
Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
rootPath := strings.TrimSpace(root.Path)
|
||||
for _, row := range rows {
|
||||
rel, ok := relativePathWithinRoot(row.Path, rootPath)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := db.Unscoped().Model(&model.Media{}).Where("id = ?", row.ID).Updates(map[string]any{
|
||||
"library_root_id": root.ID,
|
||||
"relative_path": rel,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func relativePathWithinRoot(pathValue, root string) (string, bool) {
|
||||
pathValue = strings.TrimSpace(pathValue)
|
||||
root = strings.TrimSpace(root)
|
||||
if pathValue == "" || root == "" {
|
||||
return "", false
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(root), "cloud://") || strings.HasPrefix(strings.ToLower(pathValue), "cloud://") {
|
||||
prefix := strings.TrimRight(root, "/") + "/"
|
||||
if strings.EqualFold(pathValue, root) {
|
||||
return "", true
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(pathValue), strings.ToLower(prefix)) {
|
||||
return strings.TrimPrefix(pathValue, prefix), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
cleanPath := filepath.Clean(pathValue)
|
||||
cleanRoot := filepath.Clean(root)
|
||||
rel, err := filepath.Rel(cleanRoot, cleanPath)
|
||||
if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
|
||||
return "", false
|
||||
}
|
||||
return rel, true
|
||||
}
|
||||
|
||||
func firstLibraryRootLabel(pathValue string) string {
|
||||
pathValue = strings.TrimSpace(pathValue)
|
||||
if pathValue == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(pathValue), "cloud://") {
|
||||
parts := strings.Split(strings.Trim(pathValue, "/"), "/")
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
}
|
||||
base := filepath.Base(filepath.Clean(pathValue))
|
||||
if base == "." || base == string(filepath.Separator) {
|
||||
return pathValue
|
||||
}
|
||||
return base
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package database
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
// mediaSearchIndexSchemaVersion identifies the physical FTS index layout.
|
||||
// v2 aligns FTS rowids with media rowids and keeps the index current with
|
||||
// triggers.
|
||||
const mediaSearchIndexSchemaVersion = 2
|
||||
|
||||
func ensureMediaSearchIndex(db *gorm.DB) error {
|
||||
if err := ensureMediaSearchMetaTable(db); err != nil {
|
||||
return nil
|
||||
}
|
||||
version := currentMediaSearchIndexVersion(db)
|
||||
if version != mediaSearchIndexSchemaVersion {
|
||||
resetMediaSearchIndex(db)
|
||||
}
|
||||
if !createMediaSearchFTSTable(db) {
|
||||
return nil
|
||||
}
|
||||
if err := createMediaSearchTriggers(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if version != mediaSearchIndexSchemaVersion {
|
||||
return markMediaSearchIndexVersion(db)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureMediaSearchMetaTable(db *gorm.DB) error {
|
||||
return db.Exec(`CREATE TABLE IF NOT EXISTS media_search_meta (id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL)`).Error
|
||||
}
|
||||
|
||||
func currentMediaSearchIndexVersion(db *gorm.DB) int {
|
||||
var version int
|
||||
_ = db.Raw(`SELECT version FROM media_search_meta WHERE id = 1`).Scan(&version).Error
|
||||
return version
|
||||
}
|
||||
|
||||
func resetMediaSearchIndex(db *gorm.DB) {
|
||||
for _, stmt := range []string{
|
||||
`DROP TRIGGER IF EXISTS media_search_fts_ai`,
|
||||
`DROP TRIGGER IF EXISTS media_search_fts_au`,
|
||||
`DROP TRIGGER IF EXISTS media_search_fts_ad`,
|
||||
`DROP TABLE IF EXISTS media_search_fts`,
|
||||
} {
|
||||
_ = db.Exec(stmt).Error
|
||||
}
|
||||
}
|
||||
|
||||
func createMediaSearchFTSTable(db *gorm.DB) bool {
|
||||
if err := db.Exec(`CREATE VIRTUAL TABLE IF NOT EXISTS media_search_fts USING fts5(media_id UNINDEXED, title, original_name, path, genres, tokenize='trigram')`).Error; err == nil {
|
||||
return true
|
||||
}
|
||||
if err := db.Exec(`CREATE VIRTUAL TABLE IF NOT EXISTS media_search_fts USING fts5(media_id UNINDEXED, title, original_name, path, genres, tokenize='unicode61')`).Error; err == nil {
|
||||
return true
|
||||
}
|
||||
// FTS is an acceleration path. Some embedded SQLite builds may omit FTS5;
|
||||
// keep startup working and let repository queries fall back to LIKE search.
|
||||
return false
|
||||
}
|
||||
|
||||
func createMediaSearchTriggers(db *gorm.DB) error {
|
||||
for _, stmt := range mediaSearchTriggerStatements {
|
||||
if err := db.Exec(stmt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var mediaSearchTriggerStatements = []string{
|
||||
`CREATE TRIGGER IF NOT EXISTS media_search_fts_ai AFTER INSERT ON media WHEN new.deleted_at IS NULL BEGIN
|
||||
DELETE FROM media_search_fts WHERE rowid = new.rowid;
|
||||
INSERT INTO media_search_fts(rowid, media_id, title, original_name, path, genres)
|
||||
VALUES (new.rowid, new.id, COALESCE(new.title, ''), COALESCE(new.original_name, ''), COALESCE(new.path, ''), COALESCE(new.genres, ''));
|
||||
END`,
|
||||
`CREATE TRIGGER IF NOT EXISTS media_search_fts_au AFTER UPDATE OF title, original_name, path, genres, deleted_at ON media BEGIN
|
||||
DELETE FROM media_search_fts WHERE rowid = old.rowid;
|
||||
INSERT INTO media_search_fts(rowid, media_id, title, original_name, path, genres)
|
||||
SELECT new.rowid, new.id, COALESCE(new.title, ''), COALESCE(new.original_name, ''), COALESCE(new.path, ''), COALESCE(new.genres, '')
|
||||
WHERE new.deleted_at IS NULL;
|
||||
END`,
|
||||
`CREATE TRIGGER IF NOT EXISTS media_search_fts_ad AFTER DELETE ON media BEGIN
|
||||
DELETE FROM media_search_fts WHERE rowid = old.rowid;
|
||||
END`,
|
||||
}
|
||||
|
||||
func markMediaSearchIndexVersion(db *gorm.DB) error {
|
||||
return db.Exec(`INSERT INTO media_search_meta(id, version) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET version = excluded.version`, mediaSearchIndexSchemaVersion).Error
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
// AutoMigrate creates tables for every model registered in the model package.
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensurePostgresColumnCompatibility(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensurePerformanceIndexes(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureLibraryRootsCompatibility(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureEmbyMountsCompatibility(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if isSQLite(db) {
|
||||
if err := ensureMediaSearchIndex(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureSQLiteQueryOptimizer(db)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureSQLiteQueryOptimizer(db *gorm.DB) error {
|
||||
// Refresh planner statistics so indexes on large media tables are used.
|
||||
return db.Exec("ANALYZE").Error
|
||||
}
|
||||
|
||||
func ensurePostgresColumnCompatibility(db *gorm.DB) error {
|
||||
if !isPostgres(db) {
|
||||
return nil
|
||||
}
|
||||
statements := []string{
|
||||
`ALTER TABLE media ALTER COLUMN container TYPE varchar(128)`,
|
||||
`ALTER TABLE media ALTER COLUMN genres TYPE text`,
|
||||
`ALTER TABLE media ALTER COLUMN series_id TYPE varchar(128)`,
|
||||
`ALTER TABLE media ALTER COLUMN duplicate_of TYPE varchar(128)`,
|
||||
`ALTER TABLE playback_histories ALTER COLUMN media_id TYPE varchar(128)`,
|
||||
`ALTER TABLE favorites ALTER COLUMN media_id TYPE varchar(128)`,
|
||||
`ALTER TABLE playlist_items ALTER COLUMN media_id TYPE varchar(128)`,
|
||||
}
|
||||
for _, stmt := range statements {
|
||||
if err := db.Exec(stmt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensurePerformanceIndexes(db *gorm.DB) error {
|
||||
statements := []string{
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_library_created_active ON media(library_id, created_at DESC) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_library_release_active ON media(library_id, release_date DESC, year DESC) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_library_episode_active ON media(library_id, season_num, episode_num, created_at DESC) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_library_root_active ON media(library_id, library_root_id) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_series_active ON media(series_id, season_num, episode_num) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_favorites_user_media_active ON favorites(user_id, media_id) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_playback_histories_user_media_active ON playback_histories(user_id, media_id, watched_at DESC) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_playback_histories_resume_active ON playback_histories(user_id, completed, watched_at DESC) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_play_profiles_user_created_active ON play_profiles(user_id, created_at DESC) WHERE deleted_at IS NULL`,
|
||||
}
|
||||
if isSQLite(db) {
|
||||
statements = append(statements,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_title_active ON media(title COLLATE NOCASE) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_original_name_active ON media(original_name COLLATE NOCASE) WHERE deleted_at IS NULL`,
|
||||
)
|
||||
} else {
|
||||
statements = append(statements,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_title_active ON media(title) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_original_name_active ON media(original_name) WHERE deleted_at IS NULL`,
|
||||
)
|
||||
}
|
||||
for _, stmt := range statements {
|
||||
if err := db.Exec(stmt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureEmbyMountsCompatibility(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&model.EmbyMount{}) {
|
||||
return nil
|
||||
}
|
||||
if !db.Migrator().HasColumn(&model.EmbyMount{}, "sort_order") {
|
||||
if err := db.Migrator().AddColumn(&model.EmbyMount{}, "sort_order"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// 针对已有数据:如果存在多个 sort_order=0/NULL 的记录,按创建时间顺序赋予稳定递增的序号
|
||||
var zeroCount int64
|
||||
if err := db.Model(&model.EmbyMount{}).Where("sort_order = 0 OR sort_order IS NULL").Count(&zeroCount).Error; err == nil && zeroCount > 1 {
|
||||
var mounts []model.EmbyMount
|
||||
if err := db.Order("created_at asc, id asc").Find(&mounts).Error; err == nil {
|
||||
for i, m := range mounts {
|
||||
_ = db.Exec("UPDATE emby_mounts SET sort_order = ? WHERE id = ?", i, m.ID).Error
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
func TestEnsureEmbyMountsCompatibility(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a table without sort_order simulating an older schema
|
||||
if err := db.Exec(`CREATE TABLE emby_mounts (
|
||||
id varchar(36) PRIMARY KEY,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
deleted_at datetime,
|
||||
account_id text,
|
||||
remote_view_id text,
|
||||
remote_view_name text,
|
||||
collection_type text,
|
||||
name text,
|
||||
proxy_play numeric DEFAULT false,
|
||||
enabled numeric DEFAULT true
|
||||
)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Insert older rows
|
||||
now := time.Now()
|
||||
_ = db.Exec("INSERT INTO emby_mounts (id, name, created_at) VALUES (?, ?, ?)", "m1", "Mount 1", now.Add(-2*time.Hour)).Error
|
||||
_ = db.Exec("INSERT INTO emby_mounts (id, name, created_at) VALUES (?, ?, ?)", "m2", "Mount 2", now.Add(-1*time.Hour)).Error
|
||||
|
||||
// Run compatibility migration
|
||||
if err := ensureEmbyMountsCompatibility(db); err != nil {
|
||||
t.Fatalf("ensureEmbyMountsCompatibility failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify column sort_order exists and values are initialized sequentially
|
||||
if !db.Migrator().HasColumn(&model.EmbyMount{}, "sort_order") {
|
||||
t.Fatal("expected sort_order column to be added")
|
||||
}
|
||||
|
||||
var m1, m2 model.EmbyMount
|
||||
if err := db.Where("id = ?", "m1").First(&m1).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Where("id = ?", "m2").First(&m2).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if m1.SortOrder != 0 || m2.SortOrder != 1 {
|
||||
t.Fatalf("unexpected sort orders: m1=%d, m2=%d", m1.SortOrder, m2.SortOrder)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
)
|
||||
|
||||
// MigrateSQLiteToCurrentIfNeeded copies an existing SQLite database into
|
||||
// PostgreSQL. Redis is not migrated because it is a rebuildable cache, not a
|
||||
// source of truth.
|
||||
const sqliteMigrationCompleteSettingKey = "database.sqlite_migration_complete"
|
||||
|
||||
func MigrateSQLiteToCurrentIfNeeded(cfg *config.Config, target *gorm.DB, log *zap.Logger) error {
|
||||
if cfg == nil || target == nil || target.Dialector == nil || target.Dialector.Name() != "postgres" {
|
||||
return nil
|
||||
}
|
||||
sqlitePath, err := sqliteMigrationSourcePath(cfg, log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sqlitePath == "" {
|
||||
return nil
|
||||
}
|
||||
if complete, err := sqliteMigrationMarkedComplete(target); err != nil {
|
||||
return err
|
||||
} else if complete {
|
||||
if log != nil {
|
||||
log.Info("skip sqlite to postgres migration: already completed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
src, err := openSQLiteMigrationSource(cfg, sqlitePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open sqlite migration source: %w", err)
|
||||
}
|
||||
sqlDB, err := src.DB()
|
||||
if err == nil {
|
||||
defer sqlDB.Close()
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
if err := resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src, target, log); err != nil {
|
||||
return err
|
||||
}
|
||||
_, copied, err := copyModelTables(src, target, 500)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := markSQLiteMigrationComplete(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if log != nil {
|
||||
log.Info("sqlite data migrated to postgres",
|
||||
zap.String("source", sqlitePath),
|
||||
zap.Int64("rows", copied),
|
||||
zap.Duration("duration", time.Since(started)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
func resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src, target *gorm.DB, log *zap.Logger) error {
|
||||
hasRows, err := sqliteSourceHasMigratableRows(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasRows {
|
||||
return nil
|
||||
}
|
||||
bootstrapOnly, err := targetLooksLikeBootstrapOnly(target)
|
||||
if err != nil || !bootstrapOnly {
|
||||
return err
|
||||
}
|
||||
for i := len(model.AllModels()) - 1; i >= 0; i-- {
|
||||
m := model.AllModels()[i]
|
||||
if !target.Migrator().HasTable(m) {
|
||||
continue
|
||||
}
|
||||
if err := target.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(m).Error; err != nil {
|
||||
return fmt.Errorf("clear bootstrap target table %T: %w", m, err)
|
||||
}
|
||||
}
|
||||
if log != nil {
|
||||
log.Warn("cleared bootstrap postgres rows before sqlite migration")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteSourceHasMigratableRows(src *gorm.DB) (bool, error) {
|
||||
for _, table := range []string{"users", "libraries", "media", "settings"} {
|
||||
exists, err := sqliteTableExists(src, table)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
var count int64
|
||||
if err := src.Raw("SELECT COUNT(1) FROM " + quoteIdent(table)).Scan(&count).Error; err != nil {
|
||||
return false, fmt.Errorf("count sqlite table %s: %w", table, err)
|
||||
}
|
||||
if count > 0 {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func targetLooksLikeBootstrapOnly(target *gorm.DB) (bool, error) {
|
||||
for _, m := range []any{
|
||||
&model.Library{},
|
||||
&model.Series{},
|
||||
&model.Media{},
|
||||
&model.PlaybackHistory{},
|
||||
&model.Favorite{},
|
||||
&model.Playlist{},
|
||||
&model.PlaylistItem{},
|
||||
} {
|
||||
if !target.Migrator().HasTable(m) {
|
||||
continue
|
||||
}
|
||||
var count int64
|
||||
if err := target.Unscoped().Model(m).Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if count > 0 {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
var userCount int64
|
||||
if !target.Migrator().HasTable(&model.User{}) {
|
||||
return true, nil
|
||||
}
|
||||
if err := target.Model(&model.User{}).Count(&userCount).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if userCount == 0 {
|
||||
return true, nil
|
||||
}
|
||||
if userCount != 1 {
|
||||
return false, nil
|
||||
}
|
||||
var user model.User
|
||||
if err := target.Unscoped().Where("username = ?", "admin").First(&user).Error; err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return user.Role == "admin", nil
|
||||
}
|
||||
|
||||
func sqliteMigrationMarkedComplete(db *gorm.DB) (bool, error) {
|
||||
var value string
|
||||
err := db.Raw("SELECT value FROM "+quoteIdent("settings")+" WHERE "+quoteIdent("key")+" = ?", sqliteMigrationCompleteSettingKey).Scan(&value).Error
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check sqlite migration marker: %w", err)
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(value), "true"), nil
|
||||
}
|
||||
|
||||
func markSQLiteMigrationComplete(db *gorm.DB) error {
|
||||
now := time.Now()
|
||||
if err := db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value", "updated_at"}),
|
||||
}).Create(&model.Setting{
|
||||
Key: sqliteMigrationCompleteSettingKey,
|
||||
Value: "true",
|
||||
UpdatedAt: now,
|
||||
}).Error; err != nil {
|
||||
return fmt.Errorf("mark sqlite migration complete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/schema"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
func copyModelTables(src, target *gorm.DB, batchSize int) (map[string]int64, int64, error) {
|
||||
if batchSize <= 0 {
|
||||
batchSize = 500
|
||||
}
|
||||
tableCounts := make(map[string]int64)
|
||||
var totalCopied int64
|
||||
for _, m := range model.AllModels() {
|
||||
table, err := modelTableName(src, m)
|
||||
if err != nil {
|
||||
return tableCounts, totalCopied, err
|
||||
}
|
||||
primaryColumns, err := modelPrimaryColumns(src, m)
|
||||
if err != nil {
|
||||
return tableCounts, totalCopied, fmt.Errorf("inspect model %T primary keys: %w", m, err)
|
||||
}
|
||||
exists, err := sqliteTableExists(src, table)
|
||||
if err != nil {
|
||||
return tableCounts, totalCopied, err
|
||||
}
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
var sourceCount int64
|
||||
if err := src.Raw("SELECT COUNT(1) FROM " + quoteIdent(table)).Scan(&sourceCount).Error; err != nil {
|
||||
return tableCounts, totalCopied, fmt.Errorf("count sqlite table %s: %w", table, err)
|
||||
}
|
||||
if sourceCount == 0 {
|
||||
continue
|
||||
}
|
||||
var targetCount int64
|
||||
if err := target.Raw("SELECT COUNT(1) FROM " + quoteIdent(table)).Scan(&targetCount).Error; err != nil {
|
||||
return tableCounts, totalCopied, fmt.Errorf("count target table %s: %w", table, err)
|
||||
}
|
||||
modelType := reflect.TypeOf(m)
|
||||
if modelType.Kind() != reflect.Ptr {
|
||||
return tableCounts, totalCopied, fmt.Errorf("model %T is not a pointer", m)
|
||||
}
|
||||
sliceType := reflect.SliceOf(modelType.Elem())
|
||||
slicePtr := reflect.New(sliceType)
|
||||
if err := src.Unscoped().Find(slicePtr.Interface()).Error; err != nil {
|
||||
return tableCounts, totalCopied, fmt.Errorf("read sqlite table %s: %w", table, err)
|
||||
}
|
||||
filtered := slicePtr.Elem()
|
||||
if targetCount > 0 {
|
||||
primaryKeySet, err := targetPrimaryKeySet(target, table, primaryColumns)
|
||||
if err != nil {
|
||||
return tableCounts, totalCopied, err
|
||||
}
|
||||
filtered = filterRowsMissingInTarget(target, table, primaryColumns, filtered, primaryKeySet)
|
||||
}
|
||||
if filtered.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
filteredPtr := reflect.New(filtered.Type())
|
||||
filteredPtr.Elem().Set(filtered)
|
||||
if err := target.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(filteredPtr.Interface(), batchSize).Error; err != nil {
|
||||
return tableCounts, totalCopied, fmt.Errorf("copy sqlite table %s: %w", table, err)
|
||||
}
|
||||
copiedForTable := int64(filtered.Len())
|
||||
tableCounts[table] = copiedForTable
|
||||
totalCopied += copiedForTable
|
||||
}
|
||||
return tableCounts, totalCopied, nil
|
||||
}
|
||||
|
||||
func modelPrimaryColumns(db *gorm.DB, m any) ([]string, error) {
|
||||
stmt := &gorm.Statement{DB: db}
|
||||
if err := stmt.Parse(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cols []string
|
||||
for _, field := range stmt.Schema.PrimaryFields {
|
||||
cols = append(cols, field.DBName)
|
||||
}
|
||||
if len(cols) == 0 {
|
||||
return nil, fmt.Errorf("no primary key columns")
|
||||
}
|
||||
return cols, nil
|
||||
}
|
||||
|
||||
func targetPrimaryKeySet(target *gorm.DB, table string, primaryColumns []string) (map[string]struct{}, error) {
|
||||
if len(primaryColumns) != 1 {
|
||||
return nil, nil
|
||||
}
|
||||
var values []string
|
||||
if err := target.Raw("SELECT " + quoteIdent(primaryColumns[0]) + " FROM " + quoteIdent(table)).Scan(&values).Error; err != nil {
|
||||
return nil, fmt.Errorf("read target primary keys for table %s: %w", table, err)
|
||||
}
|
||||
set := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
set[value] = struct{}{}
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
|
||||
func filterRowsMissingInTarget(target *gorm.DB, table string, primaryColumns []string, rows reflect.Value, primaryKeySet map[string]struct{}) reflect.Value {
|
||||
if rows.Kind() != reflect.Slice || rows.Len() == 0 || len(primaryColumns) == 0 {
|
||||
return rows
|
||||
}
|
||||
out := reflect.MakeSlice(rows.Type(), 0, rows.Len())
|
||||
for i := 0; i < rows.Len(); i++ {
|
||||
row := rows.Index(i)
|
||||
keys, ok := rowPrimaryKeys(row, primaryColumns)
|
||||
if !ok {
|
||||
out = reflect.Append(out, row)
|
||||
continue
|
||||
}
|
||||
if primaryKeySet != nil {
|
||||
if _, exists := primaryKeySet[fmt.Sprint(keys[primaryColumns[0]])]; !exists {
|
||||
out = reflect.Append(out, row)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !targetHasPrimaryKey(target, table, keys) {
|
||||
out = reflect.Append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func rowPrimaryKeys(row reflect.Value, primaryColumns []string) (map[string]any, bool) {
|
||||
if row.Kind() == reflect.Pointer {
|
||||
if row.IsNil() {
|
||||
return nil, false
|
||||
}
|
||||
row = row.Elem()
|
||||
}
|
||||
if row.Kind() != reflect.Struct {
|
||||
return nil, false
|
||||
}
|
||||
keys := make(map[string]any, len(primaryColumns))
|
||||
for _, column := range primaryColumns {
|
||||
value, ok := fieldByDBName(row, column)
|
||||
if !ok || value.IsZero() {
|
||||
return nil, false
|
||||
}
|
||||
keys[column] = value.Interface()
|
||||
}
|
||||
return keys, true
|
||||
}
|
||||
|
||||
func fieldByDBName(row reflect.Value, column string) (reflect.Value, bool) {
|
||||
rowType := row.Type()
|
||||
for i := 0; i < row.NumField(); i++ {
|
||||
fieldType := rowType.Field(i)
|
||||
field := row.Field(i)
|
||||
if fieldType.Anonymous {
|
||||
if value, ok := fieldByDBName(field, column); ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
if columnNameForStructField(fieldType) == column {
|
||||
if field.Kind() == reflect.Pointer && field.IsNil() {
|
||||
return reflect.Value{}, false
|
||||
}
|
||||
return field, field.CanInterface()
|
||||
}
|
||||
}
|
||||
return reflect.Value{}, false
|
||||
}
|
||||
|
||||
func columnNameForStructField(field reflect.StructField) string {
|
||||
if field.PkgPath != "" && !field.Anonymous {
|
||||
return ""
|
||||
}
|
||||
tag := field.Tag.Get("gorm")
|
||||
settings := schema.ParseTagSetting(tag, ";")
|
||||
if column := settings["COLUMN"]; column != "" {
|
||||
return column
|
||||
}
|
||||
return schema.NamingStrategy{}.ColumnName("", field.Name)
|
||||
}
|
||||
|
||||
func targetHasPrimaryKey(target *gorm.DB, table string, keys map[string]any) bool {
|
||||
where := make([]string, 0, len(keys))
|
||||
args := make([]any, 0, len(keys))
|
||||
for _, column := range sortedMapKeys(keys) {
|
||||
where = append(where, quoteIdent(column)+" = ?")
|
||||
args = append(args, keys[column])
|
||||
}
|
||||
var count int64
|
||||
err := target.Raw("SELECT COUNT(1) FROM "+quoteIdent(table)+" WHERE "+strings.Join(where, " AND "), args...).Scan(&count).Error
|
||||
return err == nil && count > 0
|
||||
}
|
||||
|
||||
func sortedMapKeys(m map[string]any) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for key := range m {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func sqliteTableExists(db *gorm.DB, table string) (bool, error) {
|
||||
var count int64
|
||||
if err := db.Raw(`SELECT COUNT(1) FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&count).Error; err != nil {
|
||||
return false, fmt.Errorf("inspect sqlite table %s: %w", table, err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func modelTableName(db *gorm.DB, m any) (string, error) {
|
||||
stmt := &gorm.Statement{DB: db}
|
||||
if err := stmt.Parse(m); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return stmt.Schema.Table, nil
|
||||
}
|
||||
|
||||
func quoteIdent(value string) string {
|
||||
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
)
|
||||
|
||||
func openSQLiteMigrationSource(cfg *config.Config, sqlitePath string) (*gorm.DB, error) {
|
||||
srcCfg := *cfg
|
||||
srcCfg.Database.Type = "sqlite"
|
||||
srcCfg.Database.DBPath = sqlitePath
|
||||
return gorm.Open(sqlite.Open(buildSQLiteDSN(&srcCfg)), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrationSourcePath(cfg *config.Config, log *zap.Logger) (string, error) {
|
||||
configured := strings.TrimSpace(cfg.Database.DBPath)
|
||||
if configured != "" {
|
||||
exists, err := regularFileExists(configured)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stat sqlite migration source: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return configured, nil
|
||||
}
|
||||
}
|
||||
|
||||
fallback := filepath.Join(strings.TrimSpace(cfg.App.DataDir), "mmtl.db")
|
||||
if fallback == "" || sameCleanPath(configured, fallback) {
|
||||
return "", nil
|
||||
}
|
||||
exists, err := regularFileExists(fallback)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stat default sqlite migration source: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return "", nil
|
||||
}
|
||||
if log != nil && configured != "" {
|
||||
log.Warn("configured sqlite migration source not found; using data-dir default",
|
||||
zap.String("configured", configured),
|
||||
zap.String("fallback", fallback))
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
func regularFileExists(path string) (bool, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return false, nil
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return !info.IsDir(), nil
|
||||
}
|
||||
|
||||
func sameCleanPath(a, b string) bool {
|
||||
if a == "" || b == "" {
|
||||
return false
|
||||
}
|
||||
return filepath.Clean(a) == filepath.Clean(b)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
)
|
||||
|
||||
func installSQLiteWriteGate(db *gorm.DB) {
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
const lockedKey = "mmtl:sqlite_write_locked"
|
||||
gate := newSQLiteWriteGate()
|
||||
lock := func(tx *gorm.DB) {
|
||||
ctx := context.Background()
|
||||
if tx.Statement != nil && tx.Statement.Context != nil {
|
||||
ctx = tx.Statement.Context
|
||||
}
|
||||
if err := gate.Lock(ctx); err != nil {
|
||||
_ = tx.AddError(err)
|
||||
return
|
||||
}
|
||||
tx.InstanceSet(lockedKey, struct{}{})
|
||||
}
|
||||
unlock := func(tx *gorm.DB) {
|
||||
if _, ok := tx.InstanceGet(lockedKey); ok {
|
||||
gate.Unlock()
|
||||
}
|
||||
}
|
||||
rawLock := func(tx *gorm.DB) {
|
||||
if tx.Statement != nil && isReadOnlySQL(tx.Statement.SQL.String()) {
|
||||
return
|
||||
}
|
||||
lock(tx)
|
||||
}
|
||||
_ = db.Callback().Create().Before("gorm:create").Register("mmtl:sqlite_write_lock", lock)
|
||||
_ = db.Callback().Create().After("gorm:create").Register("mmtl:sqlite_write_unlock", unlock)
|
||||
_ = db.Callback().Update().Before("gorm:update").Register("mmtl:sqlite_write_lock", lock)
|
||||
_ = db.Callback().Update().After("gorm:update").Register("mmtl:sqlite_write_unlock", unlock)
|
||||
_ = db.Callback().Delete().Before("gorm:delete").Register("mmtl:sqlite_write_lock", lock)
|
||||
_ = db.Callback().Delete().After("gorm:delete").Register("mmtl:sqlite_write_unlock", unlock)
|
||||
_ = db.Callback().Raw().Before("gorm:raw").Register("mmtl:sqlite_write_lock", rawLock)
|
||||
_ = db.Callback().Raw().After("gorm:raw").Register("mmtl:sqlite_write_unlock", unlock)
|
||||
}
|
||||
|
||||
func isReadOnlySQL(sql string) bool {
|
||||
trimmed := strings.TrimSpace(sql)
|
||||
if len(trimmed) == 0 {
|
||||
return false
|
||||
}
|
||||
upper := strings.ToUpper(trimmed)
|
||||
if strings.HasPrefix(upper, "SELECT") || strings.HasPrefix(upper, "EXPLAIN") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(upper, "WITH") && !strings.Contains(upper, "INSERT") && !strings.Contains(upper, "UPDATE") && !strings.Contains(upper, "DELETE") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sqliteWriteGate serializes in-process SQLite writes while respecting the
|
||||
// statement context, so request cancellation can break out of a queued write.
|
||||
type sqliteWriteGate struct {
|
||||
ch chan struct{}
|
||||
}
|
||||
|
||||
func newSQLiteWriteGate() *sqliteWriteGate {
|
||||
return &sqliteWriteGate{ch: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
func (g *sqliteWriteGate) Lock(ctx context.Context) error {
|
||||
select {
|
||||
case g.ch <- struct{}{}:
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
select {
|
||||
case g.ch <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (g *sqliteWriteGate) Unlock() {
|
||||
select {
|
||||
case <-g.ch:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func buildSQLiteDSN(cfg *config.Config) string {
|
||||
dbPath := cfg.Database.DBPath
|
||||
if !filepath.IsAbs(dbPath) {
|
||||
// keep as-is to respect user-provided relative paths.
|
||||
dbPath = filepath.Clean(dbPath)
|
||||
}
|
||||
dsn := dbPath + "?_pragma=foreign_keys(1)"
|
||||
if cfg.Database.WALMode {
|
||||
dsn += "&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)"
|
||||
}
|
||||
if cfg.Database.BusyTimeout > 0 {
|
||||
dsn += fmt.Sprintf("&_pragma=busy_timeout(%d)", cfg.Database.BusyTimeout)
|
||||
}
|
||||
if cfg.Database.CacheSize != 0 {
|
||||
dsn += fmt.Sprintf("&_pragma=cache_size(%d)", cfg.Database.CacheSize)
|
||||
}
|
||||
dsn += "&_pragma=temp_store(MEMORY)&_pragma=mmap_size(536870912)"
|
||||
if cfg.Database.WALMode {
|
||||
dsn += "&_pragma=wal_autocheckpoint(1000)"
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
|
||||
func isSQLite(db *gorm.DB) bool {
|
||||
return db != nil && db.Dialector != nil && db.Dialector.Name() == "sqlite"
|
||||
}
|
||||
|
||||
func isPostgres(db *gorm.DB) bool {
|
||||
return db != nil && db.Dialector != nil && db.Dialector.Name() == "postgres"
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/middleware"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
const embyCtxUserName = "emby_user_name"
|
||||
|
||||
func activeUserRequired(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
userID, _ := uid.(string)
|
||||
if userID == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40101, "message": "missing user"})
|
||||
return
|
||||
}
|
||||
u, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
if service.IsTransientDatabaseLock(err) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40101, "message": "user not found"})
|
||||
return
|
||||
}
|
||||
if u == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40101, "message": "user not found"})
|
||||
return
|
||||
}
|
||||
if !u.IsActive {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 40302, "message": "user account is disabled"})
|
||||
return
|
||||
}
|
||||
if u.ExpiredAt != nil && time.Now().After(*u.ExpiredAt) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 40303, "message": "user account has expired"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func activeEmbyUserRequired(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
userID, _ := uid.(string)
|
||||
u, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
|
||||
if userID == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"Code": 40101, "Message": "User not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
if service.IsTransientDatabaseLock(err) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"Code": 40101, "Message": "User not found"})
|
||||
return
|
||||
}
|
||||
if u == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"Code": 40101, "Message": "User not found"})
|
||||
return
|
||||
}
|
||||
if !u.IsActive {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"Code": 40302, "Message": "User account is disabled"})
|
||||
return
|
||||
}
|
||||
if u.ExpiredAt != nil && time.Now().After(*u.ExpiredAt) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"Code": 40303, "Message": "User account has expired"})
|
||||
return
|
||||
}
|
||||
c.Set(embyCtxUserName, u.Username)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
// Package handler — admin endpoints (users / settings / logs).
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func listUsersHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
users, err := svc.Repo.User.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := annotateProtectedUsers(c.Request.Context(), svc, users); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if svc.Sessions != nil {
|
||||
svc.Sessions.ApplyToUsers(c.Request.Context(), users)
|
||||
}
|
||||
for i := range users {
|
||||
users[i].PopulateComputedFields()
|
||||
}
|
||||
c.JSON(http.StatusOK, users)
|
||||
}
|
||||
}
|
||||
|
||||
type adminCreateUserReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
func createUserHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req adminCreateUserReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
u, _, err := svc.Auth.Register(c.Request.Context(), req.Username, req.Password)
|
||||
if err != nil {
|
||||
writeUserMutationError(c, svc, err)
|
||||
return
|
||||
}
|
||||
// Admin-created users are intentionally normal viewers by default.
|
||||
// They can log in from Web/Emby-compatible clients and play media, but
|
||||
// cannot scrape, scan, download, delete, export NFO, or manage files.
|
||||
if u.Role != "user" {
|
||||
u, err = svc.Profile.AdminUpdateRole(c.Request.Context(), u.ID, "user")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusCreated, u)
|
||||
}
|
||||
}
|
||||
|
||||
type adminUpdateUserReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
}
|
||||
|
||||
type adminResetPasswordReq struct {
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
type adminUpdateUserStatusReq struct {
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func updateUserHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req adminUpdateUserReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
nextUsername := strings.TrimSpace(req.Username)
|
||||
if nextUsername == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "username required"})
|
||||
return
|
||||
}
|
||||
userID := c.Param("id")
|
||||
user, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if existing, err := svc.Repo.User.FindByUsername(c.Request.Context(), nextUsername); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
} else if existing != nil && existing.ID != userID {
|
||||
writeUserMutationError(c, svc, service.ErrUsernameTaken)
|
||||
return
|
||||
}
|
||||
updates := map[string]any{"username": nextUsername}
|
||||
if firstAdmin, err := svc.Repo.User.FirstAdmin(c.Request.Context()); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
} else if firstAdmin != nil && firstAdmin.ID == userID {
|
||||
updates["role"] = "admin"
|
||||
updates["tier"] = "plus"
|
||||
}
|
||||
if err := svc.Repo.User.UpdateFields(c.Request.Context(), userID, updates); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteUserHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
firstAdmin, err := svc.Repo.User.FirstAdmin(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if firstAdmin != nil && firstAdmin.ID == c.Param("id") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "default admin cannot be deleted"})
|
||||
return
|
||||
}
|
||||
if svc.Sessions != nil && svc.Sessions.UserRecentlyActive(c.Request.Context(), c.Param("id"), service.RealtimeDeletionGuardWindow()) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "user has a recent realtime session; confirm the user is offline before deletion"})
|
||||
return
|
||||
}
|
||||
if err := svc.Repo.User.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func resetUserPasswordHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req adminResetPasswordReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := svc.Auth.ResetPassword(c.Request.Context(), c.Param("id"), req.Password); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func updateUserStatusHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req adminUpdateUserStatusReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.Param("id")
|
||||
if !req.IsActive {
|
||||
if firstAdmin, err := svc.Repo.User.FirstAdmin(c.Request.Context()); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
} else if firstAdmin != nil && firstAdmin.ID == userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "default admin cannot be disabled"})
|
||||
return
|
||||
}
|
||||
}
|
||||
updates := map[string]any{"is_active": req.IsActive}
|
||||
if req.IsActive {
|
||||
updates["share_warnings"] = 0
|
||||
updates["last_share_warn_at"] = nil
|
||||
}
|
||||
if err := svc.Repo.User.UpdateFields(c.Request.Context(), userID, updates); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.IsActive {
|
||||
_ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, false)
|
||||
} else {
|
||||
_ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, true)
|
||||
}
|
||||
updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updated.PopulateComputedFields()
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
}
|
||||
|
||||
type adminUpdateUserLibrariesReq struct {
|
||||
AllowedLibraryIDs *[]string `json:"allowed_library_ids"`
|
||||
}
|
||||
|
||||
func updateUserLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req adminUpdateUserLibrariesReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.Param("id")
|
||||
user, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var rawJSON string
|
||||
if req.AllowedLibraryIDs != nil && len(*req.AllowedLibraryIDs) > 0 {
|
||||
var cleanIDs []string
|
||||
for _, id := range *req.AllowedLibraryIDs {
|
||||
trimmed := strings.TrimSpace(id)
|
||||
if trimmed != "" {
|
||||
cleanIDs = append(cleanIDs, trimmed)
|
||||
}
|
||||
}
|
||||
if len(cleanIDs) > 0 {
|
||||
data, err := json.Marshal(cleanIDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
rawJSON = string(data)
|
||||
}
|
||||
}
|
||||
|
||||
updates := map[string]any{"allowed_library_ids": rawJSON}
|
||||
if err := svc.Repo.User.UpdateFields(c.Request.Context(), userID, updates); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
|
||||
if err != nil || updated == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload user"})
|
||||
return
|
||||
}
|
||||
updated.PopulateComputedFields()
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
}
|
||||
|
||||
func annotateProtectedUsers(ctx context.Context, svc *service.Container, users []model.User) error {
|
||||
firstAdmin, err := svc.Repo.User.FirstAdmin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range users {
|
||||
if service.UserIsProtectedAccount(ctx, svc.Repo, &users[i]) {
|
||||
users[i].IsProtected = true
|
||||
}
|
||||
if firstAdmin != nil && users[i].ID == firstAdmin.ID {
|
||||
users[i].IsDefaultAdmin = true
|
||||
users[i].IsProtected = true
|
||||
users[i].Role = "admin"
|
||||
users[i].Tier = "plus"
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeUserMutationError(c *gin.Context, svc *service.Container, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrUsernameTaken):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "username already taken"})
|
||||
case errors.Is(err, service.ErrUserLimitReached):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user limit reached", "max_users": service.UserLimit})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
type DatabaseConnectionPayload struct {
|
||||
Type string `json:"type"`
|
||||
DSN string `json:"dsn"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
DBName string `json:"dbname"`
|
||||
SSLMode string `json:"sslmode"`
|
||||
}
|
||||
|
||||
func (p *DatabaseConnectionPayload) BuildDSN() string {
|
||||
raw := strings.TrimSpace(p.DSN)
|
||||
if raw != "" {
|
||||
return raw
|
||||
}
|
||||
host := strings.TrimSpace(p.Host)
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
port := p.Port
|
||||
if port <= 0 {
|
||||
port = 5432
|
||||
}
|
||||
user := strings.TrimSpace(p.User)
|
||||
dbname := strings.TrimSpace(p.DBName)
|
||||
if dbname == "" {
|
||||
dbname = "mmtl"
|
||||
}
|
||||
sslmode := strings.TrimSpace(p.SSLMode)
|
||||
if sslmode == "" {
|
||||
sslmode = "disable"
|
||||
}
|
||||
|
||||
userInfo := url.User(user)
|
||||
if p.Password != "" {
|
||||
userInfo = url.UserPassword(user, p.Password)
|
||||
}
|
||||
|
||||
u := url.URL{
|
||||
Scheme: "postgres",
|
||||
User: userInfo,
|
||||
Host: fmt.Sprintf("%s:%d", host, port),
|
||||
Path: "/" + dbname,
|
||||
RawQuery: "sslmode=" + url.QueryEscape(sslmode),
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func getDatabaseStatusHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc.Database == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database service unavailable"})
|
||||
return
|
||||
}
|
||||
status := svc.Database.GetStatus(c.Request.Context())
|
||||
c.JSON(http.StatusOK, status)
|
||||
}
|
||||
}
|
||||
|
||||
func testDatabaseHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req DatabaseConnectionPayload
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()})
|
||||
return
|
||||
}
|
||||
dsn := req.BuildDSN()
|
||||
if dsn == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供有效的 PostgreSQL 连接信息或 DSN"})
|
||||
return
|
||||
}
|
||||
res, err := svc.Database.TestPostgres(c.Request.Context(), dsn)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
}
|
||||
}
|
||||
|
||||
func migrateDatabaseHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req DatabaseConnectionPayload
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()})
|
||||
return
|
||||
}
|
||||
dsn := req.BuildDSN()
|
||||
if dsn == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供目标 PostgreSQL 连接信息或 DSN"})
|
||||
return
|
||||
}
|
||||
res, err := svc.Database.MigrateToPostgres(c.Request.Context(), dsn)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "迁移失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
}
|
||||
}
|
||||
|
||||
func saveDatabaseConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req DatabaseConnectionPayload
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()})
|
||||
return
|
||||
}
|
||||
dbType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if dbType == "" {
|
||||
dbType = "postgres"
|
||||
}
|
||||
var dsn string
|
||||
if dbType == "postgres" {
|
||||
dsn = req.BuildDSN()
|
||||
if dsn == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供有效的 PostgreSQL 连接信息或 DSN"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := svc.Database.SaveConfig(c.Request.Context(), dbType, dsn); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "数据库配置已成功保存至配置文件,重启服务后将以新数据库运行",
|
||||
"type": dbType,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func TestBuildDSN(t *testing.T) {
|
||||
cases := []struct {
|
||||
payload DatabaseConnectionPayload
|
||||
want string
|
||||
}{
|
||||
{
|
||||
payload: DatabaseConnectionPayload{
|
||||
DSN: "postgres://myuser:mypass@10.0.0.1:5432/mydb?sslmode=require",
|
||||
},
|
||||
want: "postgres://myuser:mypass@10.0.0.1:5432/mydb?sslmode=require",
|
||||
},
|
||||
{
|
||||
payload: DatabaseConnectionPayload{
|
||||
Host: "127.0.0.1",
|
||||
Port: 5432,
|
||||
User: "postgres",
|
||||
Password: "secretpassword",
|
||||
DBName: "mmtl_prod",
|
||||
SSLMode: "disable",
|
||||
},
|
||||
want: "postgres://postgres:secretpassword@127.0.0.1:5432/mmtl_prod?sslmode=disable",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got := c.payload.BuildDSN()
|
||||
if got != c.want {
|
||||
t.Errorf("BuildDSN() = %q, want %q", got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDatabaseStatusHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := &config.Config{}
|
||||
cfg.Database.Type = "sqlite"
|
||||
cfg.Database.DBPath = "./data/mmtl.db"
|
||||
|
||||
svc := &service.Container{
|
||||
Database: service.NewDatabaseAdminService(cfg, nil, nil, nil),
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/api/admin/database/status", getDatabaseStatusHandler(svc))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/database/status", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if resp["type"] != "sqlite" {
|
||||
t.Fatalf("expected type=sqlite, got %v", resp["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveDatabaseConfigHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
dir := t.TempDir()
|
||||
cfg := &config.Config{}
|
||||
cfg.App.DataDir = dir
|
||||
cfg.Database.Type = "sqlite"
|
||||
|
||||
svc := &service.Container{
|
||||
Database: service.NewDatabaseAdminService(cfg, nil, nil, nil),
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/admin/database/save-config", saveDatabaseConfigHandler(svc))
|
||||
|
||||
body := bytes.NewBufferString(`{"type":"postgres","host":"localhost","port":5432,"user":"admin","password":"pwd","dbname":"mmtl"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/database/save-config", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
type settingReq struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func listSettingsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
settings, err := svc.Repo.Setting.All(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
|
||||
func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req settingReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
oldValue := ""
|
||||
if req.Key == service.AdultLibraryIDsSettingKey {
|
||||
oldValue, _ = svc.Repo.Setting.Get(c.Request.Context(), req.Key)
|
||||
}
|
||||
if err := svc.Repo.Setting.Set(c.Request.Context(), req.Key, req.Value); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
oldAdultLibraryIDs := service.DecodeAllowedLibraryIDs(oldValue)
|
||||
newAdultLibraryIDs := service.DecodeAllowedLibraryIDs(req.Value)
|
||||
if req.Key == service.AdultLibraryIDsSettingKey && len(oldAdultLibraryIDs) == 0 && len(newAdultLibraryIDs) > 0 {
|
||||
_ = svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.User{}).Where("hide_adult = ?", false).Update("hide_adult", true).Error
|
||||
}
|
||||
service.ApplyRuntimeSetting(svc.Cfg, req.Key, req.Value)
|
||||
if err := applyHTTPSetting(svc, req.Key, req.Value); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if svc.FFprobe != nil && (req.Key == "ffprobe.max_concurrent" || req.Key == "app.ffprobe_max_concurrent") {
|
||||
svc.FFprobe.SetMaxConcurrent(svc.Cfg.App.FFprobeMaxConcurrent)
|
||||
}
|
||||
if req.Key == "transcode.enabled" && !svc.Cfg.Transcoder.Enabled {
|
||||
svc.Transcoder.StopAll()
|
||||
}
|
||||
if req.Key == "transcode.hw_enabled" || req.Key == "transcode.hw_accel" || req.Key == "transcoder.hardware_accel" || req.Key == "transcoder.encoder" {
|
||||
svc.Transcoder.StopAll()
|
||||
}
|
||||
if req.Key == "cache.images_max_size_mb" && svc.Scheduler != nil {
|
||||
_ = svc.Scheduler.RunNowAsync(c.Request.Context(), "image_cache_cleanup")
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// applyHTTPSetting 校验 HTTPS 相关设置,并在可行时热重载监听。
|
||||
// 必须在 ApplyRuntimeSetting 之后调用,这样 svc.Cfg 已反映刚保存的值。
|
||||
//
|
||||
// 规则:
|
||||
// - https.enabled=true 时强制要求证书与私钥都已配置(内容或路径均可)且匹配,
|
||||
// 否则返回错误("如果启用就必须配置 SSL 证书和密钥");
|
||||
// - 证书/私钥(内容或路径)单独保存时只校验格式;若 HTTPS 已开启且新的整体
|
||||
// 配置可解析匹配才触发重载,避免"只存了新证书、私钥还没保存"时用旧私钥带
|
||||
// 新证书对外提供服务。
|
||||
func applyHTTPSetting(svc *service.Container, key, value string) error {
|
||||
skipReload := func(reason string) {
|
||||
if svc.Log != nil {
|
||||
svc.Log.Warn("https setting saved but not applied yet", zap.String("key", key), zap.String("reason", reason))
|
||||
}
|
||||
}
|
||||
switch key {
|
||||
case "https.enabled":
|
||||
if svc.Cfg.App.HTTPSEnabled {
|
||||
if _, err := service.ResolveSSLKeyPair(svc.Cfg.App.SSLCert, svc.Cfg.App.SSLCertPath, svc.Cfg.App.SSLKey, svc.Cfg.App.SSLKeyPath); err != nil {
|
||||
return fmt.Errorf("启用 HTTPS 失败:%v", err)
|
||||
}
|
||||
}
|
||||
case "https.cert", "https.cert_path", "https.key", "https.key_path":
|
||||
if err := validateSSLMaterialSource(key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
if !svc.Cfg.App.HTTPSEnabled {
|
||||
return nil
|
||||
}
|
||||
if !httpsPairReady(svc) {
|
||||
skipReload("证书与私钥尚未匹配,等待另一半保存后生效")
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if svc.ReloadHTTPServer != nil {
|
||||
return svc.ReloadHTTPServer()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSSLMaterialSource 校验刚保存的证书/私钥来源(内容或路径)本身格式合法。
|
||||
func validateSSLMaterialSource(key, value string) error {
|
||||
switch key {
|
||||
case "https.cert":
|
||||
return service.ValidateSSLCert(value)
|
||||
case "https.cert_path":
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil // 清空路径也允许,启用时由整体校验把关
|
||||
}
|
||||
pemStr, err := service.ResolveSSLMaterial("", value, "证书")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return service.ValidateSSLCert(pemStr)
|
||||
case "https.key":
|
||||
return service.ValidateSSLKey(value)
|
||||
case "https.key_path":
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
pemStr, err := service.ResolveSSLMaterial("", value, "私钥")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return service.ValidateSSLKey(pemStr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// httpsPairReady 判断基于当前配置解析出的证书/私钥是否完整且匹配。
|
||||
func httpsPairReady(svc *service.Container) bool {
|
||||
_, err := service.ResolveSSLKeyPair(svc.Cfg.App.SSLCert, svc.Cfg.App.SSLCertPath, svc.Cfg.App.SSLKey, svc.Cfg.App.SSLKeyPath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
type testAdultScraperReq struct {
|
||||
Engine string `json:"engine"`
|
||||
ServerURL string `json:"server_url"`
|
||||
Token string `json:"token"`
|
||||
JavDBURL string `json:"javdb_url"`
|
||||
JavBusURL string `json:"javbus_url"`
|
||||
Cookie string `json:"cookie"`
|
||||
}
|
||||
|
||||
func testAdultScraperHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req testAdultScraperReq
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
engine := strings.ToLower(strings.TrimSpace(req.Engine))
|
||||
if engine == "" {
|
||||
engine = "metatube"
|
||||
}
|
||||
|
||||
if engine == "metatube" {
|
||||
serverURL := strings.TrimSpace(req.ServerURL)
|
||||
if serverURL == "" {
|
||||
serverURL, _ = svc.Repo.Setting.Get(c.Request.Context(), "adult.scraper.metatube_server")
|
||||
}
|
||||
if serverURL == "" {
|
||||
serverURL = "http://127.0.0.1:7700"
|
||||
}
|
||||
token := strings.TrimSpace(req.Token)
|
||||
if token == "" {
|
||||
token, _ = svc.Repo.Setting.Get(c.Request.Context(), "adult.scraper.metatube_token")
|
||||
}
|
||||
|
||||
client := service.NewMetaTubeProvider(svc.Log)
|
||||
res, err := client.TestConnection(c.Request.Context(), serverURL, token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"latency_ms": res.LatencyMs,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": res.Success,
|
||||
"latency_ms": res.LatencyMs,
|
||||
"providers": res.Providers,
|
||||
"error": res.Error,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Builtin scraper test
|
||||
bases := []string{}
|
||||
if req.JavDBURL != "" {
|
||||
bases = append(bases, strings.TrimSpace(req.JavDBURL))
|
||||
}
|
||||
if req.JavBusURL != "" {
|
||||
bases = append(bases, strings.Split(req.JavBusURL, ",")...)
|
||||
}
|
||||
if len(bases) == 0 {
|
||||
if s, _ := svc.Repo.Setting.Get(c.Request.Context(), "adult.scraper.builtin_javdb_url"); s != "" {
|
||||
bases = append(bases, s)
|
||||
}
|
||||
if s, _ := svc.Repo.Setting.Get(c.Request.Context(), "adult.scraper.builtin_javbus_url"); s != "" {
|
||||
bases = append(bases, strings.Split(s, ",")...)
|
||||
}
|
||||
}
|
||||
if len(bases) == 0 {
|
||||
bases = []string{"https://javdb.com", "https://javbus.sbs", "https://www.javbus.com"}
|
||||
}
|
||||
|
||||
cookie := strings.TrimSpace(req.Cookie)
|
||||
if cookie == "" {
|
||||
cookie, _ = svc.Repo.Setting.Get(c.Request.Context(), "adult.scraper.builtin_cookie")
|
||||
}
|
||||
if !strings.Contains(cookie, "age=") {
|
||||
cookie = cookie + "; age=verified"
|
||||
}
|
||||
|
||||
httpClient := service.NewExternalHTTPClient(8 * time.Second)
|
||||
start := time.Now()
|
||||
var lastErr error
|
||||
success := false
|
||||
for _, b := range bases {
|
||||
b = strings.TrimRight(strings.TrimSpace(b), "/")
|
||||
if b == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(b, "http://") && !strings.HasPrefix(b, "https://") {
|
||||
b = "https://" + b
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, b, nil)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
httpReq.Header.Set("Cookie", cookie)
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 256<<10))
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
lastErr = fmt.Errorf("%s returned HTTP %d", b, resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
text := string(bodyBytes)
|
||||
if strings.Contains(text, "driver-verify") || strings.Contains(text, "Age Verification") {
|
||||
lastErr = fmt.Errorf("%s intercepted by age verification", b)
|
||||
continue
|
||||
}
|
||||
success = true
|
||||
break
|
||||
}
|
||||
|
||||
latency := time.Since(start).Milliseconds()
|
||||
if success {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"latency_ms": latency,
|
||||
"message": "内置刮削源连接正常",
|
||||
})
|
||||
return
|
||||
}
|
||||
errMsg := "内置源连接失败"
|
||||
if lastErr != nil {
|
||||
errMsg = lastErr.Error()
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"latency_ms": latency,
|
||||
"error": errMsg,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func recentLogsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
rows, err := svc.Repo.Log.Recent(c.Request.Context(), 200)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, rows)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func TestDeleteUserRefusesRecentRealtimeSession(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
admin := model.User{Base: model.Base{ID: "admin"}, Username: "admin", PasswordHash: "x", Role: "admin", IsActive: true}
|
||||
viewer := model.User{Base: model.Base{ID: "viewer"}, Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true}
|
||||
if err := repos.DB.Create(&[]model.User{admin, viewer}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tracker := service.NewSessionTrackerService(zap.NewNop())
|
||||
tracker.RecordLogin(t.Context(), viewer.ID, viewer.Username, "dev-1", "Apple TV", "Yamby", "10.0.0.8")
|
||||
svc := &service.Container{Repo: repos, Sessions: tracker}
|
||||
router := gin.New()
|
||||
router.DELETE("/admin/users/:id", deleteUserHandler(svc))
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/admin/users/viewer", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if found, _ := repos.User.FindByID(t.Context(), viewer.ID); found == nil {
|
||||
t.Fatal("recent realtime user should not be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserLibraries(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
user := model.User{Base: model.Base{ID: "u1"}, Username: "alice", PasswordHash: "x", Role: "user", IsActive: true}
|
||||
lib1 := model.Library{Base: model.Base{ID: "lib-1"}, Name: "电影", Type: "movie", Path: "/movie"}
|
||||
lib2 := model.Library{Base: model.Base{ID: "lib-2"}, Name: "剧集", Type: "tv", Path: "/tv"}
|
||||
lib3 := model.Library{Base: model.Base{ID: "lib-3"}, Name: "动漫", Type: "anime", Path: "/anime"}
|
||||
if err := repos.DB.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.DB.Create(&[]model.Library{lib1, lib2, lib3}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := &service.Container{Repo: repos}
|
||||
router := gin.New()
|
||||
router.PATCH("/admin/users/:id/libraries", updateUserLibrariesHandler(svc))
|
||||
|
||||
// 1. 设置限制为 lib-1 和 lib-2
|
||||
body := `{"allowed_library_ids":["lib-1","lib-2"]}`
|
||||
req := httptest.NewRequest(http.MethodPatch, "/admin/users/u1/libraries", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
found, err := repos.User.FindByID(t.Context(), "u1")
|
||||
if err != nil || found == nil {
|
||||
t.Fatal("user not found")
|
||||
}
|
||||
allowed := found.DecodeAllowedLibraryIDs()
|
||||
if len(allowed) != 2 || allowed[0] != "lib-1" || allowed[1] != "lib-2" {
|
||||
t.Fatalf("expected [lib-1, lib-2], got %v", allowed)
|
||||
}
|
||||
|
||||
// 验证可见性
|
||||
vis := service.UserDefaultMediaVisibility(t.Context(), repos, "u1")
|
||||
if len(vis.AllowedLibraryIDs) != 2 {
|
||||
t.Fatalf("expected 2 allowed libraries, got %v", vis.AllowedLibraryIDs)
|
||||
}
|
||||
if !service.LibraryVisibleForUser(t.Context(), repos, lib1, vis) {
|
||||
t.Fatal("lib1 should be visible")
|
||||
}
|
||||
if !service.LibraryVisibleForUser(t.Context(), repos, lib2, vis) {
|
||||
t.Fatal("lib2 should be visible")
|
||||
}
|
||||
if service.LibraryVisibleForUser(t.Context(), repos, lib3, vis) {
|
||||
t.Fatal("lib3 should not be visible")
|
||||
}
|
||||
|
||||
// 2. 清空限制,恢复全部可见
|
||||
bodyEmpty := `{"allowed_library_ids":[]}`
|
||||
reqEmpty := httptest.NewRequest(http.MethodPatch, "/admin/users/u1/libraries", strings.NewReader(bodyEmpty))
|
||||
reqEmpty.Header.Set("Content-Type", "application/json")
|
||||
wEmpty := httptest.NewRecorder()
|
||||
router.ServeHTTP(wEmpty, reqEmpty)
|
||||
|
||||
if wEmpty.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body = %s", wEmpty.Code, wEmpty.Body.String())
|
||||
}
|
||||
|
||||
foundReset, _ := repos.User.FindByID(t.Context(), "u1")
|
||||
if len(foundReset.DecodeAllowedLibraryIDs()) != 0 {
|
||||
t.Fatalf("expected nil or empty, got %v", foundReset.DecodeAllowedLibraryIDs())
|
||||
}
|
||||
|
||||
visReset := service.UserDefaultMediaVisibility(t.Context(), repos, "u1")
|
||||
if len(visReset.AllowedLibraryIDs) != 0 {
|
||||
t.Fatalf("expected no library restrictions, got %v", visReset.AllowedLibraryIDs)
|
||||
}
|
||||
if !service.LibraryVisibleForUser(t.Context(), repos, lib3, visReset) {
|
||||
t.Fatal("lib3 should now be visible")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Package handler — third-party API config (TMDb / Bangumi / TheTVDB / …).
|
||||
//
|
||||
// All routes live under /api/admin/api-configs/* so only administrators
|
||||
// can list / update / delete provider keys.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func listAPIConfigsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
items, err := svc.APIConfig.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
}
|
||||
|
||||
func getAPIConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
view, err := svc.APIConfig.Get(c.Request.Context(), c.Param("provider"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if view == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
}
|
||||
|
||||
func updateAPIConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var patch service.APIConfigPatch
|
||||
if err := c.ShouldBindJSON(&patch); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
view, err := svc.APIConfig.Update(c.Request.Context(), c.Param("provider"), patch)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteAPIConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.APIConfig.Delete(c.Request.Context(), c.Param("provider")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func testAPIConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
provider := c.Param("provider")
|
||||
result, err := svc.ApiConfig.TestConnection(c.Request.Context(), provider)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"result": result, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"result": result})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// Package handler — API 配置 HTTP Handler。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
// ApiConfigHandler API 配置 HTTP 处理。
|
||||
type ApiConfigHandler struct {
|
||||
svc *service.Container
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// NewApiConfigHandler 创建 API 配置处理器。
|
||||
func NewApiConfigHandler(svc *service.Container, log *zap.Logger) *ApiConfigHandler {
|
||||
return &ApiConfigHandler{svc: svc, log: log}
|
||||
}
|
||||
|
||||
// ListApiConfigs 获取所有 API 配置。
|
||||
// GET /api/api-config
|
||||
func (h *ApiConfigHandler) ListApiConfigs(c *gin.Context) {
|
||||
configs, err := h.svc.ApiConfig.List(c.Request.Context())
|
||||
if err != nil {
|
||||
h.log.Error("list api configs failed", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
// 遮蔽 API Key
|
||||
for i := range configs {
|
||||
if configs[i].APIKey != "" {
|
||||
configs[i].APIKey = h.svc.ApiConfig.MaskAPIKey(configs[i].APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": configs})
|
||||
}
|
||||
|
||||
// ListProviders 获取预定义的提供者列表。
|
||||
// GET /api/api-config/providers/list
|
||||
func (h *ApiConfigHandler) ListProviders(c *gin.Context) {
|
||||
providers := h.svc.ApiConfig.GetProviders()
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": providers})
|
||||
}
|
||||
|
||||
// GetApiConfig 获取指定提供者的配置。
|
||||
// GET /api/api-config/:provider
|
||||
func (h *ApiConfigHandler) GetApiConfig(c *gin.Context) {
|
||||
provider := c.Param("provider")
|
||||
if provider == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "provider required", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.svc.ApiConfig.GetByProvider(c.Request.Context(), provider)
|
||||
if err != nil {
|
||||
if err == service.ErrApiConfigNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 40401, "message": "api config not found", "data": nil})
|
||||
return
|
||||
}
|
||||
h.log.Error("get api config failed", zap.Error(err), zap.String("provider", provider))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
// 遮蔽 API Key
|
||||
if cfg.APIKey != "" {
|
||||
cfg.APIKey = h.svc.ApiConfig.MaskAPIKey(cfg.APIKey)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": cfg})
|
||||
}
|
||||
|
||||
// GetEffectiveConfig 获取生效的配置(数据库配置优先于配置文件)。
|
||||
// GET /api/api-config/:provider/effective
|
||||
func (h *ApiConfigHandler) GetEffectiveConfig(c *gin.Context) {
|
||||
provider := c.Param("provider")
|
||||
if provider == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "provider required", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.svc.ApiConfig.GetEffectiveConfig(c.Request.Context(), provider)
|
||||
if err != nil {
|
||||
if err == service.ErrApiConfigNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 40401, "message": "api config not found", "data": nil})
|
||||
return
|
||||
}
|
||||
h.log.Error("get effective config failed", zap.Error(err), zap.String("provider", provider))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
// 遮蔽 API Key
|
||||
if cfg.APIKey != "" {
|
||||
cfg.APIKey = h.svc.ApiConfig.MaskAPIKey(cfg.APIKey)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": cfg})
|
||||
}
|
||||
|
||||
// UpsertApiConfig 创建或更新 API 配置。
|
||||
// POST /api/api-config/:provider
|
||||
func (h *ApiConfigHandler) UpsertApiConfig(c *gin.Context) {
|
||||
provider := c.Param("provider")
|
||||
if provider == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "provider required", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
APIKey string `json:"api_key"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Extra string `json:"extra"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "invalid request", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.svc.ApiConfig.Upsert(c.Request.Context(), provider, req.APIKey, req.BaseURL, req.Extra, req.Enabled)
|
||||
if err != nil {
|
||||
if err == service.ErrInvalidProvider {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "invalid provider", "data": nil})
|
||||
return
|
||||
}
|
||||
h.log.Error("upsert api config failed", zap.Error(err), zap.String("provider", provider))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
// 返回遮蔽后的配置
|
||||
cfg.APIKey = h.svc.ApiConfig.MaskAPIKey(cfg.APIKey)
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": cfg})
|
||||
}
|
||||
|
||||
// DeleteApiConfig 删除 API 配置。
|
||||
// DELETE /api/api-config/:provider
|
||||
func (h *ApiConfigHandler) DeleteApiConfig(c *gin.Context) {
|
||||
provider := c.Param("provider")
|
||||
if provider == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "provider required", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.ApiConfig.Delete(c.Request.Context(), provider); err != nil {
|
||||
h.log.Error("delete api config failed", zap.Error(err), zap.String("provider", provider))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": nil})
|
||||
}
|
||||
|
||||
// TestApiConfig 测试 API 连接。
|
||||
// POST /api/api-config/:provider/test
|
||||
func (h *ApiConfigHandler) TestApiConfig(c *gin.Context) {
|
||||
provider := c.Param("provider")
|
||||
if provider == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "provider required", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.ApiConfig.TestConnection(c.Request.Context(), provider)
|
||||
if err != nil {
|
||||
h.log.Debug("test api config failed", zap.Error(err), zap.String("provider", provider))
|
||||
// 不返回错误,只返回测试结果
|
||||
}
|
||||
|
||||
// 更新测试结果
|
||||
_ = h.svc.ApiConfig.UpdateTestResult(c.Request.Context(), provider, result)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": gin.H{
|
||||
"result": result,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Package handler — auth-related HTTP endpoints.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/middleware"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
type loginReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type registerReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
func loginHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req loginReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
resp, err := svc.Auth.Login(c.Request.Context(), req.Username, req.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrInvalidCredentials) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrUserInactive) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "user account is inactive"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if svc.Sessions != nil {
|
||||
svc.Sessions.RecordLogin(c.Request.Context(), resp.User.ID, resp.User.Username, "", "Web", "Web", c.ClientIP())
|
||||
}
|
||||
if resp.Tokens != nil {
|
||||
setAccessTokenCookie(c, resp.Tokens.AccessToken, int(resp.Tokens.ExpiresIn))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": resp.User,
|
||||
"tokens": resp.Tokens,
|
||||
})
|
||||
svc.Audit.RecordBestEffort(resp.User.ID, "auth.login", resp.User.Username, c.ClientIP(), "")
|
||||
}
|
||||
}
|
||||
|
||||
func registerHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req registerReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
u, tokens, err := svc.Auth.Register(c.Request.Context(), req.Username, req.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrUsernameTaken) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "username taken"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if tokens != nil {
|
||||
setAccessTokenCookie(c, tokens.AccessToken, int(tokens.ExpiresIn))
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"user": u,
|
||||
"tokens": tokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func meHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
u, err := svc.Repo.User.FindByID(c.Request.Context(), uid.(string))
|
||||
if err != nil || u == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
u.PopulateComputedFields()
|
||||
c.JSON(http.StatusOK, u)
|
||||
}
|
||||
}
|
||||
|
||||
type changePwdReq struct {
|
||||
OldPassword string `json:"old_password" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
func changePasswordHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req changePwdReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
if err := svc.Auth.ChangePassword(c.Request.Context(), uid.(string), req.OldPassword, req.NewPassword); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/middleware"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func setAccessTokenCookie(c *gin.Context, token string, maxAgeSeconds int) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return
|
||||
}
|
||||
if maxAgeSeconds <= 0 {
|
||||
maxAgeSeconds = int(service.AccessTokenDuration.Seconds())
|
||||
}
|
||||
writeAccessTokenCookie(c, token, maxAgeSeconds)
|
||||
}
|
||||
|
||||
func clearAccessTokenCookie(c *gin.Context) {
|
||||
writeAccessTokenCookie(c, "", -1)
|
||||
}
|
||||
|
||||
func writeAccessTokenCookie(c *gin.Context, value string, maxAgeSeconds int) {
|
||||
cookie := &http.Cookie{
|
||||
Name: middleware.AccessTokenCookieName,
|
||||
Value: value,
|
||||
Path: middleware.AccessTokenCookiePath,
|
||||
MaxAge: maxAgeSeconds,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: requestIsHTTPS(c),
|
||||
}
|
||||
if maxAgeSeconds > 0 {
|
||||
cookie.Expires = time.Now().Add(time.Duration(maxAgeSeconds) * time.Second)
|
||||
} else if maxAgeSeconds < 0 {
|
||||
cookie.Expires = time.Unix(0, 0)
|
||||
}
|
||||
http.SetCookie(c.Writer, cookie)
|
||||
}
|
||||
|
||||
func requestIsHTTPS(c *gin.Context) bool {
|
||||
if c == nil || c.Request == nil {
|
||||
return false
|
||||
}
|
||||
if c.Request.TLS != nil {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/middleware"
|
||||
)
|
||||
|
||||
func TestSetAccessTokenCookie(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "https://media.local/api/auth/login", nil)
|
||||
|
||||
setAccessTokenCookie(c, "access-token", 3600)
|
||||
|
||||
cookie := findResponseCookie(t, w, middleware.AccessTokenCookieName)
|
||||
if cookie.Value != "access-token" {
|
||||
t.Fatalf("cookie value = %q", cookie.Value)
|
||||
}
|
||||
if cookie.Path != middleware.AccessTokenCookiePath {
|
||||
t.Fatalf("cookie path = %q, want %q", cookie.Path, middleware.AccessTokenCookiePath)
|
||||
}
|
||||
if cookie.MaxAge != 3600 {
|
||||
t.Fatalf("cookie max age = %d, want 3600", cookie.MaxAge)
|
||||
}
|
||||
if !cookie.HttpOnly {
|
||||
t.Fatal("cookie should be HttpOnly")
|
||||
}
|
||||
if !cookie.Secure {
|
||||
t.Fatal("https request should set Secure cookie")
|
||||
}
|
||||
if cookie.SameSite != http.SameSiteLaxMode {
|
||||
t.Fatalf("cookie SameSite = %v, want Lax", cookie.SameSite)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearAccessTokenCookie(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "http://127.0.0.1:8080/api/me/logout", nil)
|
||||
|
||||
clearAccessTokenCookie(c)
|
||||
|
||||
cookie := findResponseCookie(t, w, middleware.AccessTokenCookieName)
|
||||
if cookie.MaxAge >= 0 {
|
||||
t.Fatalf("clear cookie max age = %d, want negative", cookie.MaxAge)
|
||||
}
|
||||
if cookie.Path != middleware.AccessTokenCookiePath {
|
||||
t.Fatalf("cookie path = %q, want %q", cookie.Path, middleware.AccessTokenCookiePath)
|
||||
}
|
||||
if cookie.Secure {
|
||||
t.Fatal("plain http request should not set Secure cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func findResponseCookie(t *testing.T, w *httptest.ResponseRecorder, name string) *http.Cookie {
|
||||
t.Helper()
|
||||
for _, cookie := range w.Result().Cookies() {
|
||||
if cookie.Name == name {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing response cookie %q", name)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Package handler — auth surface beyond /login + /register:
|
||||
//
|
||||
// POST /auth/refresh — issue a fresh JWT for the current user
|
||||
// POST /auth/logout — best-effort no-op (kept for parity)
|
||||
// PATCH /auth/profile — alias for /me
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/middleware"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
// refreshHandler returns a fresh token signed for the current user.
|
||||
// Because we don't track refresh tokens server-side, the caller's
|
||||
// existing access token is sufficient — it must already pass the
|
||||
// AuthRequired middleware.
|
||||
func refreshHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
u, err := svc.Repo.User.FindByID(c.Request.Context(), toString(uid))
|
||||
if err != nil || u == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid session"})
|
||||
return
|
||||
}
|
||||
token, err := svc.Auth.IssueToken(u)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"token": token, "user": u})
|
||||
}
|
||||
}
|
||||
|
||||
// logoutHandler is a deliberate no-op (we use stateless JWT). It exists
|
||||
// so the Vue frontend's logout button gets a 200 instead of 404.
|
||||
func logoutHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
clearAccessTokenCookie(c)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Package handler — database backup/restore endpoints.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func createBackupHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
info, err := svc.Backup.Create(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, info)
|
||||
}
|
||||
}
|
||||
|
||||
func listBackupsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
items, err := svc.Backup.List()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
}
|
||||
|
||||
func deleteBackupHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.Backup.Delete(c.Query("filename")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func restoreBackupHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.Backup.Restore(c.Request.Context(), c.Query("filename")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "restored — restart the server to apply"})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Package handler — danmaku endpoints.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
// getDanmakuHandler returns danmaku for a media. ?kw= optionally overrides the
|
||||
// search keyword (used by the player's manual search box); ?episodeId= forces a
|
||||
// specific danmaku library chosen by the user after a disambiguation.
|
||||
func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
res, err := svc.Danmaku.Fetch(c.Request.Context(), c.Param("id"), c.Query("kw"), c.Query("episodeId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
}
|
||||
}
|
||||
|
||||
// getDanmakuConfigHandler exposes the danmaku renderer knobs (opacity, font
|
||||
// size, area, enabled) so the player can initialize its control panel without
|
||||
// admin privileges.
|
||||
func getDanmakuConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, svc.Danmaku.Config(c.Request.Context()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Package handler — DLNA / UPnP discovery + cast endpoints.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func dlnaListHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
force := c.Query("force") == "true"
|
||||
devices, err := svc.DLNA.Discover(c.Request.Context(), force)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"devices": devices})
|
||||
}
|
||||
}
|
||||
|
||||
type dlnaCastReq struct {
|
||||
ControlURL string `json:"control_url" binding:"required"`
|
||||
MediaURL string `json:"media_url" binding:"required"`
|
||||
}
|
||||
|
||||
func dlnaCastHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req dlnaCastReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := svc.DLNA.Cast(c.Request.Context(), req.ControlURL, req.MediaURL); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Package handler — per-renderer DLNA control endpoints used by the
|
||||
// Vue UI. These are best-effort SOAP calls; failures surface as 4xx.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
// dlnaControlPath maps the action name to the AVTransport SOAP body.
|
||||
// (kept for parity with the upstream Vue admin UI)
|
||||
type dlnaAction string
|
||||
|
||||
const (
|
||||
_dlnaPlay dlnaAction = "Play"
|
||||
_dlnaPause dlnaAction = "Pause"
|
||||
_dlnaStop dlnaAction = "Stop"
|
||||
)
|
||||
|
||||
var _ = []dlnaAction{_dlnaPlay, _dlnaPause, _dlnaStop}
|
||||
|
||||
// findRendererControlURL returns the cached control URL for the given
|
||||
// uuid (matched against the device UDN). We rely on DLNAService's
|
||||
// existing Discover() cache.
|
||||
func findRendererControlURL(ctx context.Context, svc *service.Container, uuid string) (string, error) {
|
||||
devs, err := svc.DLNA.Discover(ctx, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, d := range devs {
|
||||
if d.UDN == uuid || strings.HasSuffix(d.UDN, uuid) {
|
||||
return d.ControlURL, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("renderer not found")
|
||||
}
|
||||
|
||||
// dlnaPlayHandler resumes playback on the chosen renderer.
|
||||
func dlnaPlayHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
controlURL, err := findRendererControlURL(c.Request.Context(), svc, c.Param("uuid"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
envelope := buildSimpleAVTransport("Play", `<Speed>1</Speed>`)
|
||||
if err := svc.DLNA.SOAP(c.Request.Context(), controlURL, "Play", envelope); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// dlnaPauseHandler pauses playback on the chosen renderer.
|
||||
func dlnaPauseHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
controlURL, err := findRendererControlURL(c.Request.Context(), svc, c.Param("uuid"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
envelope := buildSimpleAVTransport("Pause", "")
|
||||
if err := svc.DLNA.SOAP(c.Request.Context(), controlURL, "Pause", envelope); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// dlnaStopHandler stops playback.
|
||||
func dlnaStopHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
controlURL, err := findRendererControlURL(c.Request.Context(), svc, c.Param("uuid"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
envelope := buildSimpleAVTransport("Stop", "")
|
||||
if err := svc.DLNA.SOAP(c.Request.Context(), controlURL, "Stop", envelope); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// dlnaStatusHandler returns "playing" / "paused" / "stopped" via
|
||||
// GetTransportInfo. We don't parse the response — the UI can read the
|
||||
// raw body via the upstream proxy if it needs more detail.
|
||||
func dlnaStatusHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
controlURL, err := findRendererControlURL(c.Request.Context(), svc, c.Param("uuid"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
envelope := buildSimpleAVTransport("GetTransportInfo", "")
|
||||
if err := svc.DLNA.SOAP(c.Request.Context(), controlURL, "GetTransportInfo", envelope); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// buildSimpleAVTransport assembles a SOAP body for the given action +
|
||||
// extra body fragment. InstanceID is hard-coded to 0 (single zone).
|
||||
func buildSimpleAVTransport(action string, extra string) string {
|
||||
return fmt.Sprintf(
|
||||
`<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
||||
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<u:%s xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
||||
<InstanceID>0</InstanceID>%s
|
||||
</u:%s>
|
||||
</s:Body>
|
||||
</s:Envelope>`, action, extra, action,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package handler provides the HTTP API, including Emby/Jellyfin compatibility
|
||||
// routes mounted both at /emby/* and at the root path for client discovery.
|
||||
package handler
|
||||
@@ -0,0 +1,351 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/middleware"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
// embyError 返回 Emby 风格的错误(顶层 Code/Message)。
|
||||
func embyError(c *gin.Context, status int, msg string) {
|
||||
c.JSON(status, gin.H{"Code": status, "Message": msg})
|
||||
}
|
||||
|
||||
// embyUserID 从中间件中获取 user id。Emby auth middleware 写入 CtxUserID。
|
||||
func embyUserID(c *gin.Context) string {
|
||||
if uid, ok := c.Get(middleware.CtxUserID); ok {
|
||||
if s, ok := uid.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const embyCompatSessionTTL = 30 * time.Minute
|
||||
|
||||
type embyCompatSession struct {
|
||||
token string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
var embyCompatSessions = struct {
|
||||
sync.RWMutex
|
||||
items map[string]embyCompatSession
|
||||
}{items: map[string]embyCompatSession{}}
|
||||
|
||||
func embyAuthRequiredWithSessionFallback(secret string) gin.HandlerFunc {
|
||||
required := middleware.EmbyAuthRequired(secret)
|
||||
return func(c *gin.Context) {
|
||||
// 兼容小幻影视等客户端的「UserId 直连」凭据格式:
|
||||
// token 形如 X-Emby-Token=UserId="<uuid>",不是 JWT。解析出 uuid
|
||||
// 注入 CtxUserID,交由后续 activeEmbyUserRequired 查库验证用户
|
||||
// 存在且有效(未禁用/未过期),避免这类客户端每次 401 Invalid token。
|
||||
if uid := userIdDirectToken(c); uid != "" {
|
||||
c.Set(middleware.CtxUserID, uid)
|
||||
c.Set(middleware.EmbyCtxUserID, uid)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if embyRequestToken(c) == "" {
|
||||
if token := embyCompatSessionToken(c); token != "" {
|
||||
c.Request.Header.Set("X-Emby-Token", token)
|
||||
}
|
||||
}
|
||||
required(c)
|
||||
}
|
||||
}
|
||||
|
||||
// userIdDirectToken 从 Emby token 来源中识别形如 UserId="<uuid>" 的直连凭据,
|
||||
// 返回其中的 uuid;不是该格式则返回空串。用于兼容小幻影视(RodelPlayer)等
|
||||
// 客户端把用户 ID 当作 token 提交的行为。
|
||||
// 识别三种来源:
|
||||
// 1. URL query:X-Emby-Token=UserId="<uuid>"
|
||||
// 2. Authorization / X-Emby-Authorization / X-MediaBrowser-Authorization 头:
|
||||
// Emby UserId="<uuid>", Client="...", ...(无 Token=)
|
||||
// 3. X-Emby-Token / X-MediaBrowser-Token 头直传 UserId="<uuid>"
|
||||
func userIdDirectToken(c *gin.Context) string {
|
||||
if c == nil || c.Request == nil {
|
||||
return ""
|
||||
}
|
||||
const prefix = `UserId="`
|
||||
// 从一段文本中提取 UserId="<uuid>" 中的 uuid;不存在则返回空。
|
||||
extract := func(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
idx := strings.Index(raw, prefix)
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := raw[idx+len(prefix):]
|
||||
end := strings.Index(rest, `"`)
|
||||
if end <= 0 {
|
||||
return ""
|
||||
}
|
||||
uid := strings.TrimSpace(rest[:end])
|
||||
if len(uid) > 0 && len(uid) <= 64 {
|
||||
return uid
|
||||
}
|
||||
return ""
|
||||
}
|
||||
// 1) URL query 参数直传 UserId="..."。
|
||||
for _, key := range []string{"X-Emby-Token", "X-MediaBrowser-Token", "token", "api_key", "apiKey", "ApiKey"} {
|
||||
if uid := extract(c.Query(key)); uid != "" {
|
||||
return uid
|
||||
}
|
||||
}
|
||||
// 2) 认证头中的 Emby/MediaBrowser UserId="..."(无 Token= 的直连凭据)。
|
||||
for _, header := range []string{"Authorization", "X-Emby-Authorization", "X-MediaBrowser-Authorization"} {
|
||||
if uid := extract(c.GetHeader(header)); uid != "" {
|
||||
// 仅当该头不是标准 Token= 形式时才当作直连凭据,避免误拦截。
|
||||
if !strings.Contains(c.GetHeader(header), "Token=") {
|
||||
return uid
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3) X-Emby-Token / X-MediaBrowser-Token 头直传 UserId="..."。
|
||||
for _, header := range []string{"X-Emby-Token", "X-MediaBrowser-Token"} {
|
||||
if uid := extract(c.GetHeader(header)); uid != "" {
|
||||
return uid
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func embyRealtimeSessionActivity(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
recordEmbySessionActivity(c, svc, embyUserID(c), embyContextUserName(c))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func recordEmbySessionActivity(c *gin.Context, svc *service.Container, userID, userName string) {
|
||||
if c == nil || svc == nil || svc.Sessions == nil || strings.TrimSpace(userID) == "" {
|
||||
return
|
||||
}
|
||||
clientInfo := embyClientInfoFromRequest(c)
|
||||
svc.Sessions.RecordActivity(c.Request.Context(), userID, userName,
|
||||
clientInfo.DeviceID,
|
||||
clientInfo.DeviceName,
|
||||
clientInfo.Client,
|
||||
c.ClientIP())
|
||||
}
|
||||
|
||||
func embyContextUserName(c *gin.Context) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
if value, ok := c.Get(embyCtxUserName); ok {
|
||||
if username, ok := value.(string); ok {
|
||||
return strings.TrimSpace(username)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func embyRememberCompatSession(c *gin.Context, token string) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return
|
||||
}
|
||||
keys := embyCompatSessionKeys(c)
|
||||
if len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
expiresAt := time.Now().Add(embyCompatSessionTTL)
|
||||
embyCompatSessions.Lock()
|
||||
defer embyCompatSessions.Unlock()
|
||||
if len(embyCompatSessions.items) > 1000 {
|
||||
now := time.Now()
|
||||
for key, session := range embyCompatSessions.items {
|
||||
if now.After(session.expiresAt) {
|
||||
delete(embyCompatSessions.items, key)
|
||||
}
|
||||
}
|
||||
if len(embyCompatSessions.items) > 1000 {
|
||||
embyCompatSessions.items = map[string]embyCompatSession{}
|
||||
}
|
||||
}
|
||||
for _, key := range keys {
|
||||
embyCompatSessions.items[key] = embyCompatSession{token: token, expiresAt: expiresAt}
|
||||
}
|
||||
}
|
||||
|
||||
func embyCompatSessionToken(c *gin.Context) string {
|
||||
keys := embyCompatSessionKeys(c)
|
||||
if len(keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
now := time.Now()
|
||||
embyCompatSessions.RLock()
|
||||
defer embyCompatSessions.RUnlock()
|
||||
for _, key := range keys {
|
||||
session, ok := embyCompatSessions.items[key]
|
||||
if ok && now.Before(session.expiresAt) {
|
||||
return session.token
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func embyCompatSessionKeys(c *gin.Context) []string {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
ip := strings.TrimSpace(c.ClientIP())
|
||||
if ip == "" {
|
||||
return nil
|
||||
}
|
||||
keys := []string{}
|
||||
add := func(kind, value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
keys = append(keys, ip+"\x00"+kind+"\x00"+value)
|
||||
}
|
||||
}
|
||||
add("device", firstHeaderValue(c, "X-Emby-Device-Id", "X-Emby-DeviceId", "X-MediaBrowser-Device-Id", "X-MediaBrowser-DeviceId"))
|
||||
add("ua", c.GetHeader("User-Agent"))
|
||||
return keys
|
||||
}
|
||||
|
||||
func firstHeaderValue(c *gin.Context, names ...string) string {
|
||||
for _, name := range names {
|
||||
if value := strings.TrimSpace(c.GetHeader(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type embyClientInfo struct {
|
||||
DeviceID string
|
||||
DeviceName string
|
||||
Client string
|
||||
}
|
||||
|
||||
func embyClientInfoFromRequest(c *gin.Context) embyClientInfo {
|
||||
auth := parseMediaBrowserAuthorization(firstHeaderValue(c,
|
||||
"X-Emby-Authorization",
|
||||
"X-MediaBrowser-Authorization",
|
||||
"Authorization",
|
||||
))
|
||||
info := embyClientInfo{
|
||||
DeviceID: firstNonEmptyHeaderString(
|
||||
firstHeaderValue(c, "X-Emby-Device-Id", "X-Emby-DeviceId", "X-MediaBrowser-Device-Id", "X-MediaBrowser-DeviceId"),
|
||||
c.Query("DeviceId"),
|
||||
c.Query("DeviceID"),
|
||||
c.Query("deviceId"),
|
||||
c.Query("deviceID"),
|
||||
auth["DeviceId"],
|
||||
auth["DeviceID"],
|
||||
),
|
||||
DeviceName: firstNonEmptyHeaderString(
|
||||
firstHeaderValue(c, "X-Emby-Device-Name", "X-Emby-DeviceName", "X-MediaBrowser-Device-Name", "X-MediaBrowser-DeviceName"),
|
||||
c.Query("Device"),
|
||||
c.Query("DeviceName"),
|
||||
c.Query("device"),
|
||||
c.Query("deviceName"),
|
||||
auth["Device"],
|
||||
),
|
||||
Client: firstNonEmptyHeaderString(
|
||||
firstHeaderValue(c, "X-Emby-Client", "X-MediaBrowser-Client"),
|
||||
c.Query("Client"),
|
||||
c.Query("client"),
|
||||
auth["Client"],
|
||||
),
|
||||
}
|
||||
ua := strings.TrimSpace(c.GetHeader("User-Agent"))
|
||||
if info.Client == "" {
|
||||
info.Client = embyClientFromUserAgent(ua)
|
||||
}
|
||||
if info.DeviceName == "" {
|
||||
info.DeviceName = embyDeviceFromUserAgent(ua)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func parseMediaBrowserAuthorization(raw string) map[string]string {
|
||||
out := map[string]string{}
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return out
|
||||
}
|
||||
for _, prefix := range []string{"MediaBrowser ", "Emby "} {
|
||||
if strings.HasPrefix(raw, prefix) {
|
||||
raw = strings.TrimSpace(strings.TrimPrefix(raw, prefix))
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
key, value, ok := strings.Cut(strings.TrimSpace(part), "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.Trim(strings.TrimSpace(value), `"`)
|
||||
if key != "" && value != "" {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmptyHeaderString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func embyClientFromUserAgent(ua string) string {
|
||||
ua = strings.TrimSpace(ua)
|
||||
lower := strings.ToLower(ua)
|
||||
switch {
|
||||
case strings.Contains(lower, "infuse"):
|
||||
return "Infuse"
|
||||
case strings.Contains(lower, "emby"):
|
||||
return "Emby"
|
||||
case strings.Contains(lower, "jellyfin"):
|
||||
return "Jellyfin"
|
||||
case strings.Contains(lower, "yamby"):
|
||||
return "Yamby"
|
||||
case strings.Contains(lower, "vidhub"):
|
||||
return "VidHub"
|
||||
case strings.Contains(lower, "hills"):
|
||||
return "Hills"
|
||||
default:
|
||||
return ua
|
||||
}
|
||||
}
|
||||
|
||||
func embyDeviceFromUserAgent(ua string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(ua))
|
||||
switch {
|
||||
case strings.Contains(lower, "android"):
|
||||
return "Android"
|
||||
case strings.Contains(lower, "iphone"):
|
||||
return "iPhone"
|
||||
case strings.Contains(lower, "ipad"):
|
||||
return "iPad"
|
||||
case strings.Contains(lower, "ios"):
|
||||
return "iOS"
|
||||
case strings.Contains(lower, "windows"):
|
||||
return "Windows PC"
|
||||
case strings.Contains(lower, "macintosh") || strings.Contains(lower, "mac os"):
|
||||
return "Mac"
|
||||
case strings.Contains(lower, "linux"):
|
||||
return "Linux PC"
|
||||
case strings.Contains(lower, "appletv") || strings.Contains(lower, "apple tv"):
|
||||
return "Apple TV"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type embyAuthByNameReq struct {
|
||||
Username string `json:"Username"`
|
||||
Pw string `json:"Pw"`
|
||||
Password string `json:"Password"`
|
||||
PasswordMd5 string `json:"PasswordMd5"`
|
||||
PasswordSha1 string `json:"PasswordSha1"`
|
||||
}
|
||||
|
||||
func parseEmbyAuthByNameReq(c *gin.Context) (embyAuthByNameReq, error) {
|
||||
req := embyAuthByNameReq{}
|
||||
if strings.Contains(strings.ToLower(c.GetHeader("Content-Type")), "json") {
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||
return req, err
|
||||
}
|
||||
fillEmbyAuthFromMap(&req, body)
|
||||
}
|
||||
|
||||
if req.Username == "" || (req.Pw == "" && req.Password == "" && req.PasswordMd5 == "" && req.PasswordSha1 == "") {
|
||||
_ = c.Request.ParseForm()
|
||||
if req.Username == "" {
|
||||
req.Username = firstFormValue(c, "Username", "username", "Name", "name")
|
||||
}
|
||||
if req.Pw == "" {
|
||||
req.Pw = firstFormValue(c, "Pw", "pw")
|
||||
}
|
||||
if req.Password == "" {
|
||||
req.Password = firstFormValue(c, "Password", "password")
|
||||
}
|
||||
if req.PasswordMd5 == "" {
|
||||
req.PasswordMd5 = firstFormValue(c, "PasswordMd5", "passwordMd5", "password_md5")
|
||||
}
|
||||
if req.PasswordSha1 == "" {
|
||||
req.PasswordSha1 = firstFormValue(c, "PasswordSha1", "passwordSha1", "password_sha1")
|
||||
}
|
||||
}
|
||||
|
||||
if req.Username == "" {
|
||||
req.Username = firstQueryValue(c, "Username", "username", "Name", "name")
|
||||
}
|
||||
if req.Pw == "" {
|
||||
req.Pw = firstQueryValue(c, "Pw", "pw")
|
||||
}
|
||||
if req.Password == "" {
|
||||
req.Password = firstQueryValue(c, "Password", "password")
|
||||
}
|
||||
if req.PasswordMd5 == "" {
|
||||
req.PasswordMd5 = firstQueryValue(c, "PasswordMd5", "passwordMd5", "password_md5")
|
||||
}
|
||||
if req.PasswordSha1 == "" {
|
||||
req.PasswordSha1 = firstQueryValue(c, "PasswordSha1", "passwordSha1", "password_sha1")
|
||||
}
|
||||
if req.Username == "" || (req.Pw == "" && req.Password == "" && req.PasswordMd5 == "" && req.PasswordSha1 == "") {
|
||||
fillEmbyAuthFromRawBody(c, &req)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func fillEmbyAuthFromMap(req *embyAuthByNameReq, body map[string]any) {
|
||||
if req.Username == "" {
|
||||
req.Username = firstStringFromMap(body, "Username", "username", "UserName", "userName", "Name", "name", "LoginName", "loginName")
|
||||
}
|
||||
if req.Pw == "" {
|
||||
req.Pw = firstStringFromMap(body, "Pw", "pw", "PW")
|
||||
}
|
||||
if req.Password == "" {
|
||||
req.Password = firstStringFromMap(body, "Password", "password", "Pass", "pass", "Pwd", "pwd")
|
||||
}
|
||||
if req.PasswordMd5 == "" {
|
||||
req.PasswordMd5 = firstStringFromMap(body, "PasswordMd5", "passwordMd5", "password_md5")
|
||||
}
|
||||
if req.PasswordSha1 == "" {
|
||||
req.PasswordSha1 = firstStringFromMap(body, "PasswordSha1", "passwordSha1", "password_sha1")
|
||||
}
|
||||
}
|
||||
|
||||
func fillEmbyAuthFromRawBody(c *gin.Context, req *embyAuthByNameReq) {
|
||||
if c.Request == nil || c.Request.Body == nil {
|
||||
return
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(raw))
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 {
|
||||
return
|
||||
}
|
||||
if bytes.HasPrefix(raw, []byte("{")) {
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(raw, &body); err == nil {
|
||||
fillEmbyAuthFromMap(req, body)
|
||||
}
|
||||
return
|
||||
}
|
||||
if values, err := url.ParseQuery(string(raw)); err == nil {
|
||||
fillEmbyAuthFromValues(req, values)
|
||||
}
|
||||
}
|
||||
|
||||
func fillEmbyAuthFromValues(req *embyAuthByNameReq, values url.Values) {
|
||||
if req.Username == "" {
|
||||
req.Username = firstValue(values, "Username", "username", "UserName", "userName", "Name", "name", "LoginName", "loginName")
|
||||
}
|
||||
if req.Pw == "" {
|
||||
req.Pw = firstValue(values, "Pw", "pw", "PW")
|
||||
}
|
||||
if req.Password == "" {
|
||||
req.Password = firstValue(values, "Password", "password", "Pass", "pass", "Pwd", "pwd")
|
||||
}
|
||||
if req.PasswordMd5 == "" {
|
||||
req.PasswordMd5 = firstValue(values, "PasswordMd5", "passwordMd5", "password_md5")
|
||||
}
|
||||
if req.PasswordSha1 == "" {
|
||||
req.PasswordSha1 = firstValue(values, "PasswordSha1", "passwordSha1", "password_sha1")
|
||||
}
|
||||
}
|
||||
|
||||
func firstValue(values url.Values, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(values.Get(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstStringFromMap(body map[string]any, keys ...string) string {
|
||||
if len(body) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, key := range keys {
|
||||
if value, ok := body[key]; ok {
|
||||
if s, ok := value.(string); ok {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstFormValue(c *gin.Context, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if values, ok := c.Request.PostForm[key]; ok && len(values) > 0 {
|
||||
if value := strings.TrimSpace(values[0]); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstQueryValue(c *gin.Context, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(c.Query(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func TestParseEmbyAuthByNameReqAcceptsLowercaseJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/Users/AuthenticateByName", strings.NewReader(`{"username":"alice","password":"secret"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
req, err := parseEmbyAuthByNameReq(c)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEmbyAuthByNameReq returned error: %v", err)
|
||||
}
|
||||
if req.Username != "alice" || req.Password != "secret" {
|
||||
t.Fatalf("unexpected request: %#v", req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEmbyAuthByNameReqAcceptsFormBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/Users/AuthenticateByName", strings.NewReader("Username=bob&Pw=secret"))
|
||||
c.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
req, err := parseEmbyAuthByNameReq(c)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEmbyAuthByNameReq returned error: %v", err)
|
||||
}
|
||||
if req.Username != "bob" || req.Pw != "secret" {
|
||||
t.Fatalf("unexpected request: %#v", req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEmbyAuthByNameReqAcceptsJSONWithoutContentType(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/emby/users/authenticatebyname", strings.NewReader(`{"UserName":"carol","PW":"secret"}`))
|
||||
|
||||
req, err := parseEmbyAuthByNameReq(c)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEmbyAuthByNameReq returned error: %v", err)
|
||||
}
|
||||
if req.Username != "carol" || req.Pw != "secret" {
|
||||
t.Fatalf("unexpected request: %#v", req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyAuthenticateByNameAcceptsCaseVariantUsernameAndPath(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.UserPermission{}, &model.RefreshToken{}, &model.Setting{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
log := zap.NewNop()
|
||||
permissions := service.NewPermissionService(log, repos)
|
||||
auth := service.NewAuthService(cfg, log, repos, service.NewTokenService(cfg, log, repos), permissions)
|
||||
if _, _, err := auth.Register(context.Background(), "viewer", "secret-pass"); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
|
||||
Repo: repos,
|
||||
Auth: auth,
|
||||
Emby: service.NewEmbyService(cfg, log, repos),
|
||||
Audit: service.NewAuditService(log, repos),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/emby/users/authenticatebyname", strings.NewReader(`{"Username":"Viewer","Pw":"secret-pass"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload["AccessToken"] == "" {
|
||||
t.Fatalf("missing AccessToken: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyAuthenticateRecordsMediaBrowserClientInfo(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
log := zap.NewNop()
|
||||
permissions := service.NewPermissionService(log, repos)
|
||||
auth := service.NewAuthService(cfg, log, repos, service.NewTokenService(cfg, log, repos), permissions)
|
||||
if _, _, err := auth.Register(context.Background(), "viewer", "secret-pass"); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
|
||||
Repo: repos,
|
||||
Auth: auth,
|
||||
Emby: service.NewEmbyService(cfg, log, repos),
|
||||
Device: service.NewDeviceService(log, repos),
|
||||
Audit: service.NewAuditService(log, repos),
|
||||
Permissions: permissions,
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(`{"Username":"viewer","Pw":"secret-pass"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-MediaBrowser-Authorization", `MediaBrowser Client="Infuse", Device="PC", DeviceId="device-42"`)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
user, err := repos.User.FindByUsername(context.Background(), "viewer")
|
||||
if err != nil {
|
||||
t.Fatalf("find user: %v", err)
|
||||
}
|
||||
devices, err := repos.UserDevice.ListByUser(context.Background(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list devices: %v", err)
|
||||
}
|
||||
if len(devices) != 1 {
|
||||
t.Fatalf("devices = %#v, want one recorded device", devices)
|
||||
}
|
||||
if devices[0].DeviceID != "device-42" || devices[0].DeviceName != "PC" || devices[0].Client != "Infuse" {
|
||||
t.Fatalf("device info not parsed from MediaBrowser header: %#v", devices[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbyUserIdDirectTokenCompatibility covers clients (e.g. 小幻影视 / Hills)
|
||||
// that send `X-Emby-Token=UserId="<uuid>"` as the auth credential instead of a
|
||||
// JWT. The server must accept a valid, non-disabled user id in that format and
|
||||
// let the request through (user validity is enforced by activeEmbyUserRequired).
|
||||
func TestEmbyUserIdDirectTokenCompatibility(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
// :memory: SQLite 每个连接是独立库,必须锁单连接。
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
log := zap.NewNop()
|
||||
permissions := service.NewPermissionService(log, repos)
|
||||
auth := service.NewAuthService(cfg, log, repos, service.NewTokenService(cfg, log, repos), permissions)
|
||||
user, _, err := auth.Register(context.Background(), "viewer", "secret-pass")
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
|
||||
Repo: repos,
|
||||
Auth: auth,
|
||||
Emby: service.NewEmbyService(cfg, log, repos),
|
||||
Device: service.NewDeviceService(log, repos),
|
||||
Audit: service.NewAuditService(log, repos),
|
||||
Permissions: permissions,
|
||||
})
|
||||
|
||||
// 1) UserId="<uuid>" direct credential must pass for a valid user.
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Users/Me?X-Emby-Token="+url.QueryEscape(`UserId="`+user.ID+`"`), nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("userId direct token (query) = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 1b) Emby UserId="<uuid>" in X-Emby-Authorization header (RodelPlayer / 小幻影视 style).
|
||||
req = httptest.NewRequest(http.MethodGet, "/emby/Users/Me", nil)
|
||||
req.Header.Set("X-Emby-Authorization", `Emby UserId="`+user.ID+`", Client="RodelPlayer", Device="WHILETRUE", DeviceId="dev-1", Version="2.2607.7.0"`)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("userId direct token (X-Emby-Authorization header) = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 2) A random / non-existent user id in the same format must be rejected.
|
||||
req = httptest.NewRequest(http.MethodGet, "/emby/Users/Me?X-Emby-Token="+url.QueryEscape(`UserId="does-not-exist"`), nil)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("bogus userId direct token = %d, want 401", w.Code)
|
||||
}
|
||||
|
||||
// 2b) Non-existent user in X-Emby-Authorization header must be rejected too.
|
||||
req = httptest.NewRequest(http.MethodGet, "/emby/Users/Me", nil)
|
||||
req.Header.Set("X-Emby-Authorization", `Emby UserId="does-not-exist", Client="RodelPlayer"`)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("bogus userId direct token (header) = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
// TestEmbyLoginThenAuthedRequest simulates the exact flow an Emby-compatible
|
||||
// client performs: POST /Users/AuthenticateByName to obtain an AccessToken,
|
||||
// then immediately reuses that token to fetch /Users/Me and /Items.
|
||||
//
|
||||
// This guards against regressions where login succeeds but the returned token
|
||||
// fails downstream auth (the "每次登录后立刻 401 Invalid token" report).
|
||||
func TestEmbyLoginThenAuthedRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
log := zap.NewNop()
|
||||
permissions := service.NewPermissionService(log, repos)
|
||||
tokenSvc := service.NewTokenService(cfg, log, repos)
|
||||
auth := service.NewAuthService(cfg, log, repos, tokenSvc, permissions)
|
||||
if _, _, err := auth.Register(context.Background(), "viewer", "secret-pass"); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
|
||||
Repo: repos,
|
||||
Auth: auth,
|
||||
Emby: service.NewEmbyService(cfg, log, repos),
|
||||
Device: service.NewDeviceService(log, repos),
|
||||
Audit: service.NewAuditService(log, repos),
|
||||
Permissions: permissions,
|
||||
})
|
||||
|
||||
loginBody := `{"Username":"viewer","Pw":"secret-pass"}`
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(loginBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var login struct {
|
||||
AccessToken string `json:"AccessToken"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &login); err != nil {
|
||||
t.Fatalf("decode login: %v", err)
|
||||
}
|
||||
if login.AccessToken == "" {
|
||||
t.Fatalf("empty AccessToken from login")
|
||||
}
|
||||
|
||||
// Reuse the returned token against authenticated endpoints.
|
||||
for _, path := range []string{"/emby/Users/Me", "/emby/Items", "/emby/Library/VirtualFolders"} {
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("X-Emby-Token", login.AccessToken)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s with returned token = %d body=%s", path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbyLoginWithAuthorizationHeaderToken covers clients that place the
|
||||
// bearer token in Authorization instead of X-Emby-Token.
|
||||
func TestEmbyLoginWithAuthorizationHeaderToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
// :memory: SQLite 每个连接是独立库,必须锁单连接否则表在不同连接上互相不可见。
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
log := zap.NewNop()
|
||||
permissions := service.NewPermissionService(log, repos)
|
||||
auth := service.NewAuthService(cfg, log, repos, service.NewTokenService(cfg, log, repos), permissions)
|
||||
if _, _, err := auth.Register(context.Background(), "viewer", "secret-pass"); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
|
||||
Repo: repos,
|
||||
Auth: auth,
|
||||
Emby: service.NewEmbyService(cfg, log, repos),
|
||||
Device: service.NewDeviceService(log, repos),
|
||||
Audit: service.NewAuditService(log, repos),
|
||||
Permissions: permissions,
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(`{"Username":"viewer","Pw":"secret-pass"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var login struct {
|
||||
AccessToken string `json:"AccessToken"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &login); err != nil {
|
||||
t.Fatalf("decode login: %v", err)
|
||||
}
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/emby/Users/Me", nil)
|
||||
req.Header.Set("Authorization", "MediaBrowser Token=\""+login.AccessToken+"\"")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Authorization-token request = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func TestEmbyWithRequestAddressUsesHost(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "http://192.168.1.4:18080/System/Info/Public", nil)
|
||||
|
||||
payload := embyWithRequestAddress(c, map[string]any{"Id": "mmtl-001"})
|
||||
|
||||
if payload["LocalAddress"] != "http://192.168.1.4:18080" {
|
||||
t.Fatalf("unexpected LocalAddress: %#v", payload["LocalAddress"])
|
||||
}
|
||||
if payload["WanAddress"] != "http://192.168.1.4:18080" {
|
||||
t.Fatalf("unexpected WanAddress: %#v", payload["WanAddress"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyWithRequestAddressHonorsForwardedHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "http://127.0.0.1/System/Info/Public", nil)
|
||||
c.Request.Header.Set("X-Forwarded-Proto", "https")
|
||||
c.Request.Header.Set("X-Forwarded-Host", "media.example.test")
|
||||
|
||||
payload := embyWithRequestAddress(c, map[string]any{"Id": "mmtl-001"})
|
||||
|
||||
if payload["LocalAddress"] != "https://media.example.test" {
|
||||
t.Fatalf("unexpected LocalAddress: %#v", payload["LocalAddress"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyPublicSystemInfoLooksLikeModernEmbyServer(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, "test-secret", &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/System/Info/Public", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode system info: %v", err)
|
||||
}
|
||||
if payload["ProductName"] != "Emby Server" {
|
||||
t.Fatalf("ProductName = %#v, want Emby Server", payload["ProductName"])
|
||||
}
|
||||
version, _ := payload["Version"].(string)
|
||||
if !strings.HasPrefix(version, "4.") {
|
||||
t.Fatalf("Version = %q, want Emby-compatible 4.x", version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyMobileCompatibilityRoutesAvoidPlaybackBlocking404s(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "viewer",
|
||||
PasswordHash: "hash",
|
||||
Role: "admin",
|
||||
IsActive: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
repos := repository.New(db)
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(cfg, zap.NewNop(), repos),
|
||||
})
|
||||
|
||||
token := signedTestToken(t, cfg.Secrets.JWTSecret)
|
||||
tests := []struct {
|
||||
path string
|
||||
auth bool
|
||||
wantCode int
|
||||
}{
|
||||
{path: "/emby/System/Ext/ServerDomains", wantCode: http.StatusOK},
|
||||
{path: "/emby/Items/msgo-series-demo/Similar", auth: true, wantCode: http.StatusOK},
|
||||
{path: "/emby/api/danmu/media-demo/raw", auth: true, wantCode: http.StatusOK},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
if tc.auth {
|
||||
req.Header.Set("X-Emby-Token", token)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != tc.wantCode {
|
||||
t.Fatalf("%s status = %d body=%s", tc.path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyOfficialClientProbeRoutesAvoidHomepageBlocking404s(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
})
|
||||
token := signedTestToken(t, secret)
|
||||
tests := []struct {
|
||||
method string
|
||||
path string
|
||||
auth bool
|
||||
}{
|
||||
{method: http.MethodGet, path: "/emby/CustomCssJS/Scripts"},
|
||||
{method: http.MethodGet, path: "/emby/Localization/cultures"},
|
||||
{method: http.MethodPost, path: "/emby/Sessions/Logout"},
|
||||
{method: http.MethodGet, path: "/emby/System/WakeOnLanInfo", auth: true},
|
||||
{method: http.MethodGet, path: "/emby/ScheduledTasks", auth: true},
|
||||
{method: http.MethodGet, path: "/emby/LiveTv/Recordings", auth: true},
|
||||
{method: http.MethodGet, path: "/emby/System/ActivityLog/Entries", auth: true},
|
||||
{method: http.MethodGet, path: "/emby/web/configurationpages", auth: true},
|
||||
{method: http.MethodPost, path: "/emby/Users/user-1/Configuration", auth: true},
|
||||
{method: http.MethodGet, path: "/emby/Items/Latest?UserId=user-1", auth: true},
|
||||
{method: http.MethodGet, path: "/emby/Items/Resume?UserId=user-1", auth: true},
|
||||
{method: http.MethodGet, path: "/emby/Genres", auth: true},
|
||||
{method: http.MethodGet, path: "/emby/Shows/Upcoming", auth: true},
|
||||
{method: http.MethodGet, path: "/emby/Items/item-1/ThumbnailSet", auth: true},
|
||||
{method: http.MethodGet, path: "/emby/Items/item-1/ThemeMedia", auth: true},
|
||||
{method: http.MethodGet, path: "/emby/Users/user-1/Items/item-1/SpecialFeatures", auth: true},
|
||||
{method: http.MethodGet, path: "/emby/Users/user-1/Items/item-1/Intros", auth: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tt.method, tt.path, nil)
|
||||
if tt.auth {
|
||||
req.Header.Set("X-Emby-Token", token)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code == http.StatusNotFound {
|
||||
t.Fatalf("route returned 404 body=%s", w.Body.String())
|
||||
}
|
||||
if w.Code >= 500 {
|
||||
t.Fatalf("route returned %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbySenPlayerDiscoveryRoutesReturnProtocolResponses(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.App.Port = 9011
|
||||
repos := repository.New(db)
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, "test-secret", &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(cfg, zap.NewNop(), repos),
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
contentType string
|
||||
contains string
|
||||
}{
|
||||
{path: "/emby", contentType: "application/json", contains: "Emby Server"},
|
||||
{path: "/emby/", contentType: "application/json", contains: "Emby Server"},
|
||||
{path: "/Startup/Configuration", contentType: "application/json", contains: "StartupWizardCompleted"},
|
||||
{path: "/emby/Startup/Configuration", contentType: "application/json", contains: "StartupWizardCompleted"},
|
||||
{path: "/System/Configuration/Public", contentType: "application/json", contains: "IsStartupWizardCompleted"},
|
||||
{path: "/emby/System/Configuration/Public", contentType: "application/json", contains: "IsStartupWizardCompleted"},
|
||||
{path: "/QuickConnect/Enabled", contentType: "application/json", contains: "false"},
|
||||
{path: "/emby/QuickConnect/Enabled", contentType: "application/json", contains: "false"},
|
||||
{path: "/Branding/Css", contentType: "text/css", contains: ""},
|
||||
{path: "/emby/Branding/Css", contentType: "text/css", contains: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if contentType := w.Header().Get("Content-Type"); !strings.Contains(contentType, tt.contentType) {
|
||||
t.Fatalf("Content-Type = %q, want %q", contentType, tt.contentType)
|
||||
}
|
||||
if tt.contains != "" && !strings.Contains(w.Body.String(), tt.contains) {
|
||||
t.Fatalf("body = %q, want contains %q", w.Body.String(), tt.contains)
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "<html") {
|
||||
t.Fatalf("protocol discovery route served SPA HTML: %q", w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
var embyPlaceholderPNG = []byte{
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
||||
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41,
|
||||
0x54, 0x78, 0x9c, 0x63, 0x50, 0xd1, 0x30, 0xf8,
|
||||
0x0f, 0x00, 0x02, 0x6c, 0x01, 0x7c, 0x30, 0xed,
|
||||
0x6e, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
|
||||
0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
}
|
||||
|
||||
// embyItemImageHandler 把 /Items/{id}/Images/Primary 等请求直接输出为图片。
|
||||
// Emby 客户端缓存图片 URL 时经常不会继续携带 token;如果重定向到受保护的
|
||||
// /api/img 会变成 401,所以这里复用 ImageProxy 但不再走 /api 路由。
|
||||
func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
clearEmbyImageNoStoreHeaders(c)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
req := c.Request.WithContext(ctx)
|
||||
id := c.Param("id")
|
||||
imgType := strings.ToLower(c.Param("type"))
|
||||
raw, err := svc.Emby.ImageURL(ctx, id, imgType)
|
||||
if err != nil || raw == "" {
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
if svc.ImageProxy == nil {
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
|
||||
embyServePlaceholderImage(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clearEmbyImageNoStoreHeaders(c *gin.Context) {
|
||||
c.Writer.Header().Del("Pragma")
|
||||
c.Writer.Header().Del("Expires")
|
||||
}
|
||||
|
||||
func embyServePlaceholderImage(c *gin.Context) {
|
||||
c.Header("Content-Type", "image/png")
|
||||
c.Header("Cache-Control", "public, max-age=3600")
|
||||
c.Header("Content-Length", strconv.Itoa(len(embyPlaceholderPNG)))
|
||||
if c.Request.Method == http.MethodHead {
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "image/png", embyPlaceholderPNG)
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "50"))
|
||||
offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
||||
}
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
splitOpt := func(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return service.ItemsParams{
|
||||
UserID: uid,
|
||||
ParentID: firstQueryValue(c, "ParentId", "parentId", "parentid"),
|
||||
IDs: splitOpt(firstQueryValue(c, "Ids", "ids")),
|
||||
SearchTerm: firstQueryValue(c, "SearchTerm", "searchTerm", "searchterm"),
|
||||
IncludeItemTypes: splitOpt(firstQueryValue(c, "IncludeItemTypes", "includeItemTypes", "includeitemtypes")),
|
||||
Filters: splitOpt(firstQueryValue(c, "Filters", "filters")),
|
||||
Recursive: strings.EqualFold(firstQueryValue(c, "Recursive", "recursive"), "true"),
|
||||
SortBy: firstQueryValue(c, "SortBy", "sortBy", "sortby"),
|
||||
SortOrder: firstQueryValue(c, "SortOrder", "sortOrder", "sortorder"),
|
||||
Limit: limit,
|
||||
StartIndex: offset,
|
||||
}
|
||||
}
|
||||
|
||||
func embyFirstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func embyItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
out, err := svc.Emby.Items(c.Request.Context(), parseEmbyItemsParams(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
embyAttachRequestTokenToMediaSources(c, out)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
out, err := svc.Emby.Item(c.Request.Context(), id, uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if out == nil {
|
||||
embyError(c, http.StatusNotFound, "item not found")
|
||||
return
|
||||
}
|
||||
embyAttachRequestTokenToMediaSources(c, out)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func embyUserItemByIDHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
switch strings.ToLower(c.Param("id")) {
|
||||
case "latest":
|
||||
embyLatestItemsHandler(svc)(c)
|
||||
case "resume":
|
||||
embyResumeItemsHandler(svc)(c)
|
||||
default:
|
||||
embyItemByIDHandler(svc)(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
||||
}
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
|
||||
out, err := svc.Emby.LatestItems(c.Request.Context(), uid, firstQueryValue(c, "ParentId", "parentId", "parentid"), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
embyAttachRequestTokenToMediaSources(c, out)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
||||
}
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
|
||||
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
embyAttachRequestTokenToMediaSources(c, out)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func embyItemsCountsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc != nil && svc.Emby != nil {
|
||||
uid := firstQueryValue(c, "UserId", "userId")
|
||||
if uid == "" {
|
||||
uid = c.Param("userId")
|
||||
}
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
out, err := svc.Emby.ItemCounts(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"MovieCount": 0,
|
||||
"SeriesCount": 0,
|
||||
"EpisodeCount": 0,
|
||||
"ItemCount": 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func embyDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"Id": c.Param("id"),
|
||||
"ViewType": "Poster",
|
||||
"SortBy": "SortName",
|
||||
"SortOrder": "Ascending",
|
||||
"IndexBy": "SortName",
|
||||
"RememberIndexing": false,
|
||||
"PrimaryImageHeight": 250,
|
||||
"PrimaryImageWidth": 250,
|
||||
"ScrollDirection": "Vertical",
|
||||
"ShowSidebar": true,
|
||||
"CustomPrefs": gin.H{
|
||||
"homeexploresection": "1",
|
||||
"homesection0": "smalllibrarytiles",
|
||||
"homesection1": "resume",
|
||||
"homesection2": "none",
|
||||
"homesection3": "nextup",
|
||||
"homesection4": "none",
|
||||
"homesection5": "none",
|
||||
"homesection6": "none",
|
||||
"latestItems": "false",
|
||||
"landing-livetv": "false",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func embySaveDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
params := service.ItemsParams{
|
||||
UserID: firstQueryValue(c, "UserId", "userId"),
|
||||
ParentID: c.Param("id"),
|
||||
Limit: 500,
|
||||
}
|
||||
out, err := svc.Emby.Items(c.Request.Context(), params)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
embyAttachRequestTokenToMediaSources(c, out)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
parentID := firstQueryValue(c, "SeasonId", "seasonId")
|
||||
if parentID == "" {
|
||||
parentID = c.Param("id")
|
||||
}
|
||||
params := service.ItemsParams{
|
||||
UserID: firstQueryValue(c, "UserId", "userId"),
|
||||
ParentID: parentID,
|
||||
IncludeItemTypes: []string{"Episode"},
|
||||
Recursive: true,
|
||||
Limit: 500,
|
||||
}
|
||||
out, err := svc.Emby.Items(c.Request.Context(), params)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
embyAttachRequestTokenToMediaSources(c, out)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func TestEmbyItemImageServesWithoutAPIAuth(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Media{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
posterPath := filepath.Join(t.TempDir(), "poster.png")
|
||||
if err := os.WriteFile(posterPath, []byte{
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
||||
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41,
|
||||
0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
|
||||
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00,
|
||||
0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
|
||||
0x42, 0x60, 0x82,
|
||||
}, 0o644); err != nil {
|
||||
t.Fatalf("write poster: %v", err)
|
||||
}
|
||||
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{
|
||||
App: config.AppConfig{DataDir: filepath.Dir(posterPath)},
|
||||
Cache: config.CacheConfig{CacheDir: t.TempDir()},
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "media-1"},
|
||||
Title: "Poster Test",
|
||||
Path: "D:\\media\\poster-test.mp4",
|
||||
PosterURL: posterPath,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, "test-secret", &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(cfg, zap.NewNop(), repos),
|
||||
ImageProxy: service.NewImageProxy(cfg, zap.NewNop()),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items/media-1/Images/Primary", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if location := w.Header().Get("Location"); location != "" {
|
||||
t.Fatalf("expected direct image response, got redirect to %q", location)
|
||||
}
|
||||
if contentType := w.Header().Get("Content-Type"); !strings.Contains(contentType, "image/png") {
|
||||
t.Fatalf("expected png content type, got %q", contentType)
|
||||
}
|
||||
if got := w.Header().Get("Cache-Control"); !strings.Contains(got, "max-age=2592000") {
|
||||
t.Fatalf("image Cache-Control = %q, want long browser cache", got)
|
||||
}
|
||||
if got := w.Header().Get("Pragma"); got != "" {
|
||||
t.Fatalf("image Pragma = %q, want empty", got)
|
||||
}
|
||||
if got := w.Header().Get("Expires"); got != "" {
|
||||
t.Fatalf("image Expires = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyMissingItemImageReturnsTransparentPlaceholder(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, "test-secret", &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(cfg, zap.NewNop(), repos),
|
||||
ImageProxy: service.NewImageProxy(cfg, zap.NewNop()),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodHead, "/Items/missing/Images/Primary", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected placeholder status 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if contentType := w.Header().Get("Content-Type"); !strings.Contains(contentType, "image/png") {
|
||||
t.Fatalf("expected png content type, got %q", contentType)
|
||||
}
|
||||
if length := w.Header().Get("Content-Length"); length == "" || length == "0" {
|
||||
t.Fatalf("expected placeholder content length, got %q", length)
|
||||
}
|
||||
if got := w.Header().Get("Pragma"); got != "" {
|
||||
t.Fatalf("placeholder Pragma = %q, want empty", got)
|
||||
}
|
||||
if got := w.Header().Get("Expires"); got != "" {
|
||||
t.Fatalf("placeholder Expires = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyUserItemByIDRouteReturnsJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.Favorite{}, &model.PlaybackHistory{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
lib := model.Library{Name: "剧集", Path: "D:\\media\\tv", Type: "tv", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "episode-1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Test Show",
|
||||
Path: "D:\\media\\tv\\Test Show\\Season 01\\Test Show - S01E01.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
Container: "mkv",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Users/user-1/Items/episode-1", nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
req.Header.Set("If-None-Match", `"stale-client-cache"`)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if contentType := w.Header().Get("Content-Type"); !strings.Contains(contentType, "application/json") {
|
||||
t.Fatalf("expected JSON content type, got %q body=%s", contentType, w.Body.String())
|
||||
}
|
||||
var item map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &item); err != nil {
|
||||
t.Fatalf("decode item: %v", err)
|
||||
}
|
||||
if item["Id"] != "episode-1" || item["Type"] != "Episode" {
|
||||
t.Fatalf("unexpected item payload: %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyUserItemByIDRouteReturnsLibraryView(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
lib := model.Library{Base: model.Base{ID: "lib-tv"}, Name: "剧集", Path: "D:\\media\\tv", Type: "tv", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Users/user-1/Items/lib-tv", nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var item map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &item); err != nil {
|
||||
t.Fatalf("decode item: %v", err)
|
||||
}
|
||||
if item["Id"] != "lib-tv" || item["Type"] != "CollectionFolder" || item["CollectionType"] != "tvshows" {
|
||||
t.Fatalf("unexpected library payload: %#v", item)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/gorilla/websocket"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func TestEmbyVirtualFoldersRouteReturnsJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Library{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
for _, lib := range []model.Library{
|
||||
{Name: "电影", Path: "D:\\media\\movies", Type: "movie", Enabled: true},
|
||||
{Name: "剧集", Path: "D:\\media\\tv", Type: "tv", Enabled: true},
|
||||
{Name: "综艺", Path: "D:\\media\\variety", Type: "variety", Enabled: true},
|
||||
} {
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{Repo: repos})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Library/VirtualFolders", nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if contentType := w.Header().Get("Content-Type"); !strings.Contains(contentType, "application/json") {
|
||||
t.Fatalf("expected JSON content type, got %q body=%s", contentType, w.Body.String())
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(w.Body.String()), "<!doctype html>") {
|
||||
t.Fatalf("route returned frontend HTML instead of JSON")
|
||||
}
|
||||
|
||||
var folders []map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &folders); err != nil {
|
||||
t.Fatalf("decode folders: %v", err)
|
||||
}
|
||||
if len(folders) != 3 {
|
||||
t.Fatalf("expected 3 folders, got %d: %#v", len(folders), folders)
|
||||
}
|
||||
if folders[1]["CollectionType"] != "tvshows" || folders[2]["CollectionType"] != "tvshows" {
|
||||
t.Fatalf("episodic libraries should expose tvshows collection type: %#v", folders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyItemsCountsRouteReturnsJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{Repo: repos})
|
||||
|
||||
for _, path := range []string{"/Items/Counts", "/Users/user-1/Items/Counts", "/items/counts"} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("%s status=%d body=%s", path, w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("%s decode response: %v", path, err)
|
||||
}
|
||||
if _, ok := body["MovieCount"]; !ok {
|
||||
t.Fatalf("%s missing MovieCount: %#v", path, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyDisplayPreferencesAllowsAnonymousCompatibility(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, "secret", &service.Container{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/DisplayPreferences/usersettings", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected GET status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode display preferences: %v", err)
|
||||
}
|
||||
if body["Id"] != "usersettings" {
|
||||
t.Fatalf("unexpected preferences payload: %#v", body)
|
||||
}
|
||||
customPrefs, ok := body["CustomPrefs"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("missing CustomPrefs: %#v", body)
|
||||
}
|
||||
if customPrefs["homesection0"] != "smalllibrarytiles" || customPrefs["homesection2"] != "none" || customPrefs["latestItems"] != "false" {
|
||||
t.Fatalf("homepage sections should expose library tiles without duplicate latest rails: %#v", customPrefs)
|
||||
}
|
||||
if body["ScrollDirection"] != "Vertical" {
|
||||
t.Fatalf("homepage should prefer vertical library browsing, got %#v", body)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/emby/displaypreferences/usersettings", strings.NewReader(`{}`))
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("unexpected POST status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyWebSocketRouteUpgradesForOfficialClients(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, "secret", &service.Container{})
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/embywebsocket?api_key=test-token&deviceId=device-1"
|
||||
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
status := 0
|
||||
if resp != nil {
|
||||
status = resp.StatusCode
|
||||
}
|
||||
t.Fatalf("websocket dial failed status=%d err=%v", status, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if resp == nil || resp.StatusCode != http.StatusSwitchingProtocols {
|
||||
t.Fatalf("expected websocket upgrade, got resp=%#v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyWebSocketRefreshesRealtimeActivity(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "viewer",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
const secret = "test-secret"
|
||||
tracker := service.NewSessionTrackerService(zap.NewNop())
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Sessions: tracker,
|
||||
})
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/embywebsocket?deviceId=device-1&device=Windows&client=Emby"
|
||||
header := http.Header{"X-Emby-Token": []string{signedTestToken(t, secret)}}
|
||||
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, header)
|
||||
if err != nil {
|
||||
status := 0
|
||||
if resp != nil {
|
||||
status = resp.StatusCode
|
||||
}
|
||||
t.Fatalf("websocket dial failed status=%d err=%v", status, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
sessions := tracker.List(t.Context())
|
||||
if len(sessions) != 1 {
|
||||
t.Fatalf("sessions = %#v, want websocket heartbeat session", sessions)
|
||||
}
|
||||
if sessions[0].DeviceID != "device-1" || sessions[0].DeviceName != "Windows" || sessions[0].Client != "Emby" {
|
||||
t.Fatalf("websocket did not refresh client session: %#v", sessions[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/middleware"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
repos := repository.New(db)
|
||||
user := &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "test_viewer",
|
||||
PasswordHash: "x",
|
||||
Role: "user",
|
||||
Tier: "free",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := repos.User.Create(t.Context(), user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{}
|
||||
logger := zap.NewNop()
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(cfg, logger, repos),
|
||||
Sessions: service.NewSessionTrackerService(logger),
|
||||
Playback: service.NewPlaybackService(logger, repos),
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
// 注册带认证的路由,模拟已登录用户
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set(middleware.CtxUserID, user.ID)
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/Sessions/Playing/Progress", embyPlayingProgressHandler(svc))
|
||||
router.GET("/Items", embyItemsHandler(svc))
|
||||
router.GET("/Users/:userId/Items/Resume", embyResumeItemsHandler(svc))
|
||||
router.GET("/Sessions", embySessionsHandler(svc))
|
||||
|
||||
remoteMediaID := service.EncodeEmbyRemoteID("mount-1", "remote-item-123")
|
||||
|
||||
// 1. 测试上报进度:客户端使用小写 query 参数 itemId / positionTicks
|
||||
progressReq := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000",
|
||||
nil,
|
||||
)
|
||||
wProgress := httptest.NewRecorder()
|
||||
router.ServeHTTP(wProgress, progressReq)
|
||||
if wProgress.Code != http.StatusNoContent {
|
||||
t.Fatalf("progress status = %d, body = %s", wProgress.Code, wProgress.Body.String())
|
||||
}
|
||||
|
||||
// 验证已持久化到 PlaybackHistory
|
||||
var hist model.PlaybackHistory
|
||||
if err := db.Where("user_id = ? AND media_id = ?", user.ID, remoteMediaID).First(&hist).Error; err != nil {
|
||||
t.Fatalf("playback history not saved: %v", err)
|
||||
}
|
||||
if hist.PositionMs != 30000 {
|
||||
t.Fatalf("expected position_ms = 30000, got %d", hist.PositionMs)
|
||||
}
|
||||
|
||||
// 2. 测试 Filters=IsResumable 能够包含该远程条目
|
||||
resumableReq := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/Items?Filters=IsResumable",
|
||||
nil,
|
||||
)
|
||||
wResumable := httptest.NewRecorder()
|
||||
router.ServeHTTP(wResumable, resumableReq)
|
||||
if wResumable.Code != http.StatusOK {
|
||||
t.Fatalf("items resumable status = %d, body = %s", wResumable.Code, wResumable.Body.String())
|
||||
}
|
||||
var resumableEnvelope map[string]any
|
||||
if err := json.Unmarshal(wResumable.Body.Bytes(), &resumableEnvelope); err != nil {
|
||||
t.Fatalf("decode resumable: %v", err)
|
||||
}
|
||||
// 因为没有配置真实的远程客户端连接,该远程条目在当前离线测试中不会 panic 崩溃,并且正常响应 Envelope
|
||||
if resumableEnvelope["TotalRecordCount"] == nil {
|
||||
t.Fatalf("missing TotalRecordCount in resumable envelope")
|
||||
}
|
||||
|
||||
// 3. 测试 /Users/:userId/Items/Resume 别名路由
|
||||
resumeAliasReq := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/Users/"+user.ID+"/Items/Resume",
|
||||
nil,
|
||||
)
|
||||
wResumeAlias := httptest.NewRecorder()
|
||||
router.ServeHTTP(wResumeAlias, resumeAliasReq)
|
||||
if wResumeAlias.Code != http.StatusOK {
|
||||
t.Fatalf("resume alias status = %d, body = %s", wResumeAlias.Code, wResumeAlias.Body.String())
|
||||
}
|
||||
|
||||
// 4. 测试 /Sessions 返回 NowPlayingItem
|
||||
sessionsReq := httptest.NewRequest(http.MethodGet, "/Sessions", nil)
|
||||
wSessions := httptest.NewRecorder()
|
||||
router.ServeHTTP(wSessions, sessionsReq)
|
||||
if wSessions.Code != http.StatusOK {
|
||||
t.Fatalf("sessions status = %d, body = %s", wSessions.Code, wSessions.Body.String())
|
||||
}
|
||||
var sessionsList []map[string]any
|
||||
if err := json.Unmarshal(wSessions.Body.Bytes(), &sessionsList); err != nil {
|
||||
t.Fatalf("decode sessions: %v", err)
|
||||
}
|
||||
if len(sessionsList) == 0 {
|
||||
t.Fatalf("expected at least 1 session")
|
||||
}
|
||||
nowPlaying, ok := sessionsList[0]["NowPlayingItem"].(map[string]any)
|
||||
if !ok || nowPlaying["Id"] != remoteMediaID {
|
||||
t.Fatalf("expected NowPlayingItem with id %q, got %#v", remoteMediaID, sessionsList[0]["NowPlayingItem"])
|
||||
}
|
||||
}
|
||||
|
||||
func signMockToken(secret, userID string) string {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"sub": userID,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
s, _ := token.SignedString([]byte(secret))
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// Emby 挂载管理 HTTP 层:远程 Emby 服务器(账号)下的媒体库挂载 CRUD,
|
||||
// 以及账号远程媒体库(View)列表预览。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
// embyMountView 挂载的对外 JSON(附带账号信息)。
|
||||
type embyMountView struct {
|
||||
model.EmbyMount
|
||||
AccountName string `json:"account_name"`
|
||||
}
|
||||
|
||||
// embyMountInput 创建挂载的请求体(单个或批量)。
|
||||
type embyMountInput struct {
|
||||
AccountID string `json:"account_id" binding:"required"`
|
||||
Views []embyViewInput `json:"views" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
type embyViewInput struct {
|
||||
RemoteViewID string `json:"remote_view_id" binding:"required"`
|
||||
RemoteViewName string `json:"remote_view_name"`
|
||||
CollectionType string `json:"collection_type"`
|
||||
Name string `json:"name"`
|
||||
ProxyPlay bool `json:"proxy_play"`
|
||||
}
|
||||
|
||||
func embyMountViews(mounts []model.EmbyMount, accounts map[string]string) []embyMountView {
|
||||
out := make([]embyMountView, 0, len(mounts))
|
||||
for _, m := range mounts {
|
||||
out = append(out, embyMountView{EmbyMount: m, AccountName: accounts[m.AccountID]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// embyAccountViewsHandler 列出账号上的远程媒体库(View),供挂载选择。
|
||||
func embyAccountViewsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
acct := svc.EmbyRemote.AccountByID(c.Request.Context(), c.Param("id"))
|
||||
if acct == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "账号不存在或已禁用"})
|
||||
return
|
||||
}
|
||||
views, err := svc.EmbyRemote.RemoteViews(c.Request.Context(), acct)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
type viewEntry struct {
|
||||
RemoteViewID string `json:"remote_view_id"`
|
||||
RemoteViewName string `json:"remote_view_name"`
|
||||
CollectionType string `json:"collection_type"`
|
||||
ChildCount int `json:"child_count"`
|
||||
AlreadyMounted bool `json:"already_mounted"`
|
||||
}
|
||||
mounted := map[string]bool{}
|
||||
if mounts, err := svc.EmbyRemote.ListMountsByAccount(c.Request.Context(), acct.ID); err == nil {
|
||||
for _, m := range mounts {
|
||||
mounted[m.RemoteViewID] = true
|
||||
}
|
||||
}
|
||||
out := make([]viewEntry, 0, len(views))
|
||||
for _, v := range views {
|
||||
viewID := service.RemoteItemIDString(v)
|
||||
if strings.TrimSpace(viewID) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, viewEntry{
|
||||
RemoteViewID: viewID,
|
||||
RemoteViewName: service.RemoteItemNameString(v),
|
||||
CollectionType: service.RemoteItemCollectionType(v),
|
||||
ChildCount: service.RemoteItemChildCount(v),
|
||||
AlreadyMounted: mounted[viewID],
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// listEmbyMountsHandler 列出全部挂载。
|
||||
func listEmbyMountsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
mounts, err := svc.EmbyRemote.ListMounts(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
names := map[string]string{}
|
||||
if accounts, err := svc.EmbyRemote.ListAccounts(c.Request.Context()); err == nil {
|
||||
for _, a := range accounts {
|
||||
names[a.ID] = a.Name
|
||||
}
|
||||
}
|
||||
out := embyMountViews(mounts, names)
|
||||
if out == nil {
|
||||
out = []embyMountView{}
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// createEmbyMountsHandler 批量创建挂载(同一账号下的多个远程媒体库)。
|
||||
func createEmbyMountsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req embyMountInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
mounts := make([]*model.EmbyMount, 0, len(req.Views))
|
||||
for _, v := range req.Views {
|
||||
mounts = append(mounts, &model.EmbyMount{
|
||||
AccountID: req.AccountID,
|
||||
RemoteViewID: v.RemoteViewID,
|
||||
RemoteViewName: v.RemoteViewName,
|
||||
CollectionType: v.CollectionType,
|
||||
Name: v.Name,
|
||||
ProxyPlay: v.ProxyPlay,
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
if _, err := svc.EmbyRemote.CreateMounts(c.Request.Context(), mounts); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "created": len(mounts)})
|
||||
}
|
||||
}
|
||||
|
||||
// fullMountEmbyAccountHandler 全量挂载:把账号所有远程媒体库一次挂载进来。
|
||||
func fullMountEmbyAccountHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
acct := svc.EmbyRemote.AccountByID(c.Request.Context(), c.Param("id"))
|
||||
if acct == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "账号不存在或已禁用"})
|
||||
return
|
||||
}
|
||||
proxy := c.Query("proxy") == "1" || c.Query("proxy") == "true"
|
||||
n, err := svc.EmbyRemote.FullMountAccount(c.Request.Context(), acct, proxy)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "created": n})
|
||||
}
|
||||
}
|
||||
|
||||
// updateEmbyMountHandler 更新挂载(显示名 / 代理开关 / 启用)。
|
||||
func updateEmbyMountHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
ProxyPlay *bool `json:"proxy_play"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
mount, err := svc.EmbyRemote.MountByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || mount == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "挂载不存在"})
|
||||
return
|
||||
}
|
||||
if req.Name != nil {
|
||||
mount.Name = *req.Name
|
||||
}
|
||||
if req.ProxyPlay != nil {
|
||||
mount.ProxyPlay = *req.ProxyPlay
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
mount.Enabled = *req.Enabled
|
||||
}
|
||||
if _, err := svc.EmbyRemote.UpdateMount(c.Request.Context(), mount.ID, mount); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, mount)
|
||||
}
|
||||
}
|
||||
|
||||
// deleteEmbyMountHandler 删除挂载。
|
||||
func deleteEmbyMountHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.EmbyRemote.DeleteMount(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
type reorderEmbyMountsReq struct {
|
||||
IDs []string `json:"ids" binding:"required"`
|
||||
}
|
||||
|
||||
// reorderEmbyMountsHandler 批量重排挂载媒体库顺序。
|
||||
func reorderEmbyMountsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req reorderEmbyMountsReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if svc.EmbyRemote == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "emby remote service not available"})
|
||||
return
|
||||
}
|
||||
if err := svc.EmbyRemote.ReorderMounts(c.Request.Context(), req.IDs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/database"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func TestReorderEmbyMountsHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
ctx := t.Context()
|
||||
|
||||
m1 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-1", Name: "Mount 1"}
|
||||
m2 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-2", Name: "Mount 2"}
|
||||
_ = repos.EmbyMount.Create(ctx, m1)
|
||||
_ = repos.EmbyMount.Create(ctx, m2)
|
||||
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
EmbyRemote: service.NewEmbyRemoteService(nil, zap.NewNop(), repos, nil),
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.PUT("/admin/emby/mounts/reorder", reorderEmbyMountsHandler(svc))
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"ids": []string{m2.ID, m1.ID},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/admin/emby/mounts/reorder", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
list, err := repos.EmbyMount.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 2 || list[0].ID != m2.ID || list[1].ID != m1.ID {
|
||||
t.Fatalf("expected order [m2, m1], got [m%s, m%s]", list[0].ID, list[1].ID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"), uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if out == nil {
|
||||
embyError(c, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
embyAttachRequestTokenToMediaSources(c, out)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// embySubtitleStreamHandler serves an external subtitle track advertised in a
|
||||
// MediaSource's MediaStreams via its Emby index
|
||||
// (/Videos/:id/Subtitles/:index/Stream). The index maps to a discovered
|
||||
// sideloaded subtitle track next to the video (SRT/ASS/SSA/VTT, local or
|
||||
// cloud://), following the same layout appended by mediaStreams. 远程 Emby
|
||||
// 条目的字幕直接反向代理远程。
|
||||
func embySubtitleStreamHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
encodedID := c.Param("id")
|
||||
if accountID, remoteID, ok := service.DecodeEmbyRemoteID(encodedID); ok {
|
||||
if err := svc.Emby.ProxyRemoteSubtitle(c.Request.Context(), c.Writer, c.Request, accountID, remoteID, c.Param("index")); err != nil {
|
||||
embyError(c, http.StatusNotFound, "subtitle not found")
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
// The official-format route carries a :format suffix (Stream.ass /
|
||||
// Stream.vtt); prefer it for the Content-Type when present, otherwise
|
||||
// fall back to the discovered source codec. :mediaSourceId is ignored —
|
||||
// the item is located by :id and subtitles by :index (1:1 in this shim).
|
||||
format := strings.TrimSpace(c.Param("format"))
|
||||
codec := svc.Emby.SubtitleStreamCodec(ctx, c.Param("id"), c.Param("index"), uid)
|
||||
if codec != "" {
|
||||
if format != "" {
|
||||
codec = service.SubtitleCodecFromFormat(format)
|
||||
}
|
||||
c.Header("Content-Type", service.SubtitleContentType(codec))
|
||||
}
|
||||
c.Header("Cache-Control", "public, max-age=3600")
|
||||
if err := svc.Emby.ServeSubtitleStream(ctx, c.Writer, c.Param("id"), c.Param("index"), uid); err != nil {
|
||||
embyError(c, http.StatusNotFound, "subtitle not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func embyAttachRequestTokenToMediaSources(c *gin.Context, out any) {
|
||||
token := embyRequestToken(c)
|
||||
if token == "" || out == nil {
|
||||
return
|
||||
}
|
||||
embyAttachTokenToMediaSourcesValue(out, token)
|
||||
}
|
||||
|
||||
func embyAttachTokenToMediaSourcesValue(value any, token string) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
embyAttachTokenToMediaSourcesMap(typed, token)
|
||||
case gin.H:
|
||||
embyAttachTokenToMediaSourcesMap(map[string]any(typed), token)
|
||||
case []map[string]any:
|
||||
for _, item := range typed {
|
||||
embyAttachTokenToMediaSourcesMap(item, token)
|
||||
}
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
embyAttachTokenToMediaSourcesValue(item, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func embyAttachTokenToMediaSourcesMap(out map[string]any, token string) {
|
||||
if out == nil {
|
||||
return
|
||||
}
|
||||
if sources, ok := out["MediaSources"].([]map[string]any); ok {
|
||||
embyAttachTokenToMediaSources(sources, token)
|
||||
} else if sources, ok := out["MediaSources"].([]any); ok {
|
||||
for _, source := range sources {
|
||||
if sourceMap, ok := source.(map[string]any); ok {
|
||||
embyAttachTokenToMediaSources([]map[string]any{sourceMap}, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
if items, ok := out["Items"]; ok {
|
||||
embyAttachTokenToMediaSourcesValue(items, token)
|
||||
}
|
||||
}
|
||||
|
||||
func embyAttachTokenToMediaSources(sources []map[string]any, token string) {
|
||||
for _, source := range sources {
|
||||
for _, key := range []string{"DirectStreamUrl", "TranscodingUrl"} {
|
||||
raw, ok := source[key].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
source[key] = embyAppendAPIKey(raw, token)
|
||||
}
|
||||
// Subtitle streams advertise a DeliveryUrl; the official Emby client
|
||||
// fetches it directly, so it must carry the auth token too.
|
||||
if streams, ok := source["MediaStreams"].([]map[string]any); ok {
|
||||
for _, stream := range streams {
|
||||
if stream["Type"] != "Subtitle" {
|
||||
continue
|
||||
}
|
||||
raw, ok := stream["DeliveryUrl"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
stream["DeliveryUrl"] = embyAppendAPIKey(raw, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func embyRequestToken(c *gin.Context) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"api_key", "apiKey", "ApiKey", "token", "X-Emby-Token", "X-MediaBrowser-Token"} {
|
||||
if value := strings.TrimSpace(c.Query(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
for _, header := range []string{"X-Emby-Token", "X-MediaBrowser-Token"} {
|
||||
if value := strings.TrimSpace(c.GetHeader(header)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
for _, header := range []string{"Authorization", "X-Emby-Authorization", "X-MediaBrowser-Authorization"} {
|
||||
if token := embyTokenFromAuthHeader(c.GetHeader(header)); token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func embyTokenFromAuthHeader(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
// 优先提取 Token="..." 引号内的纯 token。RodelPlayer 等客户端会把
|
||||
// UserId 和 Token 一起放进同一个 Emby/MediaBrowser 头里,例如
|
||||
// `Emby UserId="..", Client="..", Token="<jwt>"`。此时必须取 Token 引号内的
|
||||
// 纯 JWT,不能取整个头,否则 JWT 解析会因多余杂质失败。
|
||||
if strings.Contains(value, "Token=") {
|
||||
return embyTokenFromAuthHeaderTokenPart(value)
|
||||
}
|
||||
for _, prefix := range []string{"Bearer ", "Emby "} {
|
||||
if strings.HasPrefix(value, prefix) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(value, prefix))
|
||||
}
|
||||
}
|
||||
// 其它格式(例如只带 Client/Device 信息的 MediaBrowser 头)不是令牌,
|
||||
// 不能整串返回,否则会被当作 JWT 解析导致 "Invalid token"。
|
||||
if strings.HasPrefix(value, "MediaBrowser ") || strings.HasPrefix(value, "Emby ") {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// embyTokenFromAuthHeaderTokenPart 从 "Token=..." 形如的字段中取出引号内的纯 token。
|
||||
func embyTokenFromAuthHeaderTokenPart(value string) string {
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
part = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(part), "MediaBrowser "))
|
||||
if !strings.HasPrefix(part, "Token=") {
|
||||
continue
|
||||
}
|
||||
token := strings.TrimSpace(strings.TrimPrefix(part, "Token="))
|
||||
return strings.Trim(token, `"`)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func embyAppendAPIKey(raw, token string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
token = strings.TrimSpace(token)
|
||||
if raw == "" || token == "" {
|
||||
return raw
|
||||
}
|
||||
if strings.HasPrefix(raw, "//") {
|
||||
return raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.IsAbs() {
|
||||
return raw
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("api_key") == "" && q.Get("apiKey") == "" && q.Get("token") == "" {
|
||||
q.Set("api_key", token)
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// embyVideoStreamHandler 是 GET /Videos/{id}/stream 的入口。
|
||||
// 远程 Emby 条目(embyremote~ 前缀)走反向代理;本地条目直接代理到
|
||||
// /api/stream/{id}(同一个 ServeFile)。
|
||||
func embyVideoStreamHandler(svc *service.Container, cloudMode string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
encodedID := c.Param("id")
|
||||
if accountID, remoteID, ok := service.DecodeEmbyRemoteID(encodedID); ok {
|
||||
if err := svc.Emby.ProxyRemoteVideoStream(c.Request.Context(), c.Writer, c.Request, accountID, remoteID); err != nil {
|
||||
if errors.Is(err, service.ErrEmbyRemoteNotFound) {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
uid := embyUserID(c)
|
||||
item, err := svc.Emby.Item(c.Request.Context(), encodedID, uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if item == nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if embyShouldRedirectVideoStreamToSTRM(c, svc, c.Param("id"), cloudMode) {
|
||||
target := "/api/stream/" + url.PathEscape(strings.TrimSpace(c.Param("id")))
|
||||
if token := embyPlaybackRedirectToken(c, svc); token != "" {
|
||||
target = embyAppendAPIKey(target, token)
|
||||
}
|
||||
setRedirectNoStoreHeaders(c)
|
||||
c.Redirect(http.StatusFound, absoluteRequestURL(c, target))
|
||||
return
|
||||
}
|
||||
// 直接调用 Stream service 写入 response。
|
||||
// 此前这里把所有错误一律吞成 404:云盘 Cookie 过期、直链解析失败、
|
||||
// STRM 播放被关闭……在第三方播放器上全部表现为「404 不存在」,
|
||||
// 无法排查。现在区分:行不存在→404;云盘播放不可用/上游故障→502+原因。
|
||||
err = svc.Stream.ServeFileWithCloudMode(c.Writer, c.Request, c.Param("id"), cloudMode)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, service.ErrMediaNotFound):
|
||||
c.Status(http.StatusNotFound)
|
||||
case errors.Is(err, service.ErrCloudPlaybackDisabled):
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
}
|
||||
default:
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func embyPlaybackRedirectToken(c *gin.Context, svc *service.Container) string {
|
||||
if token := embyRequestToken(c); token != "" {
|
||||
return token
|
||||
}
|
||||
if c == nil || svc == nil || svc.Auth == nil || svc.Repo == nil || svc.Repo.User == nil {
|
||||
return ""
|
||||
}
|
||||
uid := embyUserID(c)
|
||||
if uid == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := svc.Repo.User.FindByID(c.Request.Context(), uid)
|
||||
if err != nil || u == nil {
|
||||
return ""
|
||||
}
|
||||
token, err := svc.Auth.IssueEmbyToken(u)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func embyShouldRedirectVideoStreamToSTRM(c *gin.Context, svc *service.Container, mediaID, cloudMode string) bool {
|
||||
if c == nil || svc == nil || svc.Repo == nil || svc.Repo.Media == nil || cloudMode != service.CloudPlaybackModeRedirectProxy {
|
||||
return false
|
||||
}
|
||||
settings := service.CloudPlaybackSettings(c.Request.Context(), svc.Repo)
|
||||
if settings.PreferredMode != service.CloudPlaybackModeSTRM || !settings.STRMEnabled {
|
||||
return false
|
||||
}
|
||||
m, err := svc.Repo.Media.FindByID(c.Request.Context(), mediaID)
|
||||
if err != nil || m == nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(m.STRMURL) != ""
|
||||
}
|
||||
|
||||
func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 远程 Emby 条目不做本地转码(播放地址已由 PlaybackInfo 指向远程/代理直连)。
|
||||
if service.IsEmbyRemoteID(c.Param("id")) {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
uid := embyUserID(c)
|
||||
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
|
||||
if err != nil || item == nil || svc.Stream == nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id"))
|
||||
if errors.Is(err, service.ErrTranscodeDisabled) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrTranscodeBusy) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "transcode busy"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func embyVideoHLSSegmentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if service.IsEmbyRemoteID(c.Param("id")) {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
uid := embyUserID(c)
|
||||
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
|
||||
if err != nil || item == nil || svc.Stream == nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg")); err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func TestEmbyLowercasePlaybackInfoRouteReturnsJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
lib := model.Library{Name: "电影", Path: t.TempDir(), Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "media-1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Lowercase Playback",
|
||||
Path: filepath.Join(lib.Path, "lowercase-playback.mp4"),
|
||||
Container: "mp4",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/users/user-1/items/media-1/playbackinfo", nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode playback info: %v", err)
|
||||
}
|
||||
if _, ok := body["MediaSources"]; !ok {
|
||||
t.Fatalf("missing MediaSources: %#v", body)
|
||||
}
|
||||
sources, ok := body["MediaSources"].([]any)
|
||||
if !ok || len(sources) == 0 {
|
||||
t.Fatalf("unexpected MediaSources: %#v", body["MediaSources"])
|
||||
}
|
||||
source, ok := sources[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected MediaSource: %#v", sources[0])
|
||||
}
|
||||
directURL, _ := source["DirectStreamUrl"].(string)
|
||||
if !strings.Contains(directURL, "api_key=") {
|
||||
t.Fatalf("DirectStreamUrl should carry api_key for clients that do not repeat auth headers: %#v", source)
|
||||
}
|
||||
transcodeURL, _ := source["TranscodingUrl"].(string)
|
||||
if transcodeURL != "" && !strings.Contains(transcodeURL, "api_key=") {
|
||||
t.Fatalf("TranscodingUrl should carry api_key: %#v", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyPlaybackInfoDoesNotExposeTokenInCloudPath(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.Setting.Set(t.Context(), service.CloudPlaybackModeSettingKey, service.CloudPlaybackModeSTRM); err != nil {
|
||||
t.Fatalf("set cloud playback mode: %v", err)
|
||||
}
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
lib := model.Library{Name: "OpenList", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "cloud-1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Cloud Movie",
|
||||
Path: "cloud://openlist/Movies/Movie.mkv",
|
||||
STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv",
|
||||
Container: "mkv",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/users/user-1/items/cloud-1/playbackinfo", nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode playback info: %v", err)
|
||||
}
|
||||
source := body["MediaSources"].([]any)[0].(map[string]any)
|
||||
pathURL, _ := source["Path"].(string)
|
||||
if pathURL != "/Movies/Movie.mkv" {
|
||||
t.Fatalf("cloud Path should expose the OpenList source path, got %#v", source)
|
||||
}
|
||||
if strings.Contains(pathURL, "api_key=") || strings.Contains(pathURL, "token=") {
|
||||
t.Fatalf("cloud Path must not expose auth key/token: %#v", source)
|
||||
}
|
||||
if strings.Contains(pathURL, "/api/cloud/play/") || strings.Contains(pathURL, "/api/stream/") {
|
||||
t.Fatalf("cloud Path should not expose a playback URL: %#v", source)
|
||||
}
|
||||
directURL, _ := source["DirectStreamUrl"].(string)
|
||||
if !strings.HasPrefix(directURL, "/api/stream/cloud-1") || !strings.Contains(directURL, "api_key=") {
|
||||
t.Fatalf("DirectStreamUrl should stay tokenized: %#v", source)
|
||||
}
|
||||
if source["SupportsDirectPlay"] != true {
|
||||
t.Fatalf("cloud media should advertise DirectPlay when tokenized Path is playable: %#v", source)
|
||||
}
|
||||
if source["SupportsTranscoding"] != false {
|
||||
t.Fatalf("cloud media should not advertise host transcoding: %#v", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyItemsDoNotExposeTokenInEmbeddedCloudPath(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.Setting.Set(t.Context(), service.CloudPlaybackModeSettingKey, service.CloudPlaybackModeSTRM); err != nil {
|
||||
t.Fatalf("set cloud playback mode: %v", err)
|
||||
}
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
lib := model.Library{Name: "OpenList", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "cloud-1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Cloud Movie",
|
||||
Path: "cloud://openlist/Movies/Movie.mkv",
|
||||
STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv",
|
||||
Container: "mkv",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
token := signedTestToken(t, secret)
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Users/user-1/Items?IncludeItemTypes=Movie&Recursive=true&Limit=5&X-Emby-Token="+token, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode items: %v", err)
|
||||
}
|
||||
items := body["Items"].([]any)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("unexpected items: %#v", body["Items"])
|
||||
}
|
||||
source := items[0].(map[string]any)["MediaSources"].([]any)[0].(map[string]any)
|
||||
pathURL, _ := source["Path"].(string)
|
||||
if pathURL != "/Movies/Movie.mkv" {
|
||||
t.Fatalf("embedded cloud Path should expose the OpenList source path, got %#v", source)
|
||||
}
|
||||
if strings.Contains(pathURL, "api_key=") || strings.Contains(pathURL, "token=") {
|
||||
t.Fatalf("embedded cloud Path must not expose auth key/token: %#v", source)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func TestEmbyLowercaseVideoStreamRouteServesMedia(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
mediaPath := filepath.Join(dir, "sample.mp4")
|
||||
if err := os.WriteFile(mediaPath, []byte("fake-video-bytes"), 0o644); err != nil {
|
||||
t.Fatalf("write media: %v", err)
|
||||
}
|
||||
lib := model.Library{Name: "电影", Path: dir, Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "media-1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Lowercase Stream",
|
||||
Path: mediaPath,
|
||||
Container: "mp4",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
Stream: service.NewStreamService(&config.Config{}, zap.NewNop(), repos, nil),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/videos/media-1/stream?api_key="+signedTestToken(t, secret), nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Body.String(); got != "fake-video-bytes" {
|
||||
t.Fatalf("unexpected stream body: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyPrefixedAPIStreamRouteServesMedia(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
mediaPath := filepath.Join(dir, "sample.mp4")
|
||||
if err := os.WriteFile(mediaPath, []byte("fake-video-bytes"), 0o644); err != nil {
|
||||
t.Fatalf("write media: %v", err)
|
||||
}
|
||||
lib := model.Library{Name: "电影", Path: dir, Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "media-1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Prefixed API Stream",
|
||||
Path: mediaPath,
|
||||
Container: "mp4",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
Stream: service.NewStreamService(&config.Config{}, zap.NewNop(), repos, nil),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/api/stream/media-1?api_key="+signedTestToken(t, secret), nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Body.String(); got != "fake-video-bytes" {
|
||||
t.Fatalf("unexpected stream body: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyLowercaseOriginalHeadRouteServesHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
mediaPath := filepath.Join(dir, "sample.mp4")
|
||||
if err := os.WriteFile(mediaPath, []byte("fake-video-bytes"), 0o644); err != nil {
|
||||
t.Fatalf("write media: %v", err)
|
||||
}
|
||||
lib := model.Library{Name: "电影", Path: dir, Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "media-1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Lowercase Original",
|
||||
Path: mediaPath,
|
||||
Container: "mp4",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
Stream: service.NewStreamService(&config.Config{}, zap.NewNop(), repos, nil),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodHead, "/videos/media-1/original.mp4?api_key="+signedTestToken(t, secret), nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if w.Body.Len() != 0 {
|
||||
t.Fatalf("HEAD response should not include body, got %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyLowercaseVideoHLSRouteDoesNot404WhenDirectOnly(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
mediaPath := filepath.Join(dir, "sample.mp4")
|
||||
if err := os.WriteFile(mediaPath, []byte("fake-video-bytes"), 0o644); err != nil {
|
||||
t.Fatalf("write media: %v", err)
|
||||
}
|
||||
lib := model.Library{Name: "电影", Path: dir, Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "media-1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Lowercase HLS",
|
||||
Path: mediaPath,
|
||||
Container: "mp4",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
if err := repos.Setting.Set(t.Context(), service.PlaybackDirectOnlySettingKey, "true"); err != nil {
|
||||
t.Fatalf("set direct-only: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
Stream: service.NewStreamService(&config.Config{}, zap.NewNop(), repos, nil),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/videos/media-1/master.m3u8?api_key="+signedTestToken(t, secret), nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code == http.StatusNotFound {
|
||||
t.Fatalf("lowercase HLS route should be registered, got 404")
|
||||
}
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("direct-only HLS should return 409, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
type embyPlayingReq struct {
|
||||
ItemId string `json:"ItemId"`
|
||||
ItemIDLower string `json:"itemId"`
|
||||
ID string `json:"Id"`
|
||||
IDLower string `json:"id"`
|
||||
PositionTicks int64 `json:"PositionTicks"`
|
||||
PositionLower int64 `json:"positionTicks"`
|
||||
RunTimeTicks int64 `json:"RunTimeTicks"`
|
||||
RunTimeLower int64 `json:"runTimeTicks"`
|
||||
}
|
||||
|
||||
func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := embyUserID(c)
|
||||
if uid == "" {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var req embyPlayingReq
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
itemID := embyFirstNonEmptyString(req.ItemId, req.ItemIDLower, req.ID, req.IDLower)
|
||||
if itemID == "" {
|
||||
itemID = embyFirstNonEmptyString(firstQueryValue(c, "ItemId", "itemId", "Id", "id"))
|
||||
}
|
||||
pos := req.PositionTicks
|
||||
if pos == 0 {
|
||||
pos = req.PositionLower
|
||||
}
|
||||
if pos == 0 {
|
||||
pos, _ = strconv.ParseInt(firstQueryValue(c, "PositionTicks", "positionTicks"), 10, 64)
|
||||
}
|
||||
runTime := req.RunTimeTicks
|
||||
if runTime == 0 {
|
||||
runTime = req.RunTimeLower
|
||||
}
|
||||
if runTime == 0 {
|
||||
runTime, _ = strconv.ParseInt(firstQueryValue(c, "RunTimeTicks", "runTimeTicks"), 10, 64)
|
||||
}
|
||||
if itemID == "" {
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
clientInfo := embyClientInfoFromRequest(c)
|
||||
if svc.Device != nil && svc.Device.IsTerminalKicked(c.Request.Context(), uid, clientInfo.DeviceID, clientInfo.DeviceName, clientInfo.Client) {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if err := svc.Emby.RecordProgress(c.Request.Context(), uid, itemID, pos, runTime); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
stopped := strings.Contains(strings.ToLower(c.FullPath()+" "+c.Request.URL.Path), "stopped")
|
||||
if svc.Sessions != nil {
|
||||
svc.Sessions.RecordPlayback(c.Request.Context(), uid, "",
|
||||
clientInfo.DeviceID,
|
||||
clientInfo.DeviceName,
|
||||
clientInfo.Client,
|
||||
c.ClientIP(),
|
||||
itemID,
|
||||
pos,
|
||||
runTime,
|
||||
stopped)
|
||||
}
|
||||
if svc.Device != nil && !stopped {
|
||||
svc.Device.RecordPlayback(c.Request.Context(), uid,
|
||||
clientInfo.DeviceID,
|
||||
clientInfo.DeviceName,
|
||||
clientInfo.Client)
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
mid := c.Param("itemId")
|
||||
if uid == "" || mid == "" {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := svc.Emby.SetFavorite(c.Request.Context(), uid, mid, fav); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out, _ := svc.Emby.Item(c.Request.Context(), mid, uid)
|
||||
if out != nil {
|
||||
c.JSON(http.StatusOK, out["UserData"])
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"IsFavorite": fav})
|
||||
}
|
||||
}
|
||||
|
||||
func embyMarkPlayedHandler(svc *service.Container, played bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
mid := c.Param("itemId")
|
||||
if uid == "" || mid == "" {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := svc.Emby.MarkPlayed(c.Request.Context(), uid, mid, played); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if played && svc.Device != nil {
|
||||
clientInfo := embyClientInfoFromRequest(c)
|
||||
svc.Device.RecordPlayback(c.Request.Context(), uid, clientInfo.DeviceID, clientInfo.DeviceName, clientInfo.Client)
|
||||
}
|
||||
out, _ := svc.Emby.Item(c.Request.Context(), mid, uid)
|
||||
if out != nil {
|
||||
c.JSON(http.StatusOK, out["UserData"])
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"Played": played})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/middleware"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
// registerEmbyRoutes 在 r 上挂双前缀("" + "/emby")的 Emby 兼容路由。
|
||||
func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container) {
|
||||
for _, prefix := range []string{"/emby", ""} {
|
||||
grp := r.Group(prefix)
|
||||
grp.Use(embyNoStoreHeaders())
|
||||
|
||||
registerEmbyRootRoutes(grp, prefix, svc)
|
||||
registerEmbyPublicRoutes(grp, jwtSecret, svc)
|
||||
registerEmbyPublicImageRoutes(grp, svc)
|
||||
|
||||
// 鉴权后端点
|
||||
auth := grp.Group("", embyAuthRequiredWithSessionFallback(jwtSecret), activeEmbyUserRequired(svc), embyRealtimeSessionActivity(svc))
|
||||
registerEmbyAuthenticatedRoutes(auth, prefix, svc)
|
||||
}
|
||||
}
|
||||
|
||||
type embyRouteHandlerFactory func(*service.Container) gin.HandlerFunc
|
||||
|
||||
func embyNoStoreHeaders() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("Pragma", "no-cache")
|
||||
c.Header("Expires", "0")
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func registerEmbyRootRoutes(grp *gin.RouterGroup, prefix string, svc *service.Container) {
|
||||
if prefix != "/emby" {
|
||||
return
|
||||
}
|
||||
grp.GET("", embyRootHandler(svc))
|
||||
grp.HEAD("", embyRootHandler(svc))
|
||||
grp.GET("/", embyRootHandler(svc))
|
||||
grp.HEAD("/", embyRootHandler(svc))
|
||||
}
|
||||
|
||||
func registerEmbyPublicRoutes(grp *gin.RouterGroup, jwtSecret string, svc *service.Container) {
|
||||
registerEmbyPublicSystemRoutes(grp, svc)
|
||||
registerEmbyPublicSessionRoutes(grp, jwtSecret, svc)
|
||||
registerEmbyPublicClientRoutes(grp, jwtSecret, svc)
|
||||
}
|
||||
|
||||
func registerEmbyPublicSystemRoutes(grp *gin.RouterGroup, svc *service.Container) {
|
||||
registerEmbyGetHeadRoutes(grp, svc, []string{"/System/Info/Public", "/system/info/public"}, embySystemInfoPublicHandler)
|
||||
registerEmbyGetHeadRoutes(grp, svc, []string{"/System/Info", "/system/info"}, embySystemInfoHandler)
|
||||
registerEmbyGetRoutes(grp, svc, []string{"/System/Endpoint", "/system/endpoint"}, embySystemEndpointHandler)
|
||||
registerEmbyGetHeadRoutes(grp, svc, []string{"/System/Ext/ServerDomains", "/system/ext/serverdomains"}, embyServerDomainsHandler)
|
||||
registerEmbyGetHeadRoutes(grp, svc, []string{"/System/Configuration/Public", "/system/configuration/public"}, embyPublicServerConfigurationHandler)
|
||||
registerEmbyGetHeadRoutes(grp, svc, []string{"/Startup/Configuration", "/startup/configuration"}, embyStartupConfigurationHandler)
|
||||
registerEmbyPostRoutes(grp, svc, []string{"/Startup/Complete", "/startup/complete"}, embyNoContentHandler)
|
||||
registerEmbyGetHeadRoutes(grp, svc, []string{"/QuickConnect/Enabled", "/quickconnect/enabled"}, embyQuickConnectEnabledHandler)
|
||||
for _, path := range []string{"/System/Ping", "/system/ping"} {
|
||||
grp.GET(path, embyPingHandler(svc))
|
||||
grp.HEAD(path, embyPingHandler(svc))
|
||||
grp.POST(path, embyPingHandler(svc))
|
||||
}
|
||||
}
|
||||
|
||||
func registerEmbyPublicSessionRoutes(grp *gin.RouterGroup, jwtSecret string, svc *service.Container) {
|
||||
for _, path := range []string{
|
||||
"/Sessions/Capabilities", "/Sessions/Capabilities/Full",
|
||||
"/sessions/capabilities", "/sessions/capabilities/full",
|
||||
} {
|
||||
grp.POST(path, embySessionCapabilitiesHandler(svc, jwtSecret))
|
||||
}
|
||||
|
||||
// 30/min per IP: many Emby clients sit behind a single NAT/reverse-proxy
|
||||
// IP, so a low limit would throttle legitimate logins into 429s.
|
||||
embyLoginLimiter := middleware.NewRateLimiter(30, 1*time.Minute)
|
||||
for _, path := range []string{"/Users/AuthenticateByName", "/Users/authenticatebyname", "/users/AuthenticateByName", "/users/authenticatebyname"} {
|
||||
grp.POST(path, middleware.RateLimit(embyLoginLimiter), embyAuthByNameHandler(svc))
|
||||
}
|
||||
|
||||
registerEmbyGetRoutes(grp, svc, []string{"/Users/Public", "/users/public"}, embyPublicUsersHandler)
|
||||
}
|
||||
|
||||
func registerEmbyPublicClientRoutes(grp *gin.RouterGroup, jwtSecret string, svc *service.Container) {
|
||||
registerEmbyGetRoutes(grp, svc, []string{"/Branding/Configuration", "/branding/configuration"}, embyBrandingConfigHandler)
|
||||
registerEmbyGetHeadRoutes(grp, svc, []string{"/Branding/Css", "/branding/css"}, embyBrandingCSSHandler)
|
||||
registerEmbyGetRoutes(grp, svc, []string{"/Localization/Options", "/localization/options"}, embyLocalizationOptionsHandler)
|
||||
registerEmbyGetRoutes(grp, svc, []string{"/Localization/Cultures", "/Localization/cultures", "/localization/cultures"}, embyLocalizationCulturesHandler)
|
||||
registerEmbyGetHeadRoutes(grp, svc, []string{"/CustomCssJS/Scripts", "/customcssjs/scripts"}, embyCustomCSSJSScriptsHandler)
|
||||
for _, path := range []string{"/embywebsocket", "/EmbyWebSocket"} {
|
||||
grp.GET(path, embyWebSocketHandler(svc, jwtSecret))
|
||||
grp.HEAD(path, embyNoContentHandler(svc))
|
||||
}
|
||||
for _, path := range []string{"/Sessions/Logout", "/sessions/logout"} {
|
||||
grp.POST(path, embySessionLogoutHandler(svc, jwtSecret))
|
||||
}
|
||||
grp.GET("/DisplayPreferences/:id", embyDisplayPreferencesHandler(svc))
|
||||
grp.POST("/DisplayPreferences/:id", embySaveDisplayPreferencesHandler(svc))
|
||||
grp.GET("/displaypreferences/:id", embyDisplayPreferencesHandler(svc))
|
||||
grp.POST("/displaypreferences/:id", embySaveDisplayPreferencesHandler(svc))
|
||||
}
|
||||
|
||||
func registerEmbyPublicImageRoutes(grp *gin.RouterGroup, svc *service.Container) {
|
||||
// 图片公开(Infuse 缓存 URL 时会丢 token)
|
||||
grp.GET("/Items/:id/Images/:type", embyItemImageHandler(svc))
|
||||
grp.GET("/Items/:id/Images/:type/:index", embyItemImageHandler(svc))
|
||||
grp.HEAD("/Items/:id/Images/:type", embyItemImageHandler(svc))
|
||||
grp.GET("/items/:id/images/:type", embyItemImageHandler(svc))
|
||||
grp.GET("/items/:id/images/:type/:index", embyItemImageHandler(svc))
|
||||
grp.HEAD("/items/:id/images/:type", embyItemImageHandler(svc))
|
||||
}
|
||||
|
||||
func registerEmbyGetRoutes(grp *gin.RouterGroup, svc *service.Container, paths []string, factory embyRouteHandlerFactory) {
|
||||
for _, path := range paths {
|
||||
grp.GET(path, factory(svc))
|
||||
}
|
||||
}
|
||||
|
||||
func registerEmbyGetHeadRoutes(grp *gin.RouterGroup, svc *service.Container, paths []string, factory embyRouteHandlerFactory) {
|
||||
for _, path := range paths {
|
||||
grp.GET(path, factory(svc))
|
||||
grp.HEAD(path, factory(svc))
|
||||
}
|
||||
}
|
||||
|
||||
func registerEmbyPostRoutes(grp *gin.RouterGroup, svc *service.Container, paths []string, factory embyRouteHandlerFactory) {
|
||||
for _, path := range paths {
|
||||
grp.POST(path, factory(svc))
|
||||
}
|
||||
}
|
||||
|
||||
func registerEmbyAuthenticatedRoutes(auth *gin.RouterGroup, prefix string, svc *service.Container) {
|
||||
registerEmbyAuthenticatedUserRoutes(auth, svc)
|
||||
registerEmbyAuthenticatedItemRoutes(auth, svc)
|
||||
registerEmbyAuthenticatedPlaybackRoutes(auth, prefix, svc)
|
||||
registerEmbyAuthenticatedProgressRoutes(auth, svc)
|
||||
registerEmbyAuthenticatedUserDataRoutes(auth, svc)
|
||||
registerEmbyAuthenticatedSystemRoutes(auth, svc)
|
||||
registerLowercaseEmbyAuthRoutes(auth, svc)
|
||||
}
|
||||
|
||||
func registerEmbyAuthenticatedUserRoutes(auth *gin.RouterGroup, svc *service.Container) {
|
||||
auth.GET("/Users/Me", embyMeHandler(svc))
|
||||
auth.GET("/Users", embyListUsersHandler(svc))
|
||||
auth.GET("/Users/:userId", embyGetUserByIDHandler(svc))
|
||||
auth.GET("/Users/:userId/Views", embyViewsHandler(svc))
|
||||
auth.GET("/Library/MediaFolders", embyViewsHandler(svc))
|
||||
auth.GET("/Library/VirtualFolders", embyVirtualFoldersHandler(svc))
|
||||
auth.GET("/Library/SelectableMediaFolders", embyVirtualFoldersHandler(svc))
|
||||
}
|
||||
|
||||
func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Container) {
|
||||
auth.GET("/Items", embyItemsHandler(svc))
|
||||
auth.GET("/Users/:userId/Items", embyItemsHandler(svc))
|
||||
auth.GET("/Items/Counts", embyItemsCountsHandler(svc))
|
||||
auth.GET("/Users/:userId/Items/Counts", embyItemsCountsHandler(svc))
|
||||
auth.GET("/Items/Latest", embyLatestItemsHandler(svc))
|
||||
auth.GET("/Items/Resume", embyResumeItemsHandler(svc))
|
||||
auth.GET("/Users/:userId/Items/Resume", embyResumeItemsHandler(svc))
|
||||
auth.GET("/UserItems/Resume", embyResumeItemsHandler(svc))
|
||||
auth.GET("/Items/:id", embyItemByIDHandler(svc))
|
||||
auth.GET("/Users/:userId/Items/:id", embyUserItemByIDHandler(svc))
|
||||
auth.GET("/Shows/:id/Seasons", embyShowSeasonsHandler(svc))
|
||||
auth.GET("/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
|
||||
auth.GET("/Users/:userId/Shows/:id/Seasons", embyShowSeasonsHandler(svc))
|
||||
auth.GET("/Users/:userId/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
|
||||
auth.GET("/Shows/NextUp", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Users/:userId/Shows/NextUp", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/MediaSegments/:id", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Artists", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Persons", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Genres", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Shows/Upcoming", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Users/:userId/Shows/Upcoming", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Items/:id/Similar", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Items/:id/ThumbnailSet", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Items/:id/ThemeMedia", embyThemeMediaHandler(svc))
|
||||
auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Users/:userId/Items/:id/Intros", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Items/:id/Intros", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/api/danmu/:id/raw", embyDanmuRawHandler(svc))
|
||||
}
|
||||
|
||||
func registerEmbyAuthenticatedPlaybackRoutes(auth *gin.RouterGroup, prefix string, svc *service.Container) {
|
||||
auth.GET("/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
|
||||
auth.POST("/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
|
||||
auth.GET("/Users/:userId/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
|
||||
auth.POST("/Users/:userId/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
|
||||
|
||||
registerEmbyVideoStreamRoutes(auth, svc, "/Videos")
|
||||
auth.GET("/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
|
||||
auth.HEAD("/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
|
||||
auth.GET("/Users/:userId/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
|
||||
auth.HEAD("/Users/:userId/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
|
||||
// Official Emby/Swagger shape:
|
||||
// /Videos/{Id}/{MediaSourceId}/Subtitles/{Index}/Stream.{Format}
|
||||
// The mediaSourceId segment is a bare path param. We name it :seg (matching
|
||||
// the HLS /Videos/:id/:seg catch-all) so gin allows the two routes to
|
||||
// coexist — gin permits the same :param name to be both terminal and parent
|
||||
// of children, but rejects two different param names at the same position.
|
||||
// The subtitle handler ignores this segment (lookup is by :id + :index).
|
||||
auth.GET("/Videos/:id/:seg/Subtitles/:index/Stream.:format", embySubtitleStreamHandler(svc))
|
||||
auth.HEAD("/Videos/:id/:seg/Subtitles/:index/Stream.:format", embySubtitleStreamHandler(svc))
|
||||
auth.GET("/Users/:userId/Videos/:id/:seg/Subtitles/:index/Stream.:format", embySubtitleStreamHandler(svc))
|
||||
auth.HEAD("/Users/:userId/Videos/:id/:seg/Subtitles/:index/Stream.:format", embySubtitleStreamHandler(svc))
|
||||
if prefix == "/emby" {
|
||||
auth.GET("/api/stream/:id", embyVideoStreamHandler(svc, service.CloudPlaybackModeSTRM))
|
||||
auth.HEAD("/api/stream/:id", embyVideoStreamHandler(svc, service.CloudPlaybackModeSTRM))
|
||||
}
|
||||
auth.GET("/Videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc))
|
||||
auth.HEAD("/Videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc))
|
||||
auth.GET("/Videos/:id/main.m3u8", embyVideoHLSPlaylistHandler(svc))
|
||||
auth.HEAD("/Videos/:id/main.m3u8", embyVideoHLSPlaylistHandler(svc))
|
||||
auth.GET("/Videos/:id/:seg", embyVideoHLSSegmentHandler(svc))
|
||||
}
|
||||
|
||||
func registerEmbyVideoStreamRoutes(auth *gin.RouterGroup, svc *service.Container, basePath string) {
|
||||
streamHandler := func() gin.HandlerFunc {
|
||||
return embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)
|
||||
}
|
||||
for _, path := range []string{"/:id/stream", "/:id/stream.:container", "/:id/original", "/:id/original.:container"} {
|
||||
fullPath := basePath + path
|
||||
auth.GET(fullPath, streamHandler())
|
||||
auth.HEAD(fullPath, streamHandler())
|
||||
}
|
||||
}
|
||||
|
||||
func registerEmbyAuthenticatedProgressRoutes(auth *gin.RouterGroup, svc *service.Container) {
|
||||
auth.POST("/Sessions/Playing", embyPlayingProgressHandler(svc))
|
||||
auth.POST("/Sessions/Playing/Progress", embyPlayingProgressHandler(svc))
|
||||
auth.POST("/Sessions/Playing/Stopped", embyPlayingProgressHandler(svc))
|
||||
}
|
||||
|
||||
func registerEmbyAuthenticatedUserDataRoutes(auth *gin.RouterGroup, svc *service.Container) {
|
||||
auth.POST("/Users/:userId/FavoriteItems/:itemId", embyFavoriteHandler(svc, true))
|
||||
auth.DELETE("/Users/:userId/FavoriteItems/:itemId", embyFavoriteHandler(svc, false))
|
||||
auth.POST("/Users/:userId/PlayedItems/:itemId", embyMarkPlayedHandler(svc, true))
|
||||
auth.DELETE("/Users/:userId/PlayedItems/:itemId", embyMarkPlayedHandler(svc, false))
|
||||
}
|
||||
|
||||
func registerEmbyAuthenticatedSystemRoutes(auth *gin.RouterGroup, svc *service.Container) {
|
||||
auth.GET("/Sessions", embySessionsHandler(svc))
|
||||
auth.GET("/System/Configuration", embyServerConfigurationHandler(svc))
|
||||
auth.GET("/System/WakeOnLanInfo", embyEmptyArrayHandler(svc))
|
||||
auth.GET("/ScheduledTasks", embyEmptyArrayHandler(svc))
|
||||
auth.GET("/LiveTv/Recordings", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/System/ActivityLog/Entries", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Web/ConfigurationPages", embyEmptyArrayHandler(svc))
|
||||
auth.POST("/Users/:userId/Configuration", embyNoContentHandler(svc))
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func registerLowercaseEmbyAuthRoutes(auth *gin.RouterGroup, svc *service.Container) {
|
||||
registerLowercaseEmbyUserRoutes(auth, svc)
|
||||
registerLowercaseEmbyItemRoutes(auth, svc)
|
||||
registerLowercaseEmbyPlaybackRoutes(auth, svc)
|
||||
registerLowercaseEmbyProgressRoutes(auth, svc)
|
||||
registerLowercaseEmbyUserDataRoutes(auth, svc)
|
||||
registerLowercaseEmbySystemRoutes(auth, svc)
|
||||
}
|
||||
|
||||
func registerLowercaseEmbyUserRoutes(auth *gin.RouterGroup, svc *service.Container) {
|
||||
auth.GET("/users/me", embyMeHandler(svc))
|
||||
auth.GET("/users", embyListUsersHandler(svc))
|
||||
auth.GET("/users/:userId", embyGetUserByIDHandler(svc))
|
||||
auth.GET("/users/:userId/views", embyViewsHandler(svc))
|
||||
auth.GET("/library/mediafolders", embyViewsHandler(svc))
|
||||
auth.GET("/library/virtualfolders", embyVirtualFoldersHandler(svc))
|
||||
auth.GET("/library/selectablemediafolders", embyVirtualFoldersHandler(svc))
|
||||
}
|
||||
|
||||
func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Container) {
|
||||
auth.GET("/items", embyItemsHandler(svc))
|
||||
auth.GET("/users/:userId/items", embyItemsHandler(svc))
|
||||
auth.GET("/items/counts", embyItemsCountsHandler(svc))
|
||||
auth.GET("/users/:userId/items/counts", embyItemsCountsHandler(svc))
|
||||
auth.GET("/items/latest", embyLatestItemsHandler(svc))
|
||||
auth.GET("/items/resume", embyResumeItemsHandler(svc))
|
||||
auth.GET("/users/:userId/items/resume", embyResumeItemsHandler(svc))
|
||||
auth.GET("/useritems/resume", embyResumeItemsHandler(svc))
|
||||
auth.GET("/items/:id", embyItemByIDHandler(svc))
|
||||
auth.GET("/users/:userId/items/:id", embyUserItemByIDHandler(svc))
|
||||
auth.GET("/shows/:id/seasons", embyShowSeasonsHandler(svc))
|
||||
auth.GET("/shows/:id/episodes", embyShowEpisodesHandler(svc))
|
||||
auth.GET("/users/:userId/shows/:id/seasons", embyShowSeasonsHandler(svc))
|
||||
auth.GET("/users/:userId/shows/:id/episodes", embyShowEpisodesHandler(svc))
|
||||
auth.GET("/shows/nextup", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/users/:userId/shows/nextup", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/mediasegments/:id", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/artists", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/persons", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/genres", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/shows/upcoming", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/users/:userId/shows/upcoming", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/items/:id/similar", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/items/:id/thumbnailset", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/items/:id/thememedia", embyThemeMediaHandler(svc))
|
||||
auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/users/:userId/items/:id/intros", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/items/:id/intros", embyEmptyItemsHandler(svc))
|
||||
}
|
||||
|
||||
func registerLowercaseEmbyPlaybackRoutes(auth *gin.RouterGroup, svc *service.Container) {
|
||||
auth.GET("/items/:id/playbackinfo", embyPlaybackInfoHandler(svc))
|
||||
auth.POST("/items/:id/playbackinfo", embyPlaybackInfoHandler(svc))
|
||||
auth.GET("/users/:userId/items/:id/playbackinfo", embyPlaybackInfoHandler(svc))
|
||||
auth.POST("/users/:userId/items/:id/playbackinfo", embyPlaybackInfoHandler(svc))
|
||||
|
||||
registerEmbyVideoStreamRoutes(auth, svc, "/videos")
|
||||
auth.GET("/videos/:id/subtitles/:index/stream", embySubtitleStreamHandler(svc))
|
||||
auth.HEAD("/videos/:id/subtitles/:index/stream", embySubtitleStreamHandler(svc))
|
||||
auth.GET("/users/:userId/videos/:id/subtitles/:index/stream", embySubtitleStreamHandler(svc))
|
||||
auth.HEAD("/users/:userId/videos/:id/subtitles/:index/stream", embySubtitleStreamHandler(svc))
|
||||
// Official Emby/Swagger shape:
|
||||
// /videos/{Id}/{MediaSourceId}/subtitles/{Index}/stream.{Format}
|
||||
// The mediaSourceId segment is a bare path param named :seg (shared with the
|
||||
// HLS /videos/:id/:seg catch-all) so gin allows both routes to coexist.
|
||||
auth.GET("/videos/:id/:seg/subtitles/:index/stream.:format", embySubtitleStreamHandler(svc))
|
||||
auth.HEAD("/videos/:id/:seg/subtitles/:index/stream.:format", embySubtitleStreamHandler(svc))
|
||||
auth.GET("/users/:userId/videos/:id/:seg/subtitles/:index/stream.:format", embySubtitleStreamHandler(svc))
|
||||
auth.HEAD("/users/:userId/videos/:id/:seg/subtitles/:index/stream.:format", embySubtitleStreamHandler(svc))
|
||||
auth.GET("/videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc))
|
||||
auth.HEAD("/videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc))
|
||||
auth.GET("/videos/:id/main.m3u8", embyVideoHLSPlaylistHandler(svc))
|
||||
auth.HEAD("/videos/:id/main.m3u8", embyVideoHLSPlaylistHandler(svc))
|
||||
auth.GET("/videos/:id/:seg", embyVideoHLSSegmentHandler(svc))
|
||||
}
|
||||
|
||||
func registerLowercaseEmbyProgressRoutes(auth *gin.RouterGroup, svc *service.Container) {
|
||||
auth.POST("/sessions/playing", embyPlayingProgressHandler(svc))
|
||||
auth.POST("/sessions/playing/progress", embyPlayingProgressHandler(svc))
|
||||
auth.POST("/sessions/playing/stopped", embyPlayingProgressHandler(svc))
|
||||
}
|
||||
|
||||
func registerLowercaseEmbyUserDataRoutes(auth *gin.RouterGroup, svc *service.Container) {
|
||||
auth.POST("/users/:userId/favoriteitems/:itemId", embyFavoriteHandler(svc, true))
|
||||
auth.DELETE("/users/:userId/favoriteitems/:itemId", embyFavoriteHandler(svc, false))
|
||||
auth.POST("/users/:userId/playeditems/:itemId", embyMarkPlayedHandler(svc, true))
|
||||
auth.DELETE("/users/:userId/playeditems/:itemId", embyMarkPlayedHandler(svc, false))
|
||||
}
|
||||
|
||||
func registerLowercaseEmbySystemRoutes(auth *gin.RouterGroup, svc *service.Container) {
|
||||
auth.GET("/sessions", embySessionsHandler(svc))
|
||||
auth.GET("/system/configuration", embyServerConfigurationHandler(svc))
|
||||
auth.GET("/system/wakeonlaninfo", embyEmptyArrayHandler(svc))
|
||||
auth.GET("/scheduledtasks", embyEmptyArrayHandler(svc))
|
||||
auth.GET("/livetv/recordings", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/system/activitylog/entries", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/web/configurationpages", embyEmptyArrayHandler(svc))
|
||||
auth.POST("/users/:userId/configuration", embyNoContentHandler(svc))
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func TestEmbyMarkPlayedRefreshesPlaybackDevice(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
if err := repos.DB.Create(&model.Media{
|
||||
Base: model.Base{ID: "media-1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Watched Movie",
|
||||
Path: `/media/movies/Watched Movie.mkv`,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
Device: service.NewDeviceService(zap.NewNop(), repos),
|
||||
})
|
||||
|
||||
token := signedTestToken(t, secret)
|
||||
req := httptest.NewRequest(http.MethodPost, "/emby/Users/user-1/PlayedItems/media-1", nil)
|
||||
req.Header.Set("X-MediaBrowser-Authorization", `MediaBrowser Client="Infuse", Device="iPhone", DeviceId="played-device", Token="`+token+`"`)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
devices, err := repos.UserDevice.ListByUser(context.Background(), "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("list devices: %v", err)
|
||||
}
|
||||
if len(devices) != 1 || devices[0].LastPlayAt == nil {
|
||||
t.Fatalf("mark played should refresh playback device, got %#v", devices)
|
||||
}
|
||||
if devices[0].DeviceID != "played-device" || devices[0].DeviceName != "iPhone" || devices[0].Client != "Infuse" {
|
||||
t.Fatalf("playback device info not parsed: %#v", devices[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyCompatSessionAllowsSameClientRequestsWithoutToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("get sql db: %v", err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
log := zap.NewNop()
|
||||
permissions := service.NewPermissionService(log, repos)
|
||||
auth := service.NewAuthService(cfg, log, repos, service.NewTokenService(cfg, log, repos), permissions)
|
||||
user, _, err := auth.Register(context.Background(), "viewer", "secret-pass")
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
if err := repos.Library.Create(t.Context(), &model.Library{Name: "Movies", Path: "D:\\media", Type: "movie", Enabled: true}); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
|
||||
embyCompatSessions.Lock()
|
||||
embyCompatSessions.items = map[string]embyCompatSession{}
|
||||
embyCompatSessions.Unlock()
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
|
||||
Repo: repos,
|
||||
Auth: auth,
|
||||
Emby: service.NewEmbyService(cfg, log, repos),
|
||||
Audit: service.NewAuditService(log, repos),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/emby/Users/authenticatebyname", strings.NewReader(`{"Username":"viewer","Pw":"secret-pass"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Emby Theater")
|
||||
req.Header.Set("X-Emby-Device-Id", "pc-device")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("login status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/emby/Users/"+user.ID+"/Views", nil)
|
||||
req.Header.Set("User-Agent", "Emby Theater")
|
||||
req.Header.Set("X-Emby-Device-Id", "pc-device")
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("views status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode views: %v", err)
|
||||
}
|
||||
if _, ok := payload["Items"]; !ok {
|
||||
t.Fatalf("missing Items: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyAuthenticatedRequestRefreshesRealtimeUserActivity(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
oldLogin := time.Now().Add(-6 * time.Hour)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "viewer",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
LastLoginAt: &oldLogin,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
log := zap.NewNop()
|
||||
tracker := service.NewSessionTrackerService(log)
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, log, repos),
|
||||
Sessions: tracker,
|
||||
}
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, svc)
|
||||
router.GET("/admin/users", listUsersHandler(svc))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Users/Me", nil)
|
||||
req.Header.Set("X-MediaBrowser-Authorization", `MediaBrowser Client="Infuse", Device="iPhone", DeviceId="phone-1", Token="`+signedTestToken(t, secret)+`"`)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("me status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
sessions := tracker.List(t.Context())
|
||||
if len(sessions) != 1 {
|
||||
t.Fatalf("sessions = %#v, want one realtime session", sessions)
|
||||
}
|
||||
if sessions[0].UserID != "user-1" || sessions[0].DeviceID != "phone-1" || sessions[0].Client != "Infuse" {
|
||||
t.Fatalf("session did not capture client info: %#v", sessions[0])
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("admin users status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var users []model.User
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &users); err != nil {
|
||||
t.Fatalf("decode users: %v", err)
|
||||
}
|
||||
if len(users) != 1 {
|
||||
t.Fatalf("users = %#v", users)
|
||||
}
|
||||
if users[0].LastLoginAt == nil || !users[0].LastLoginAt.After(oldLogin) {
|
||||
t.Fatalf("last_login_at = %v, want realtime value after %v", users[0].LastLoginAt, oldLogin)
|
||||
}
|
||||
if !users[0].RealtimeOnline || users[0].RealtimeDeviceCount != 1 {
|
||||
t.Fatalf("realtime flags online=%v devices=%d", users[0].RealtimeOnline, users[0].RealtimeDeviceCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbySessionCapabilitiesRefreshesRealtimeActivity(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "viewer",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
tracker := service.NewSessionTrackerService(zap.NewNop())
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Sessions: tracker,
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/emby/Sessions/Capabilities/Full?deviceId=phone-1&device=iPhone&client=Infuse", strings.NewReader(`{}`))
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("capabilities status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
sessions := tracker.List(t.Context())
|
||||
if len(sessions) != 1 {
|
||||
t.Fatalf("sessions = %#v, want one heartbeat session", sessions)
|
||||
}
|
||||
if sessions[0].DeviceID != "phone-1" || sessions[0].DeviceName != "iPhone" || sessions[0].Client != "Infuse" || sessions[0].UserName != "viewer" {
|
||||
t.Fatalf("capabilities did not refresh client session: %#v", sessions[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbySessionCapabilitiesIgnoresScopedPlaybackToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "viewer",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
tracker := service.NewSessionTrackerService(zap.NewNop())
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Sessions: tracker,
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/emby/Sessions/Capabilities/Full?deviceId=phone-1&device=iPhone&client=Infuse", strings.NewReader(`{}`))
|
||||
req.Header.Set("X-Emby-Token", signedTestTokenWithPurpose(t, secret, service.ExternalPlaybackTokenPurpose))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("capabilities status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if sessions := tracker.List(t.Context()); len(sessions) != 0 {
|
||||
t.Fatalf("scoped external playback token must not create realtime session: %#v", sessions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyLogoutRemovesRealtimeSessionFromPublicRoute(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "viewer",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
tracker := service.NewSessionTrackerService(zap.NewNop())
|
||||
tracker.RecordActivity(t.Context(), "user-1", "viewer", "phone-1", "iPhone", "Infuse", "192.0.2.10")
|
||||
tracker.RecordActivity(t.Context(), "user-1", "viewer", "tv-1", "Apple TV", "Yamby", "192.0.2.11")
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Sessions: tracker,
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/emby/Sessions/Logout", nil)
|
||||
req.Header.Set("X-MediaBrowser-Authorization", `MediaBrowser Client="Infuse", Device="iPhone", DeviceId="phone-1", Token="`+signedTestToken(t, secret)+`"`)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("logout status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
sessions := tracker.List(t.Context())
|
||||
if len(sessions) != 1 {
|
||||
t.Fatalf("sessions after logout = %#v, want only the other device", sessions)
|
||||
}
|
||||
if sessions[0].DeviceID != "tv-1" {
|
||||
t.Fatalf("remaining session = %#v, want tv-1", sessions[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyLogoutIgnoresScopedPlaybackToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "viewer",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
tracker := service.NewSessionTrackerService(zap.NewNop())
|
||||
tracker.RecordActivity(t.Context(), "user-1", "viewer", "phone-1", "iPhone", "Infuse", "192.0.2.10")
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Sessions: tracker,
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/emby/Sessions/Logout", nil)
|
||||
req.Header.Set("X-MediaBrowser-Authorization", `MediaBrowser Client="Infuse", Device="iPhone", DeviceId="phone-1", Token="`+signedTestTokenWithPurpose(t, secret, service.ExternalPlaybackTokenPurpose)+`"`)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("logout status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
sessions := tracker.List(t.Context())
|
||||
if len(sessions) != 1 || sessions[0].DeviceID != "phone-1" {
|
||||
t.Fatalf("scoped external playback token must not logout realtime session: %#v", sessions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyUppercaseSessionCapabilitiesRouteNoContent(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{Repo: repos})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/Sessions/Capabilities/Full", strings.NewReader(`{}`))
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbySessionCapabilitiesRouteAllowsPreAuthProbe(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, "test-secret", &service.Container{})
|
||||
|
||||
for _, path := range []string{"/Sessions/Capabilities", "/Sessions/Capabilities/Full", "/emby/Sessions/Capabilities", "/emby/Sessions/Capabilities/Full"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/middleware"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func embySessionsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc.Sessions == nil {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0)
|
||||
for _, sess := range svc.Sessions.List(c.Request.Context()) {
|
||||
last := sess.LastActivityAt
|
||||
itemID := sess.ItemID
|
||||
playState := gin.H{
|
||||
"PositionTicks": sess.PositionTicks,
|
||||
"IsPaused": sess.IsPaused,
|
||||
"PlayMethod": "DirectStream",
|
||||
"CanSeek": true,
|
||||
}
|
||||
row := gin.H{
|
||||
"Id": sess.ID,
|
||||
"ServerId": "mmtl-001",
|
||||
"Client": sess.Client,
|
||||
"DeviceId": sess.DeviceID,
|
||||
"DeviceName": sess.DeviceName,
|
||||
"UserId": sess.UserID,
|
||||
"UserName": sess.UserName,
|
||||
"LastActivityDate": last,
|
||||
"RemoteEndPoint": sess.RemoteEndPoint,
|
||||
"PlayState": playState,
|
||||
"SupportsRemoteControl": true,
|
||||
}
|
||||
if itemID != "" && sess.IsPlaying {
|
||||
nowPlaying := gin.H{"Id": itemID}
|
||||
if svc.Emby != nil {
|
||||
if item, _ := svc.Emby.Item(c.Request.Context(), itemID, sess.UserID); item != nil {
|
||||
for _, key := range []string{"Name", "Type", "RunTimeTicks", "PrimaryImageItemId", "ImageTags", "SeriesName", "SeasonName", "IndexNumber", "ParentIndexNumber"} {
|
||||
if val, ok := item[key]; ok && val != nil {
|
||||
nowPlaying[key] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
row["NowPlayingItem"] = nowPlaying
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func embySessionLogoutHandler(svc *service.Container, jwtSecret string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc.Sessions != nil {
|
||||
uid, _ := embyPublicSessionIdentity(c, svc, jwtSecret)
|
||||
clientInfo := embyClientInfoFromRequest(c)
|
||||
svc.Sessions.Logout(c.Request.Context(), uid, clientInfo.DeviceID, c.ClientIP())
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func embySessionCapabilitiesHandler(svc *service.Container, jwtSecret string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
recordEmbyPublicSessionActivity(c, svc, jwtSecret)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func recordEmbyPublicSessionActivity(c *gin.Context, svc *service.Container, jwtSecret string) {
|
||||
uid, username := embyPublicSessionIdentity(c, svc, jwtSecret)
|
||||
recordEmbySessionActivity(c, svc, uid, username)
|
||||
}
|
||||
|
||||
func embyPublicSessionIdentity(c *gin.Context, svc *service.Container, jwtSecret string) (string, string) {
|
||||
if uid := embyUserID(c); uid != "" {
|
||||
return uid, embyContextUserName(c)
|
||||
}
|
||||
token := embyRequestToken(c)
|
||||
if strings.TrimSpace(token) == "" || strings.TrimSpace(jwtSecret) == "" {
|
||||
return "", ""
|
||||
}
|
||||
claims := &middleware.Claims{}
|
||||
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrTokenSignatureInvalid
|
||||
}
|
||||
return []byte(jwtSecret), nil
|
||||
})
|
||||
if err != nil || !parsed.Valid {
|
||||
return "", ""
|
||||
}
|
||||
if strings.TrimSpace(claims.Purpose) != "" {
|
||||
return "", ""
|
||||
}
|
||||
uid := strings.TrimSpace(claims.UserID)
|
||||
if uid == "" {
|
||||
return "", ""
|
||||
}
|
||||
if svc != nil && svc.Repo != nil && svc.Repo.User != nil {
|
||||
if user, err := svc.Repo.User.FindByID(c.Request.Context(), uid); err == nil && user != nil {
|
||||
return uid, user.Username
|
||||
}
|
||||
}
|
||||
return uid, ""
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func TestEmbySessionsReturnsRealtimeSession(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tracker := service.NewSessionTrackerService(zap.NewNop())
|
||||
tracker.RecordPlayback(t.Context(), "user-1", "viewer", "dev-1", "Apple TV", "Yamby", "10.0.0.8", "media-1", 1000, 2000, false)
|
||||
svc := &service.Container{Sessions: tracker}
|
||||
router := gin.New()
|
||||
router.GET("/Sessions", embySessionsHandler(svc))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Sessions", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("cache-control = %q, want no-store", got)
|
||||
}
|
||||
var rows []map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("sessions = %d, want 1: %s", len(rows), w.Body.String())
|
||||
}
|
||||
if rows[0]["UserId"] != "user-1" || rows[0]["DeviceId"] != "dev-1" || rows[0]["Client"] != "Yamby" {
|
||||
t.Fatalf("session payload = %#v", rows[0])
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339Nano, rows[0]["LastActivityDate"].(string)); err != nil {
|
||||
t.Fatalf("LastActivityDate should be RFC3339 time, got %#v", rows[0]["LastActivityDate"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func embyNoContentHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func embyWebSocketHandler(svc *service.Container, jwtSecret string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
recordEmbyPublicSessionActivity(c, svc, jwtSecret)
|
||||
if !websocket.IsWebSocketUpgrade(c.Request) {
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
if _, _, err := conn.NextReader(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
recordEmbyPublicSessionActivity(c, svc, jwtSecret)
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func embyServerConfigurationHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"EnableFolderView": true,
|
||||
"EnableGroupingIntoCollections": true,
|
||||
"EnableExternalContentInSuggestions": false,
|
||||
"ImageSavingConvention": "Compatible",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func embyPublicServerConfigurationHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"IsStartupWizardCompleted": true,
|
||||
"EnableRemoteAccess": true,
|
||||
"EnableUPnP": false,
|
||||
"EnableHttps": false,
|
||||
"RequireHttps": false,
|
||||
"LocalNetworkSubnets": []string{},
|
||||
"LocalNetworkAddresses": []string{},
|
||||
"RemoteClientBitrateLimit": 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func embyStartupConfigurationHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"IsStartupWizardCompleted": true,
|
||||
"StartupWizardCompleted": true,
|
||||
"EnableRemoteAccess": true,
|
||||
"UICulture": "zh-CN",
|
||||
"MetadataCountryCode": "CN",
|
||||
"PreferredMetadataLanguage": "zh-CN",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func embyQuickConnectEnabledHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, false)
|
||||
}
|
||||
}
|
||||
|
||||
func embyEmptyItemsHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"Items": []any{}, "TotalRecordCount": 0})
|
||||
}
|
||||
}
|
||||
|
||||
func embyEmptyArrayHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
}
|
||||
}
|
||||
|
||||
func embyCustomCSSJSScriptsHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "application/javascript; charset=utf-8", nil)
|
||||
}
|
||||
}
|
||||
|
||||
func embyLocalizationCulturesHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, []gin.H{
|
||||
{
|
||||
"DisplayName": "简体中文",
|
||||
"Name": "zh-CN",
|
||||
"ThreeLetterISOLanguageName": "zho",
|
||||
"TwoLetterISOLanguageName": "zh",
|
||||
"ThreeLetterISOLanguageNames": []string{"zho", "chi"},
|
||||
"IsRightToLeft": false,
|
||||
},
|
||||
{
|
||||
"DisplayName": "English",
|
||||
"Name": "en-US",
|
||||
"ThreeLetterISOLanguageName": "eng",
|
||||
"TwoLetterISOLanguageName": "en",
|
||||
"ThreeLetterISOLanguageNames": []string{"eng"},
|
||||
"IsRightToLeft": false,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func embyThemeMediaHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
empty := gin.H{"Items": []any{}, "TotalRecordCount": 0}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ThemeVideosResult": empty,
|
||||
"ThemeSongsResult": empty,
|
||||
"SoundtrackSongsResult": empty,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func embyServerDomainsHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
}
|
||||
}
|
||||
|
||||
func embyDanmuRawHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "text/plain; charset=utf-8", nil)
|
||||
}
|
||||
}
|
||||
|
||||
func embyBrandingConfigHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"LoginDisclaimer": "",
|
||||
"CustomCss": "",
|
||||
"SplashscreenEnabled": false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func embyBrandingCSSHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "text/css; charset=utf-8", []byte(""))
|
||||
}
|
||||
}
|
||||
|
||||
func embyLocalizationOptionsHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, []map[string]any{
|
||||
{"Name": "简体中文", "Value": "zh-CN"},
|
||||
{"Name": "English", "Value": "en-US"},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
// TestEmbySubtitleOfficialRouteServesRawASS verifies that the official Emby
|
||||
// subtitle route shape
|
||||
// (/Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/Stream.{format}) is
|
||||
// registered, resolves the media, and streams the RAW ASS bytes — matching the
|
||||
// advertised Codec — so a real Emby client can load the subtitle.
|
||||
func TestEmbySubtitleOfficialRouteServesRawASS(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
videoPath := filepath.Join(dir, "Movie.mkv")
|
||||
subPath := filepath.Join(dir, "Movie.ass")
|
||||
rawASS := "Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,test line\n"
|
||||
if err := os.WriteFile(videoPath, []byte("video"), 0o644); err != nil {
|
||||
t.Fatalf("write video: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(subPath, []byte(rawASS), 0o644); err != nil {
|
||||
t.Fatalf("write subtitle: %v", err)
|
||||
}
|
||||
|
||||
mediaID := "media-1"
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: mediaID},
|
||||
LibraryID: "lib-1",
|
||||
Title: "Movie",
|
||||
Path: videoPath,
|
||||
Container: "mkv",
|
||||
VideoCodec: "h264",
|
||||
AudioCodec: "aac",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos).SetSubtitleService(
|
||||
service.NewSubtitleService(&config.Config{}, zap.NewNop(), repos),
|
||||
),
|
||||
})
|
||||
|
||||
// Official-format route:
|
||||
// /Videos/{Id}/{MediaSourceId}/Subtitles/{Index}/Stream.{Format}
|
||||
// Index 2 = first subtitle when audio present (Video 0, Audio 1, Sub 2).
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Videos/"+mediaID+"/"+mediaID+"/Subtitles/2/Stream.ass", nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "text/plain; charset=utf-8" {
|
||||
t.Fatalf("Content-Type = %q, want text/plain", ct)
|
||||
}
|
||||
if got := w.Body.String(); got != rawASS {
|
||||
t.Fatalf("served body = %q, want raw ASS %q", got, rawASS)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbySubtitleDeliveryUrlGetsToken ensures the subtitle DeliveryUrl carries
|
||||
// the auth token in PlaybackInfo (delivered via embyAttachTokenToMediaSources).
|
||||
func TestEmbySubtitleDeliveryUrlGetsToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
videoPath := filepath.Join(dir, "Movie.mkv")
|
||||
subPath := filepath.Join(dir, "Movie.ass")
|
||||
if err := os.WriteFile(videoPath, []byte("video"), 0o644); err != nil {
|
||||
t.Fatalf("write video: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(subPath, []byte("Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,x\n"), 0o644); err != nil {
|
||||
t.Fatalf("write subtitle: %v", err)
|
||||
}
|
||||
|
||||
mediaID := "media-1"
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: mediaID},
|
||||
LibraryID: "lib-1",
|
||||
Title: "Movie",
|
||||
Path: videoPath,
|
||||
Container: "mkv",
|
||||
VideoCodec: "h264",
|
||||
AudioCodec: "aac",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
token := signedTestToken(t, secret)
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos).SetSubtitleService(
|
||||
service.NewSubtitleService(&config.Config{}, zap.NewNop(), repos),
|
||||
),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Items/"+mediaID+"/PlaybackInfo", nil)
|
||||
req.Header.Set("X-Emby-Token", token)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
source := body["MediaSources"].([]any)[0].(map[string]any)
|
||||
streams := source["MediaStreams"].([]any)
|
||||
var deliveryURL string
|
||||
for _, s := range streams {
|
||||
stream := s.(map[string]any)
|
||||
if stream["Type"] == "Subtitle" {
|
||||
deliveryURL, _ = stream["DeliveryUrl"].(string)
|
||||
break
|
||||
}
|
||||
}
|
||||
if deliveryURL == "" {
|
||||
t.Fatalf("no subtitle DeliveryUrl found: %#v", source)
|
||||
}
|
||||
// Official shape with bare MediaSourceId + format suffix and the token appended.
|
||||
wantPrefix := "/Videos/" + mediaID + "/" + mediaID + "/Subtitles/2/Stream.ass"
|
||||
if deliveryURL[:len(wantPrefix)] != wantPrefix {
|
||||
t.Fatalf("DeliveryUrl %q does not start with %q", deliveryURL, wantPrefix)
|
||||
}
|
||||
if !strings.Contains(deliveryURL, "api_key="+token) {
|
||||
t.Fatalf("DeliveryUrl %q missing auth token", deliveryURL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func embySystemInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, embyWithRequestAddress(c, svc.Emby.SystemInfo()))
|
||||
}
|
||||
}
|
||||
|
||||
func embySystemInfoPublicHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, embyWithRequestAddress(c, svc.Emby.SystemInfoPublic()))
|
||||
}
|
||||
}
|
||||
|
||||
func embyRequestBaseURL(c *gin.Context) string {
|
||||
proto := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto"))
|
||||
if proto == "" {
|
||||
if c.Request != nil && c.Request.TLS != nil {
|
||||
proto = "https"
|
||||
} else {
|
||||
proto = "http"
|
||||
}
|
||||
}
|
||||
if comma := strings.Index(proto, ","); comma >= 0 {
|
||||
proto = strings.TrimSpace(proto[:comma])
|
||||
}
|
||||
|
||||
host := strings.TrimSpace(c.GetHeader("X-Forwarded-Host"))
|
||||
if host == "" && c.Request != nil {
|
||||
host = strings.TrimSpace(c.Request.Host)
|
||||
}
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(proto+"://"+host, "/")
|
||||
}
|
||||
|
||||
func embyWithRequestAddress(c *gin.Context, payload map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(payload)+2)
|
||||
for key, value := range payload {
|
||||
out[key] = value
|
||||
}
|
||||
if address := embyRequestBaseURL(c); address != "" {
|
||||
out["LocalAddress"] = address
|
||||
out["WanAddress"] = address
|
||||
out["PublishedServerUrl"] = address
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func embySystemEndpointHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"IsLocal": true,
|
||||
"IsInNetwork": true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func embyPingHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Emby/Jellyfin 期望 plain text "Emby Server"
|
||||
c.String(http.StatusOK, "Emby Server")
|
||||
}
|
||||
}
|
||||
|
||||
func embyRootHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, embyPublicSystemInfoPayload(c, svc))
|
||||
}
|
||||
}
|
||||
|
||||
func embyPublicSystemInfoPayload(c *gin.Context, svc *service.Container) map[string]any {
|
||||
if svc != nil && svc.Emby != nil {
|
||||
return embyWithRequestAddress(c, svc.Emby.SystemInfoPublic())
|
||||
}
|
||||
return embyWithRequestAddress(c, map[string]any{
|
||||
"Id": "mmtl-001",
|
||||
"ServerId": "mmtl-001",
|
||||
"ServerName": "MMTL",
|
||||
"Version": "4.8.10.0",
|
||||
"ServerVersion": "4.8.10.0",
|
||||
"ProductName": "Emby Server",
|
||||
"OperatingSystem": "Windows",
|
||||
"SupportsHttps": false,
|
||||
"SupportsAutoDiscovery": true,
|
||||
"StartupWizardCompleted": true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/middleware"
|
||||
)
|
||||
|
||||
func signedTestToken(t *testing.T, secret string) string {
|
||||
t.Helper()
|
||||
return signedTestTokenWithPurpose(t, secret, "")
|
||||
}
|
||||
|
||||
func signedTestTokenWithPurpose(t *testing.T, secret, purpose string) string {
|
||||
t.Helper()
|
||||
claims := middleware.Claims{
|
||||
UserID: "user-1",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
Purpose: purpose,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
Issuer: "mmtl-test",
|
||||
Subject: "user-1",
|
||||
},
|
||||
}
|
||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
t.Fatalf("sign token: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
// ─── Users / Auth ────────────────────────────────────────────────────────────
|
||||
|
||||
// embyAuthByNameHandler 处理 POST /Users/AuthenticateByName。
|
||||
//
|
||||
// 这是 Emby 客户端登录的唯一入口(Infuse / Yamby / Hills 等都走这里)。
|
||||
// 用户名+密码 → 调用我们已有的 AuthService.Login → 返回 AccessToken + User。
|
||||
func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
req, err := parseEmbyAuthByNameReq(c)
|
||||
if err != nil {
|
||||
embyError(c, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
password := req.Pw
|
||||
if password == "" {
|
||||
password = req.Password
|
||||
}
|
||||
if strings.TrimSpace(req.Username) == "" || password == "" {
|
||||
if req.PasswordMd5 != "" || req.PasswordSha1 != "" {
|
||||
embyError(c, http.StatusBadRequest, "plain password required")
|
||||
return
|
||||
}
|
||||
embyError(c, http.StatusBadRequest, "missing username or password")
|
||||
return
|
||||
}
|
||||
resp, err := svc.Auth.Login(c.Request.Context(), req.Username, password)
|
||||
if err != nil {
|
||||
embyError(c, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
// 记录登录设备会话并执行防共享检测(登录客户端数 / 设备指纹)。
|
||||
clientInfo := embyClientInfoFromRequest(c)
|
||||
if svc.Sessions != nil {
|
||||
svc.Sessions.RecordLogin(c.Request.Context(), resp.User.ID, resp.User.Username,
|
||||
clientInfo.DeviceID,
|
||||
clientInfo.DeviceName,
|
||||
clientInfo.Client,
|
||||
c.ClientIP())
|
||||
}
|
||||
if svc.Device != nil {
|
||||
svc.Device.RecordLogin(c.Request.Context(), resp.User.ID,
|
||||
clientInfo.DeviceID,
|
||||
clientInfo.DeviceName,
|
||||
clientInfo.Client,
|
||||
c.ClientIP())
|
||||
}
|
||||
userPayload, _ := svc.Emby.FindUser(c.Request.Context(), resp.User.ID)
|
||||
// Emby/Jellyfin 客户端没有 refresh token 机制:它们把这里返回的
|
||||
// AccessToken 长期保存并反复使用。若返回 60 分钟的普通 access
|
||||
// token,客户端每小时就会掉登录、无法播放、媒体库无法刷新。因此
|
||||
// 签发长期令牌(IssueEmbyToken)匹配 Emby 持久化令牌语义。
|
||||
accessToken := resp.Tokens.AccessToken
|
||||
if longLived, err := svc.Auth.IssueEmbyToken(resp.User); err == nil && longLived != "" {
|
||||
accessToken = longLived
|
||||
}
|
||||
embyRememberCompatSession(c, accessToken)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"AccessToken": accessToken,
|
||||
"ServerId": "mmtl-001",
|
||||
"User": userPayload,
|
||||
"SessionInfo": gin.H{
|
||||
"Id": resp.User.ID,
|
||||
"UserId": resp.User.ID,
|
||||
"UserName": resp.User.Username,
|
||||
"Client": clientInfo.Client,
|
||||
"DeviceId": clientInfo.DeviceID,
|
||||
"DeviceName": clientInfo.DeviceName,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func embyPublicUsersHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 公开用户列表(Emby Web 客户端登录页拉这个,列出可见用户)。
|
||||
users, err := svc.Emby.ListUsers(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
// 公开版本只暴露 Id + Name,不包含 Policy。
|
||||
out := make([]map[string]any, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, map[string]any{
|
||||
"Id": u["Id"],
|
||||
"Name": u["Name"],
|
||||
"ServerId": u["ServerId"],
|
||||
"HasPassword": true,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func embyListUsersHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
users, err := svc.Emby.ListUsers(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, users)
|
||||
}
|
||||
}
|
||||
|
||||
func embyMeHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := embyUserID(c)
|
||||
if uid == "" {
|
||||
embyError(c, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
u, err := svc.Emby.FindUser(c.Request.Context(), uid)
|
||||
if err != nil || u == nil {
|
||||
embyError(c, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, u)
|
||||
}
|
||||
}
|
||||
|
||||
func embyGetUserByIDHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
u, err := svc.Emby.FindUser(c.Request.Context(), c.Param("userId"))
|
||||
if err == nil && u != nil {
|
||||
c.JSON(http.StatusOK, u)
|
||||
return
|
||||
}
|
||||
if authUID := embyUserID(c); authUID != "" && authUID != c.Param("userId") {
|
||||
u, err = svc.Emby.FindUser(c.Request.Context(), authUID)
|
||||
if err == nil && u != nil {
|
||||
c.JSON(http.StatusOK, u)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, embyFallbackUser(c.Param("userId")))
|
||||
}
|
||||
}
|
||||
|
||||
func embyFallbackUser(id string) gin.H {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
id = "mmtl-user"
|
||||
}
|
||||
return gin.H{
|
||||
"Id": id,
|
||||
"Name": "MMTL",
|
||||
"ServerId": "mmtl-001",
|
||||
"HasPassword": true,
|
||||
"HasConfiguredPassword": true,
|
||||
"HasConfiguredEasyPassword": false,
|
||||
"EnableAutoLogin": false,
|
||||
"Policy": gin.H{
|
||||
"IsAdministrator": true,
|
||||
"EnableContentDeletion": true,
|
||||
"EnableRemoteControlOfOtherUsers": true,
|
||||
"EnableSharedDeviceControl": true,
|
||||
"EnableRemoteAccess": true,
|
||||
"EnableAllDevices": true,
|
||||
"EnableAllChannels": true,
|
||||
"EnableAllFolders": true,
|
||||
},
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user