Compare commits
731 commits
fix/srt-rt
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 43656a5e88 | |||
| 68461af990 | |||
| 8bc460025d | |||
| 3578c7b4e9 | |||
| cddcc9a29e | |||
| 0e844c0fc3 | |||
| 551af09dc7 | |||
| 4d6a999665 | |||
| f971d57bb9 | |||
| 7ab70948a0 | |||
| 13bbd4216e | |||
| fcd8e8dd2e | |||
| 67ac007706 | |||
| b4f2fb12ff | |||
| aa7f836493 | |||
| c2409bd037 | |||
| 42064acefa | |||
| 2e2b091653 | |||
|
|
c502d4a16f | ||
|
|
9d098e9778 | ||
|
|
02631f7b96 | ||
|
|
9436434599 | ||
|
|
f837e57969 | ||
|
|
ca71e47035 | ||
|
|
34352e3299 | ||
|
|
d505a488ac | ||
|
|
793011b78b | ||
|
|
5538683d78 | ||
|
|
d62af34e98 | ||
|
|
209f9fda52 | ||
|
|
29187a90df | ||
|
|
512267159a | ||
|
|
72fc608d8a | ||
|
|
3fe7d6bba2 | ||
|
|
2615143c6d | ||
|
|
0c3a4b625f | ||
|
|
fff0828d79 | ||
|
|
ec026195eb | ||
| 9d6bbf8112 | |||
| b449ef0ce3 | |||
| 39ef551489 | |||
| 8f26f1bd9a | |||
| a7ef0397e1 | |||
| cf1fe136d0 | |||
| 0818f15498 | |||
| 4473427515 | |||
| 9b47250388 | |||
| 8ea750f5df | |||
| a28dc43ed5 | |||
| 35fd9c0253 | |||
| 0ee0cb91ef | |||
| 9210b41589 | |||
| f2542bc929 | |||
| 0f6c715a30 | |||
| fdec2e307d | |||
| 92b460f503 | |||
| 500599a955 | |||
| 634f1842bd | |||
| 453103aee6 | |||
| 6f64b55824 | |||
| 303f12e0f9 | |||
| 342b56af35 | |||
| f54c49d2dc | |||
| 888ca65045 | |||
| b6f5b9b407 | |||
| 354731a363 | |||
|
|
1fcb927d26 | ||
|
|
6bc6478270 | ||
|
|
446a563647 | ||
|
|
71d8944a01 | ||
|
|
686b90294b | ||
|
|
fcf4c8bbe7 | ||
|
|
94b6710e2d | ||
|
|
6412b5c252 | ||
|
|
56d7479a35 | ||
|
|
aeecb6e32a | ||
|
|
0abef056e7 | ||
|
|
540d333758 | ||
|
|
e4e69973e5 | ||
|
|
c3b087020d | ||
|
|
c2a6c1557b | ||
|
|
b04882a310 | ||
| 60e5093c6b | |||
| 382f432693 | |||
|
|
e533566ae2 | ||
| c3e4306d9f | |||
| eeb0d9f65f | |||
| 7f0ca5922f | |||
| 469521d524 | |||
| 07840441b9 | |||
| 5774f61ac7 | |||
| 1fb790a569 | |||
| 11cb93aa51 | |||
| dbc67636b2 | |||
| 460b590d46 | |||
| f3a640a7c5 | |||
| baa289f6c3 | |||
| e5f218655e | |||
| 8119b57b45 | |||
| 9765fd91f7 | |||
| a25e4b6071 | |||
| 046d99f57a | |||
| fcc737e05b | |||
| 8f93302f45 | |||
| 17ca9bfc75 | |||
| f8fa0fa010 | |||
| 907058de83 | |||
| bfe0316067 | |||
| 5d94838830 | |||
| 76fff5efc2 | |||
| 5432c2dfa1 | |||
| b3b2655272 | |||
| 16366267c4 | |||
| 066718c968 | |||
| 60d0b09c63 | |||
| 2608d7a465 | |||
| cd18988d6d | |||
| be57eb0a50 | |||
| 25356ca439 | |||
|
|
4bea3c94f8 | ||
|
|
f1a3d6a24a | ||
|
|
91e4691230 | ||
|
|
8b48f03f6b | ||
|
|
9085835074 | ||
|
|
f5959620c8 | ||
|
|
e3afe38697 | ||
|
|
e7eff0ee8c | ||
|
|
e8ceb991a3 | ||
|
|
ac7730195d | ||
|
|
c24c6156dc | ||
|
|
7e3e6b2a28 | ||
| 5571768706 | |||
| 350c23f9d1 | |||
|
|
8028c4c4dd | ||
| e6da1432e5 | |||
| e22cf625bf | |||
| 552506ec7a | |||
|
|
e5c9c770d0 | ||
| a0a6bc9f20 | |||
|
|
c8e98ffa0d | ||
| 9dc572b913 | |||
|
|
14ece1a160 | ||
|
|
03d0d098f5 | ||
|
|
8ede44ae87 | ||
|
|
2aec4636cb | ||
|
|
cfe21e315e | ||
|
|
7e240d86c8 | ||
|
|
96effaaa3c | ||
|
|
d209a192c3 | ||
|
|
56b661ef65 | ||
|
|
b7f5a84d2d | ||
|
|
0bbaf80d2a | ||
|
|
d75a0241eb | ||
|
|
bcfc19e530 | ||
|
|
f8b6f7d5ef | ||
|
|
c9f9698b58 | ||
|
|
49a9543942 | ||
|
|
cb7cc9a43e | ||
|
|
9de4fe9ab9 | ||
|
|
88c3aa5149 | ||
|
|
a094df03ea | ||
|
|
1a723fe4c2 | ||
|
|
0248a68f57 | ||
|
|
3bca290e09 | ||
|
|
3fc8116dd3 | ||
|
|
14931d6362 | ||
|
|
1d3c0385dd | ||
|
|
5011d45391 | ||
|
|
99fae69960 | ||
| 1e51a4ca5d | |||
|
|
c2fd48b0ce | ||
|
|
183e10f8e6 | ||
| ad9e1ef5f1 | |||
| ada8105948 | |||
| c84519b606 | |||
| 33239a780e | |||
| 7a6113fc90 | |||
| de311321f4 | |||
| c48c7e6d7d | |||
| 48d54a32cf | |||
| 4172b0d70a | |||
|
|
9726dbb2df | ||
|
|
002e5acb82 | ||
| a48e1d9dd7 | |||
|
|
d1f9557dd1 | ||
| 34bf1c7b7f | |||
|
|
e71c330bdd | ||
| 5de1e3dc3d | |||
| e5e0656a6a | |||
|
|
65684aa577 | ||
|
|
cfcbec0c85 | ||
|
|
a86c1c72f9 | ||
|
|
04ce096e67 | ||
| 64d739b40d | |||
|
|
1535bbaefa | ||
|
|
a44d8bd7c9 | ||
| d257a19d9d | |||
| f0f615688e | |||
| a6f045b3d7 | |||
| 558c18e417 | |||
| 5ff507b81b | |||
| 726343db96 | |||
| 55ff2e717f | |||
| e4d4c00f52 | |||
| 03aa7a0673 | |||
| 37247fdfea | |||
| a03dd36f11 | |||
| a03c85f08a | |||
| 564cf6b18f | |||
| 89645f160e | |||
| e9eeb84c5f | |||
| 4f98f2b773 | |||
| b3c61134fc | |||
| 5edb4df35a | |||
| 07f8ffa6d5 | |||
| 8e0e94de3d | |||
| 602370be26 | |||
| 3ebe5d6639 | |||
| 6ee284e3f6 | |||
| bacdb9f49c | |||
| 6eb98d866b | |||
| cb0efdfdae | |||
| a6c9529c50 | |||
| e289554e44 | |||
| bec64e668d | |||
| a0b7b42524 | |||
| 09e2987c14 | |||
| ee0c2a12de | |||
| 782ff5b7b6 | |||
| a20b0d3fe3 | |||
| f420584e1a | |||
| 0d85899627 | |||
| be9ae32a3b | |||
| 7fc502513e | |||
| 2e1ac72585 | |||
| fba671ad40 | |||
| 33c82cab1a | |||
| 75c23448b4 | |||
| 548c2ab8a4 | |||
| 15b4d45375 | |||
| 4c8c3b72bb | |||
| 7ea3a235da | |||
| 0481fb3ecf | |||
| 37c406bf4d | |||
| b345f5f6a4 | |||
|
|
87f14b7c71 | ||
| c501d88c63 | |||
| 78539ec8b0 | |||
| de895dd7f8 | |||
| 3dad82d992 | |||
| 4673efac6a | |||
| 721f847b28 | |||
| c36c732f47 | |||
| 60e306d1db | |||
| ce31a45124 | |||
| 7189df7957 | |||
| f21157f3c7 | |||
| a5ab57d144 | |||
| 0ebc7ef777 | |||
| d94ed00312 | |||
| af905cf936 | |||
| c312991bac | |||
| 77130ac769 | |||
| a016175fc8 | |||
| 543248b8c2 | |||
| eadafffb18 | |||
| a6d789279c | |||
| 91325a4267 | |||
| 2b85fb49df | |||
| eb6c723713 | |||
| 6322b61a04 | |||
| ff2865b5d8 | |||
| 53049d1c4d | |||
| bec4bfaf31 | |||
| 0537378d82 | |||
| 3ffffd5b32 | |||
| d1fcfcc8fd | |||
| 97f08b32de | |||
| 9a6ae3b786 | |||
| 8aece9cbc4 | |||
| 5699cff4d0 | |||
| 5882c68217 | |||
| 0ff2625876 | |||
| c0d1251c1f | |||
| 9266a1d471 | |||
| f874009329 | |||
| 9ad88e4df4 | |||
| 7a2710dc9a | |||
| 674dccca4e | |||
| f525506718 | |||
| 908cf8a62d | |||
| 0551512fef | |||
| e8299fb9f6 | |||
| 6a1d271576 | |||
| 7e64675aa5 | |||
| 2515258dd4 | |||
| ccbebe172d | |||
| 74fc8323f0 | |||
| 740ab31f8c | |||
| 72fc9cb755 | |||
| 7a6296585c | |||
| 1afb150237 | |||
| 508e978fe5 | |||
| d07fb13401 | |||
| a8a2061eec | |||
| 14d689aaf3 | |||
| eed4180b70 | |||
| 854775e322 | |||
| 004bdd0778 | |||
| 6fe5f7d450 | |||
|
|
13906cd0fe | ||
|
|
7170a9945c | ||
|
|
7700548dee | ||
|
|
90a9e4361a | ||
|
|
7da171cf1f | ||
|
|
24820e921e | ||
|
|
47ad01d0b2 | ||
| f474a77bcb | |||
|
|
f186cdeacd | ||
| 630dc75787 | |||
| 899876c6cf | |||
| 61d02d522b | |||
| 45c0e0f914 | |||
|
|
992fbdfa20 | ||
|
|
9877ed351f | ||
|
|
b128c9f5a9 | ||
|
|
ef4c301149 | ||
|
|
53196d38ce | ||
|
|
6398879b56 | ||
| dc0bd51648 | |||
| c3991a1e75 | |||
| 328f7b4f31 | |||
| 3fc8fbc230 | |||
| ceceedf201 | |||
| 4864db03f3 | |||
| 8b57a9a35a | |||
| fa787bbe1e | |||
| 0aa0922fd3 | |||
| 1abf22623d | |||
| 4afd0c7b21 | |||
| 6f2de45819 | |||
| e3c3d60103 | |||
| 81324c8e52 | |||
| bec58ab138 | |||
| 451bed834f | |||
| d00e1c666e | |||
| ddb4cf0c51 | |||
| fea0f2962b | |||
| 506ee2d695 | |||
| 88689a4eb2 | |||
| dc269bec00 | |||
| 665ab5238d | |||
| bb508d3256 | |||
| 994fd799d0 | |||
| 6510871448 | |||
| 26399f8d0a | |||
| 529d14cb6b | |||
| fb44bd8aff | |||
| 24a1d57165 | |||
| 48ee66e744 | |||
| 0342aa0a5a | |||
| 406f28c663 | |||
| 835545e061 | |||
| 1392e28a88 | |||
| bc03ee866b | |||
| 69f0d130ee | |||
| 07af51b05c | |||
| 3574ae8a43 | |||
| 7dda7cc89c | |||
| 98025001e8 | |||
| 068e3a0828 | |||
| 6ad277275b | |||
| f58fe95f0d | |||
| 6e763e8270 | |||
| 6ac3050a05 | |||
| e13d111b9f | |||
| 1eaf9dff5c | |||
| 20dfa504e5 | |||
| 575e350831 | |||
| 7899066107 | |||
| 9289bd0c74 | |||
| 0945f488f6 | |||
| bd9dfd2cce | |||
| b8e1796c33 | |||
| f8bd80e38e | |||
| 90d2c1cf82 | |||
| 55e82bdeb7 | |||
| 7007d2df93 | |||
| ed3084e60f | |||
| 3392a8944d | |||
| c801eb4781 | |||
| 4a77c1bed8 | |||
| 100fc054cc | |||
| 001533fdf0 | |||
| c0345e47c9 | |||
| dfdf0845a0 | |||
| f24912ea9e | |||
| a9e0313fe4 | |||
| d54d960b8f | |||
| 2706903353 | |||
| b6dcecb672 | |||
| 14bfcabcaf | |||
| 3b1610a167 | |||
| d5fd705d66 | |||
| a700124f50 | |||
| 10952df591 | |||
| afd3fd3374 | |||
| 352d21496f | |||
| 016adff949 | |||
| fcd5a9aa09 | |||
| 304d3713b7 | |||
| 6befb0f46a | |||
| e655ccdf64 | |||
| 2c88fb0a03 | |||
| 7b13d8bd0f | |||
| 21db567396 | |||
| 68df3797f1 | |||
| dccca554e0 | |||
| 1b63429def | |||
| 87da3c0b58 | |||
| 06551c66a6 | |||
| 136820c8f9 | |||
| 7c88692c1c | |||
| 1e0015322c | |||
| 6176791174 | |||
| 9ff80f8cc1 | |||
| 738d6298d2 | |||
| a84bc3ecfe | |||
| daa203a43e | |||
| 33d2a4004d | |||
| 6e43ab30c2 | |||
| cc45cc6347 | |||
| c31933a53c | |||
| efe005378a | |||
| 5874c93956 | |||
| cd5fc3a05c | |||
| e0a2d0c95c | |||
| 4572c88f58 | |||
| c752227e20 | |||
| d4e5af459e | |||
| 29360e38e8 | |||
| e5f4c00729 | |||
| c6aeedb5fc | |||
| 32cf6bf63e | |||
| 024833cc95 | |||
| b4642b3c78 | |||
| b82cc73cf1 | |||
| 873920d27f | |||
| 37767f9939 | |||
| 00f3f2905f | |||
| 30b4deffc6 | |||
| 96f0f58e68 | |||
| a8656fc1a8 | |||
| 1074104d34 | |||
| 486e3c27e4 | |||
| bbed2a7059 | |||
| 8186b181cc | |||
| 539429c058 | |||
| 01a9d6c3db | |||
|
|
ddd3b3eca1 | ||
| a8f5bce9ee | |||
| 683f0ff101 | |||
| 47c0e1f933 | |||
| 6cad11f687 | |||
| eea1ed6bcb | |||
| c6bcbbd214 | |||
| e7495dfe29 | |||
| 5650b279c3 | |||
| 596fe228ed | |||
| e0cfe80a9e | |||
| 16a34a2fad | |||
| 75b94a5025 | |||
| 265f4174d5 | |||
| 447b2b2b81 | |||
| 3b89cf2d5f | |||
| f9236101b9 | |||
| 6561cecf33 | |||
| a4bb6e7b0c | |||
| 1f995c9029 | |||
| 891a8f82b7 | |||
| 23ae848f5b | |||
| a16c235f71 | |||
| e56704b69f | |||
| 1c0ed05ac9 | |||
| a6c9f88068 | |||
| 310eca0810 | |||
| a76e6b9a81 | |||
| 836a163cc8 | |||
| 052a880b0f | |||
| 2f3e04cfc3 | |||
| 080f82e198 | |||
| c08025eeb2 | |||
| 30cb6663dd | |||
| e256a771d5 | |||
| 3df6a4434e | |||
| 9d99811272 | |||
| c97759dc4e | |||
| b77a370eb7 | |||
| b36e859c06 | |||
| fd955076dd | |||
| 89ceef444e | |||
| 00bf112b5a | |||
| 16a1fe604f | |||
| f6c0594088 | |||
| 8403355ba9 | |||
| 4a3a672cbe | |||
| 8aa378348e | |||
| 97628bb67d | |||
| 46676bf80d | |||
| 0ebb3cffe4 | |||
| d39f86d9c5 | |||
| f4a83eedc4 | |||
| 4c65753358 | |||
| 0efef0d81b | |||
| 485af25d4a | |||
| 3b4af6ef11 | |||
| 40a66bae03 | |||
| 049beb8818 | |||
| a39c9831c5 | |||
| 066b9b17d3 | |||
| 629022ab5f | |||
| cc8ee63639 | |||
| 21d31f1678 | |||
| 4d101bc812 | |||
| 1e4e2e436f | |||
| 74299629e6 | |||
| a4b9b5be82 | |||
| a926da1c30 | |||
| 11e1de1cf8 | |||
| 7032cee6b3 | |||
| 02cfa68b92 | |||
| 737e69d72f | |||
| ab504841c3 | |||
| b1457f0aad | |||
| beb58d3cd6 | |||
| 2f48d0243b | |||
| cfdd0d1a55 | |||
| 0318d15c76 | |||
| 0433fc8805 | |||
| 777fa7fc2b | |||
| 53392608e5 | |||
| b7c7bb1662 | |||
| 81d51577b8 | |||
| dd1c40c9c8 | |||
| 7c37eababd | |||
| 6d371beda9 | |||
| 53805f2c59 | |||
| 74e87359e2 | |||
| 5e2683aba7 | |||
| fe921d0444 | |||
| 12a52c40c9 | |||
| 7486539b32 | |||
| a55c182be9 | |||
| 76281b7564 | |||
| 1725ec1de9 | |||
| dd3c2894f6 | |||
| a941f609f0 | |||
| 86d2960b60 | |||
| 28a97e2ba3 | |||
| 5161644205 | |||
| 96ef569720 | |||
| 115c7340ee | |||
| 4dd377e28d | |||
| 3128ab43b3 | |||
| 0b49f28a80 | |||
| 0b255e063f | |||
| c5a358888b | |||
| 0bc1ac9161 | |||
| feb78b8bcb | |||
| 86b80e043e | |||
| 398ee8b932 | |||
| 44277bced6 | |||
| ea04b8f9e1 | |||
| ede55a8a5f | |||
| 9ba3bf6f83 | |||
| 16888d62e2 | |||
| 5bb22c17c8 | |||
| a855ea7885 | |||
| f7aedb1936 | |||
| 879c547e08 | |||
| 0c761d553c | |||
| e3cdf70883 | |||
| 1e9710ce0c | |||
| 090452969c | |||
| 66844b93d3 | |||
| bd8b492ff6 | |||
| 910a906600 | |||
| 89771a2380 | |||
| a5823effe9 | |||
| 36e668455f | |||
| 4d0e715982 | |||
| bfc2649909 | |||
| 81c771a7be | |||
| 16b8530d43 | |||
| 8a2ef38326 | |||
| d382c6b559 | |||
| d21c61a8b2 | |||
| b175eaf54c | |||
| 90bb0769e5 | |||
| 07ded22f8e | |||
| 5019563c38 | |||
| fd6693ee17 | |||
| 18c4779f58 | |||
| aec55fea83 | |||
| 76e6568ac6 | |||
| 43a17ecd14 | |||
| de4cb1b6a0 | |||
| 4407e8ce6d | |||
| 36f165807a | |||
| 76b0a5e05e | |||
| 9c83698b81 | |||
| f39d086bc8 | |||
| 1e4fcb62f5 | |||
| 08e8377309 | |||
| 280fc9dff2 | |||
| f1e0453b0a | |||
| 9f7cb91cc2 | |||
| f8e42b886d | |||
| d18fa2f761 | |||
| 130906ef42 | |||
| d3e12deb18 | |||
| 2bb731c7fc | |||
| 1e8cde81be | |||
| 58e2e539f8 | |||
| 4f8964e807 | |||
| 0ea8d7ce33 | |||
| 3c689ccddf | |||
| b23700f30a | |||
| 0f37d01b2d | |||
| fb3b998cfd | |||
| ff892a1ad5 | |||
| 08e5ba6298 | |||
| e472075087 | |||
| e6314be92d | |||
| 660afb94bb | |||
| 508cf8d41b | |||
| 79d44826fe | |||
| 7260b188c5 | |||
| e895a2f2df | |||
| a9ca7be1d5 | |||
| 29b5910fff | |||
| ffad0051f9 | |||
| 717fdcd611 | |||
| 817eaff8b1 | |||
| 48b69879cb | |||
| 596f755a6c | |||
| 656c820638 | |||
| 910bbf8d3f | |||
| e8e26dd4d8 | |||
| 1f31d1037d | |||
| 8ab71239e3 | |||
| 78a887a3e0 | |||
| 2fabc73299 | |||
| 10152b5ad7 | |||
| ad6e836345 | |||
| 7d8ccc95e9 | |||
| 7b8d8d4818 | |||
| 1816c7fa1e | |||
| 05d49b7199 | |||
| eb248c690f | |||
| c662df94c4 | |||
| b12d8c619a | |||
| 0964645910 | |||
| 9032853629 | |||
| 67251a0dcd | |||
| 0b94153518 | |||
| 3203832aa9 | |||
| 88c0781767 | |||
| 81b832dc70 | |||
| 936867c0c3 | |||
| de3920dd4a | |||
| 9dfefc5731 | |||
| f3fbb027f6 | |||
| e3128acb15 | |||
| d8766f18cc | |||
| a40232e2b5 | |||
| ebe8b3be59 | |||
| cb63e4743d | |||
| 4f649b41a9 | |||
| 725c3ed292 | |||
| 9ceb5db1e3 | |||
| 533250b1c3 | |||
| ffffe8039e | |||
| 027e73467f | |||
| 95fa1b83b6 | |||
| 4213c8a7b3 | |||
| c7d8be9f28 | |||
| 2d21d4d44d | |||
| d0f9848717 | |||
| 28a4b24911 | |||
| 1e4c92c2df | |||
| d23ca9be73 | |||
| 5ed604136c | |||
| f93feb6e40 | |||
| f57ed1b498 | |||
| 2d8c44c529 | |||
| 6bd97a2a03 | |||
| 1f4750a1b4 | |||
| c781a469f3 | |||
| 32bce2e263 | |||
| 3ae150ad53 | |||
| 2e1bcd655f | |||
| beb8f31674 | |||
| a3596265eb | |||
| 0e48a8d70f | |||
| 5b557418f8 | |||
| 81257b5201 | |||
| 623e38ae27 | |||
| 1c7329ef35 | |||
| efebf38271 | |||
| b9879d76b7 | |||
| 230944fc4b | |||
| 57116dde42 | |||
| 57c3871cc1 | |||
| a9c16d9509 | |||
| d8229e6f3f | |||
| f181eb6d34 | |||
| 7d76f9c549 | |||
| 6a8e4ac250 | |||
| e390f0efab | |||
| b68f0c6aba | |||
| 562881f0db | |||
| e441176961 | |||
| bab24e156a | |||
| f2b8d5dc4b | |||
| 349bc5a41d | |||
| f99f07e0e7 | |||
| 72545126c4 | |||
| ea28c5189d | |||
| 3ea896c368 | |||
| ac1878452f |
283 changed files with 58181 additions and 6565 deletions
50
.env.example
50
.env.example
|
|
@ -22,5 +22,51 @@ SESSION_SECRET=changeme
|
||||||
# MAM API Configuration
|
# MAM API Configuration
|
||||||
MAM_API_URL=http://mam-api:3000
|
MAM_API_URL=http://mam-api:3000
|
||||||
|
|
||||||
# Auth (set to 'true' to require login; false for open/dev mode)
|
# Auth — default to ON in production. Setting to 'false' is a dev-only escape
|
||||||
AUTH_ENABLED=false
|
# hatch that disables all auth checks and attaches a synthetic 'dev' user to
|
||||||
|
# every request. Never run with AUTH_ENABLED=false on a network you don't control.
|
||||||
|
#
|
||||||
|
# RBAC v2 note: with AUTH_ENABLED=true, per-project access is enforced. Service
|
||||||
|
# API tokens (capture sidecar, Premiere panel, integrations) must belong to a
|
||||||
|
# user with the access they need — an 'admin' user (full access), or a user with
|
||||||
|
# the right project grants. A non-admin service token with no grants will get
|
||||||
|
# 403 on asset registration (ingest) and streaming. In dev mode the synthetic
|
||||||
|
# user is admin, so this only matters once auth is on.
|
||||||
|
AUTH_ENABLED=true
|
||||||
|
|
||||||
|
# CORS allowlist — comma-separated origins that may carry credentials to the API.
|
||||||
|
# Same-origin requests via the nginx reverse proxy do not need to be listed here.
|
||||||
|
# Leave empty to allow any origin (DEV ONLY).
|
||||||
|
ALLOWED_ORIGINS=
|
||||||
|
|
||||||
|
# Reverse-proxy trust — set 'true' when the API sits behind nginx terminating HTTPS,
|
||||||
|
# so secure-cookie + X-Forwarded-Proto behave correctly. ALSO required for accurate
|
||||||
|
# per-IP login rate-limiting (otherwise req.ip is always the nginx IP).
|
||||||
|
TRUST_PROXY=false
|
||||||
|
|
||||||
|
# Google OAuth (OIDC) sign-in — OPTIONAL. Leave the client id/secret blank to
|
||||||
|
# disable; the "Sign in with Google" button and the /auth/google routes only
|
||||||
|
# activate when all three of CLIENT_ID, CLIENT_SECRET, and REDIRECT_URL are set.
|
||||||
|
# Create an OAuth 2.0 Client (type: Web application) in Google Cloud Console and
|
||||||
|
# add OAUTH_REDIRECT_URL to its authorized redirect URIs.
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
# Must exactly match a redirect URI on the OAuth client, e.g.
|
||||||
|
# https://dragonflight.live/api/v1/auth/google/callback
|
||||||
|
OAUTH_REDIRECT_URL=
|
||||||
|
# Restrict sign-in to one Google Workspace domain (recommended). First login from
|
||||||
|
# an allowed-domain account auto-provisions a NEW 'viewer' account (matched only
|
||||||
|
# by Google's stable subject id, never by email — so a Google login can never
|
||||||
|
# seize a pre-existing local account). An admin then grants project access.
|
||||||
|
# Leave blank to allow any verified Google account to self-provision (NOT advised).
|
||||||
|
GOOGLE_ALLOWED_DOMAIN=
|
||||||
|
# Note: if a Google-linked account also has TOTP enabled, sign-in still requires
|
||||||
|
# the authenticator code (Google is treated as the first factor). Accounts without
|
||||||
|
# TOTP complete sign-in in one Google step.
|
||||||
|
|
||||||
|
# Playout / Master Control (MCR)
|
||||||
|
# Image tag the mam-api spawns when a channel starts. Build with:
|
||||||
|
# docker compose --profile build-only build playout
|
||||||
|
PLAYOUT_IMAGE=wild-dragon-playout:latest
|
||||||
|
# Base AMCP port — each channel binds to BASE + channel_id (in CasparCG terms).
|
||||||
|
PLAYOUT_AMCP_BASE_PORT=5250
|
||||||
|
|
|
||||||
16
.gitignore
vendored
16
.gitignore
vendored
|
|
@ -14,8 +14,24 @@ yarn-error.log*
|
||||||
# Build output
|
# Build output
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
# ...but the Premiere panel's packaging pipeline lives at build/ — keep it tracked.
|
||||||
|
!services/premiere-plugin/build/
|
||||||
|
!services/premiere-plugin/build/**
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env.swp
|
.env.swp
|
||||||
.env.swo
|
.env.swo
|
||||||
|
services/editor/node_modules
|
||||||
|
services/editor/**/node_modules
|
||||||
|
services/editor/**/dist
|
||||||
|
services/editor/.pnpm-store
|
||||||
|
|
||||||
|
# Blackmagic DeckLink SDK + runtime libs (operator-supplied; see services/capture/build-with-decklink.sh)
|
||||||
|
services/capture/sdk/
|
||||||
|
services/capture/lib/
|
||||||
|
|
||||||
|
# Editor backups
|
||||||
|
*.bak
|
||||||
|
*.bak2
|
||||||
|
.env.bak.*
|
||||||
|
|
|
||||||
153
DESIGN.md
Normal file
153
DESIGN.md
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
# Design system
|
||||||
|
|
||||||
|
Documented from the live tokens in `services/web-ui/public/styles.css` and the patterns used across `screens-*.jsx`. Treat this as the source of truth for shared primitives; pages may override locally but should not invent new color or type scales.
|
||||||
|
|
||||||
|
## Color
|
||||||
|
|
||||||
|
Dark theme only. Tokens live in `:root` in `styles.css`. Tinted neutrals — no `#000`, no `#fff`.
|
||||||
|
|
||||||
|
### Surfaces
|
||||||
|
|
||||||
|
| Token | Hex | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--bg-0` | `#0B0D11` | Page background, deepest surface |
|
||||||
|
| `--bg-1` | `#14171E` | Panels, sidebars, primary chrome |
|
||||||
|
| `--bg-2` | `#1B1F27` | Panel headers, hover state |
|
||||||
|
| `--bg-3` | `#232833` | Inputs, raised buttons, badges |
|
||||||
|
| `--bg-4` | `#2D3340` | Strongly raised elements (rare) |
|
||||||
|
|
||||||
|
### Borders + overlays
|
||||||
|
|
||||||
|
| Token | Use |
|
||||||
|
|---|---|
|
||||||
|
| `--border` (rgba white 6%) | Default 1px separator |
|
||||||
|
| `--border-strong` (rgba white 10%) | Emphasized separator |
|
||||||
|
| `--border-stronger` (rgba white 14%) | Hover/active border |
|
||||||
|
| `--hover` (rgba white 4%) | Subtle hover fill |
|
||||||
|
| `--hover-strong` (rgba white 7%) | Stronger hover fill |
|
||||||
|
|
||||||
|
### Text
|
||||||
|
|
||||||
|
| Token | Hex | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--text-1` | `#F2F3F6` | Primary content |
|
||||||
|
| `--text-2` | `#A8AEBC` | Secondary content |
|
||||||
|
| `--text-3` | `#6B7280` | Labels, metadata |
|
||||||
|
| `--text-4` | `#4B5260` | Section labels, off-month, disabled |
|
||||||
|
|
||||||
|
### Accent
|
||||||
|
|
||||||
|
`--accent: #5B7CFA` (Frame.io-ish blue). Soft variants `--accent-soft` (14%) and `--accent-soft-2` (22%) for fills. `--accent-text: #B4C3FF` for high-contrast accent text.
|
||||||
|
|
||||||
|
**Restrained strategy.** Accent is the only saturated color in chrome. Status colors below appear only where they reflect actual state. Project colors appear only on project chips.
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
| Token | Hex | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--success` | `#2DD4A8` | Done, healthy, ready |
|
||||||
|
| `--warning` | `#F5A623` | Processing, attention-needed |
|
||||||
|
| `--danger` | `#FF5B5B` | Failed, error |
|
||||||
|
| `--live` | `#FF3B30` | Currently recording (broadcast red, intentionally hotter than `--danger`) |
|
||||||
|
| `--purple` | `#B57CFA` | Editor / dev tags |
|
||||||
|
|
||||||
|
Each has a `*-soft` variant at ~14% for background fills.
|
||||||
|
|
||||||
|
### Project palette
|
||||||
|
|
||||||
|
Six fixed colors cycled by index in `data.jsx` (`PROJECT_COLORS`):
|
||||||
|
|
||||||
|
`#5B7CFA · #2DD4A8 · #FF5B5B · #F5A623 · #B57CFA · #6B7280`
|
||||||
|
|
||||||
|
Used only on the project chip / rail dot. Do not reuse for status meaning.
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
- **Sans:** Geist (with `cv11`, `ss01` features enabled). All UI text by default.
|
||||||
|
- **Mono:** Geist Mono. URLs, IDs, timestamps, durations, technical metadata.
|
||||||
|
|
||||||
|
Body scale runs small for density:
|
||||||
|
|
||||||
|
| Size | Use |
|
||||||
|
|---|---|
|
||||||
|
| 10.5px, 600, uppercase, 0.06–0.08em letterspacing | Column heads, section labels |
|
||||||
|
| 11–11.5px | Metadata, secondary rows |
|
||||||
|
| 12–12.5px | Body in lists/tables |
|
||||||
|
| 13px, 500–600 | Row labels, button text |
|
||||||
|
| 14px | Default body |
|
||||||
|
| 15px, 600 | Modal titles, panel heads |
|
||||||
|
| 22–28px, 600+ | Page H1 (`.page-header h1`) |
|
||||||
|
|
||||||
|
Never use `gradient text` (impeccable absolute ban). Emphasis via weight and size only.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- Sidebar: 232px fixed (`--sidebar-w`).
|
||||||
|
- Topbar: 56px (`--topbar-h`).
|
||||||
|
- Row height: 44px default, 36px compact (`--row-h`, `data-density="compact"`).
|
||||||
|
- Gap unit: 16px default, 12px compact (`--gap`).
|
||||||
|
- Border radius scale: 4 / 6 / 8 / 12 / 16 px (`--r-xs` → `--r-xl`).
|
||||||
|
- Panels (`.panel`): `--bg-1` + 1px `--border` + `--r-lg`. Use for grouped lists, not for every section.
|
||||||
|
- Do NOT nest panels.
|
||||||
|
|
||||||
|
### Page header
|
||||||
|
|
||||||
|
Standard screens use `.page > .page-header > h1`. Three screens are documented exceptions because they need full-bleed layouts and their own top-chrome:
|
||||||
|
|
||||||
|
- **Home** uses `.launcher` (lobby pattern: hero logo + tile grid + status pip).
|
||||||
|
- **Library** uses `.library-layout` (dual-pane rail + main). The h1 sits inside `.library-toolbar` as `.toolbar-title`.
|
||||||
|
- **Editor** uses `.editor-shell` (NLE with timeline + monitors). The beta banner doubles as its top chrome.
|
||||||
|
|
||||||
|
All other screens should render `<div className="page"><div className="page-header"><h1>…</h1>…</div>…</div>` for consistent IA and screen-reader hierarchy.
|
||||||
|
|
||||||
|
## Shadow
|
||||||
|
|
||||||
|
Two tokens, used sparingly:
|
||||||
|
|
||||||
|
- `--shadow-card`: subtle inset highlight + soft outer. Default for raised inputs.
|
||||||
|
- `--shadow-pop`: modal / popover / context menu.
|
||||||
|
|
||||||
|
No drop shadows on flat surfaces. No glow effects (except: the EPG now-line uses a tight `box-shadow` for the broadcast-red glow, and live status dots have a pulse halo; these are state cues, not decoration).
|
||||||
|
|
||||||
|
## Motion
|
||||||
|
|
||||||
|
- Default transition: 80–120ms on background/border (`transition: background 80ms, border 80ms`).
|
||||||
|
- Heavier reveals: 200ms.
|
||||||
|
- Easing: prefer ease-out (no bounce, no elastic).
|
||||||
|
- Don't animate layout (width/height/top); animate transforms and opacity.
|
||||||
|
|
||||||
|
## Patterns
|
||||||
|
|
||||||
|
### Status badges (`.badge`)
|
||||||
|
|
||||||
|
Variants: `live` (red, animated dot), `success`, `danger`, `warning`, `accent`, `neutral`, `outline`. Tiny — 9–10px font, ~2px vertical padding. Reserved for state, not labels.
|
||||||
|
|
||||||
|
### Row tables (`.user-row`, `.token-row`, `.job-row`, `.schedule-row`, etc.)
|
||||||
|
|
||||||
|
CSS-grid with explicit columns. Header row uses `.head` (uppercase 10.5px). No card stacking — these are dense data lists.
|
||||||
|
|
||||||
|
### Schedule EPG (`.epg-*`)
|
||||||
|
|
||||||
|
Broadcast timeline pattern. Recorder rows × time-of-day axis. Single scrolling container with sticky left gutter (220px) and sticky top ruler (32px). Hour rhythm via `repeating-linear-gradient`. Now-line is a 1px hot-red vertical bar with `--live` glow, animated by re-rendering the line component every second (transform-only positioning, no layout thrash). Event blocks are absolute-positioned within each row, colored via `--epg-block-color` set per recorder's project color. Live events get a red gradient + pulse; failures get a glyph + full red border; past events fade to 0.55 opacity.
|
||||||
|
|
||||||
|
### Inputs
|
||||||
|
|
||||||
|
`.field-input` — `--bg-3` fill, 1px `--border`, `--r-sm`, 12.5px font. Focus: `--border-strong`.
|
||||||
|
|
||||||
|
### Status dot (`.signal-dot`, `.rec-dot`, etc.)
|
||||||
|
|
||||||
|
Small (~6–8px) circle, used inline with text. Recording dots pulse with a keyframe animation.
|
||||||
|
|
||||||
|
## Impeccable absolute bans (apply project-wide)
|
||||||
|
|
||||||
|
- No `border-left` / `border-right` greater than 1px as a colored accent (rewrite with full borders, leading icons, or background tint).
|
||||||
|
- No `background-clip: text` gradient text.
|
||||||
|
- No glassmorphism (blur + translucent) decoratively.
|
||||||
|
- No hero-metric template (big number, small label, gradient accent, supporting stats).
|
||||||
|
- No identical card grids.
|
||||||
|
- Modal as last resort — exhaust inline alternatives first.
|
||||||
|
- No em dashes in code or copy. Use commas, colons, parentheses, periods.
|
||||||
|
|
||||||
|
## To extend
|
||||||
|
|
||||||
|
When a new design need arises, prefer adding a variant to an existing primitive over inventing a new token. New tokens land in `styles.css`. New components land in the relevant `screens-*.jsx` only if reused; otherwise keep them local.
|
||||||
61
PRODUCT.md
Normal file
61
PRODUCT.md
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# Product
|
||||||
|
|
||||||
|
## What it is
|
||||||
|
|
||||||
|
Dragonflight (codebase name; UI brand: "Dragonflight · Wild Dragon Broadcast") is an on-prem broadcast media asset manager and live ingest controller. Operators capture from SRT, RTMP, and SDI sources, schedule windowed recordings against named recorders, transcode/proxy in the background, browse and clip in a library, import from YouTube, and hand off to Premiere or an in-house editor.
|
||||||
|
|
||||||
|
The deployable surface is a React (Babel-in-browser) single-page app served by nginx, talking to a Node/Express API backed by Postgres + Redis + BullMQ, with capture and worker containers handling the actual media.
|
||||||
|
|
||||||
|
Stack lives at `services/web-ui` (UI), `services/mam-api` (API), `services/capture`, `services/worker`, `services/editor`.
|
||||||
|
|
||||||
|
## Register
|
||||||
|
|
||||||
|
**Product.** This is operator UI for a working broadcast tool, not a brand site or marketing surface. Design serves the operator's job, not the brand's identity.
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
Primary: broadcast operators and engineers running live productions. They schedule and supervise back-to-back recordings across multiple recorders in a single shift. They care about: what's recording right now, what's about to start, what failed, and which recorder is bound to what source.
|
||||||
|
|
||||||
|
Secondary: editors and producers who consume the resulting library, comment on assets, request proxy regeneration. They mostly live in the Library and Asset detail screens, not the scheduler.
|
||||||
|
|
||||||
|
Tertiary: admins managing recorders, users, cluster nodes, and storage. Live in the Admin screens.
|
||||||
|
|
||||||
|
## Product purpose
|
||||||
|
|
||||||
|
Replace a stack of one-trick tools (NewBlue scheduler, vMix capture, ad-hoc Premiere ingest, manual S3 syncs) with a single operator surface that supervises recorders, owns the asset catalog through proxy generation, and stays out of the editor's way once footage lands.
|
||||||
|
|
||||||
|
## Tone
|
||||||
|
|
||||||
|
Function-first. Dense. Operations-room. Mono fonts where data lives. Small type. Restrained chrome. The operator should be able to glance at any screen and read the state of the system in under a second; they should never wonder "is this still happening" or "did that finish."
|
||||||
|
|
||||||
|
Not: marketing-warm, conversational, gamified, congratulatory.
|
||||||
|
|
||||||
|
## Strategic principles
|
||||||
|
|
||||||
|
1. **Glance-readable status.** Every list, every cell, every badge must answer "what is the state of this thing right now" without a hover.
|
||||||
|
2. **Trust the operator.** No confirmation modals for reversible actions. No nag, no toasts for routine success. Errors stay visible until acknowledged.
|
||||||
|
3. **Time is the spine.** This product is about time-based events (recordings, schedules, jobs). UIs should privilege time as a primary axis, not bury it under categorical filters.
|
||||||
|
4. **Density over whitespace.** Operators run multi-monitor setups and want maximum signal per pixel. Generous whitespace is a brand-site reflex; reject it here.
|
||||||
|
5. **No half-states.** Pending UIs disable controls; live UIs show live data; failed UIs show the failure inline, not in a separate notification feed.
|
||||||
|
|
||||||
|
## Anti-references
|
||||||
|
|
||||||
|
Steer away from:
|
||||||
|
|
||||||
|
- **Linear-pastel SaaS aesthetics.** Purples, mint accents, friendly empty states with cartoon line illustrations.
|
||||||
|
- **Google-Calendar generic.** A neutral month grid with rounded event chips, no operational signal, optimized for "is Friday free" rather than "is recorder A in conflict at 14:00."
|
||||||
|
- **Gantt-chart project-management feel.** Implies long-horizon planning of tasks with dependencies; this product is hour-scale broadcast operations.
|
||||||
|
- **Cards-for-everything.** Identical card grids of icon+label+value. Particularly the SaaS hero-metric template.
|
||||||
|
- **Decorative blur / glassmorphism / gradient text.** Read as decorative AI slop in a broadcast-ops context.
|
||||||
|
- **NewBlue / Wirecast skinning.** Heavy bevels, gradient buttons, drop shadows. Read as outdated broadcast software.
|
||||||
|
|
||||||
|
## Decision context (Schedule v2)
|
||||||
|
|
||||||
|
The Schedule screen was rebuilt as an EPG (electronic program guide) timeline. Operator confirmed:
|
||||||
|
|
||||||
|
- Density: **heavy / back-to-back, many recorders all day** — month grid was the wrong primary.
|
||||||
|
- At-a-glance signals: now/next, recorder bookings (conflicts), project context, failure history.
|
||||||
|
- Aesthetic: **studio / cinematic — dark, type-led, accent moments.** DaVinci-Resolve-panel territory.
|
||||||
|
- Scope: full rethink — replace the primary view.
|
||||||
|
|
||||||
|
Implementation: recorder rows × time-of-day horizontal axis, sticky gutter + ruler, vertical hot-red now-line, event blocks colored by project, status pills in a top strip. Today / Week / List views.
|
||||||
264
README.md
264
README.md
|
|
@ -1,60 +1,258 @@
|
||||||
# Wild Dragon
|
# Dragonflight
|
||||||
|
|
||||||
Self-hosted Media Asset Management platform built to replace Grass Valley AMPP FramelightX.
|
Self-hosted broadcast media-asset management system that replaces legacy tools like Grass Valley AMPP and FramelightX. Handles live ingest, growing-file editing, scheduling, transcoding, and asset management in a single operator-focused interface.
|
||||||
|
|
||||||
## Services
|
> Repo renamed from `wild-dragon` → `dragonflight` (2026-05-23). The old URL still redirects.
|
||||||
|
|
||||||
| Service | Port | Description |
|
## Home Dashboard
|
||||||
|---------|------|-------------|
|
|
||||||
| **web-ui** | 8080 | Browser-based MAM interface + capture controls |
|
<img src="docs/screenshots/01-home.png" alt="Home Dashboard" width="800" />
|
||||||
| **mam-api** | 3000 | REST API — assets, projects, bins, jobs |
|
|
||||||
| **capture** | 3001 | SDI capture daemon (Blackmagic DeckLink + FFmpeg) |
|
The home screen provides quick access to all major features and displays system status at a glance:
|
||||||
| **worker** | — | Async job processor (proxy gen, thumbnails, conform) |
|
- **Library** — Browse projects, bins, and assets with hover-scrub previews
|
||||||
| **db** | 5432 | PostgreSQL 16 metadata store |
|
- **Recorders** — View configured capture devices and their status
|
||||||
| **queue** | 6379 | Redis 7 job queue (BullMQ) |
|
- **Editor** — Timeline editor with cross-clip preview and render queue
|
||||||
|
- **Jobs** — Proxy and thumbnail queue with retry controls
|
||||||
|
- **Settings** — Configure storage, encoder, growing files, and capture SDK
|
||||||
|
- **Dashboard** — Operations view showing recent activity, job queue, and cluster health
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Features
|
||||||
|
|
||||||
|
### 1. Live Ingest & Capture
|
||||||
|
**Multi-protocol source capture with per-recorder codec settings**
|
||||||
|
|
||||||
|
Dragonflight ingests from multiple sources simultaneously:
|
||||||
|
- **SRT** (Secure Reliable Transport) — caller and listener modes
|
||||||
|
- **RTMP** — standard streaming protocol
|
||||||
|
- **SDI** — via Blackmagic DeckLink cards with FFmpeg SDK 16.x patches
|
||||||
|
|
||||||
|
Each recorder can be configured with independent codec settings:
|
||||||
|
- ProRes (hi-res masters)
|
||||||
|
- H.264 / H.265 (proxies)
|
||||||
|
- DNxHR (Avid compatibility)
|
||||||
|
|
||||||
|
Audio routing and per-source configuration ensure flexibility for multi-camera productions.
|
||||||
|
|
||||||
|
### 2. Growing-File Editing
|
||||||
|
**Live editing in Premiere Pro while capture is still writing**
|
||||||
|
|
||||||
|
Editors mount the SMB landing zone directly in Premiere Pro and edit the live master file as it's being written. The included CEP (Custom Extension Panel) provides:
|
||||||
|
- Real-time clip detection and frame-accurate trimming
|
||||||
|
- One-click relink to final S3 master after promotion
|
||||||
|
- No waiting for capture to finish before editorial begins
|
||||||
|
|
||||||
|
### 3. Recorder Scheduler
|
||||||
|
**Time-windowed recording automation**
|
||||||
|
|
||||||
|
Schedule recordings with:
|
||||||
|
- One-shot, daily, or weekly recurrence
|
||||||
|
- Automatic start/stop via 15-second tick loop
|
||||||
|
- Conflict detection across recorders
|
||||||
|
- Project and bin assignment at schedule time
|
||||||
|
|
||||||
|
### 4. Library & Asset Management
|
||||||
|
**Browse, search, and organize captured footage**
|
||||||
|
|
||||||
|
The Library screen provides:
|
||||||
|
- Project and bin hierarchy
|
||||||
|
- Asset detail view with frame-anchored persistent comments
|
||||||
|
- Right-click context menu (move-to-bin, rename, delete)
|
||||||
|
- Global cmd/ctrl-K search across assets, projects, recorders, jobs, and users
|
||||||
|
- Hover-scrub preview with HLS playback
|
||||||
|
|
||||||
|
### 5. Jobs Queue
|
||||||
|
**BullMQ-backed proxy and thumbnail generation**
|
||||||
|
|
||||||
|
Automated background processing:
|
||||||
|
- Per-job retry logic with exponential backoff
|
||||||
|
- Bulk "retry all failed" for batch recovery
|
||||||
|
- Inline error messages with actionable diagnostics
|
||||||
|
- Status tracking: ingesting → processing → ready
|
||||||
|
|
||||||
|
Proxy encoder options:
|
||||||
|
- CPU-based: libx264 (H.264)
|
||||||
|
- GPU-accelerated: NVENC (NVIDIA) or VAAPI (AMD/Intel)
|
||||||
|
|
||||||
|
### 6. Timeline Conform & Export
|
||||||
|
**FCP XML export with server-side FFmpeg rendering**
|
||||||
|
|
||||||
|
The Premiere Pro panel exports FCP XML with:
|
||||||
|
- Server-side conform via FFmpeg
|
||||||
|
- Multiple output formats: H.264, H.265, ProRes
|
||||||
|
- Resolution presets: Broadcast, Web, Archive
|
||||||
|
- Batch processing with job queue integration
|
||||||
|
|
||||||
|
### 7. Hi-Res Auto-Relink
|
||||||
|
**One-click batch relink of proxy clips to frame-accurate server-trimmed masters**
|
||||||
|
|
||||||
|
After editing on proxies:
|
||||||
|
- Select clips in Premiere
|
||||||
|
- Trigger relink from the CEP panel
|
||||||
|
- Server trims hi-res segments to exact in/out points
|
||||||
|
- Concurrent trim worker pool for speed
|
||||||
|
- 24-hour TTL with automatic cleanup
|
||||||
|
|
||||||
|
### 8. Settings & Configuration
|
||||||
|
**Centralized control for storage, encoding, and capture**
|
||||||
|
|
||||||
|
Configure:
|
||||||
|
- **S3 Storage** — endpoint, bucket, credentials (with env-var fallback)
|
||||||
|
- **Proxy Encoder** — CPU vs GPU, bitrate, resolution
|
||||||
|
- **Growing Files** — SMB path, retention, auto-promotion
|
||||||
|
- **Capture SDK** — Blackmagic, AJA, or Deltacast uploader selection
|
||||||
|
|
||||||
|
### 9. Cluster & Distributed Capture
|
||||||
|
**Primary + worker topology with remote DeckLink nodes**
|
||||||
|
|
||||||
|
- Primary node runs API, scheduler, and web UI
|
||||||
|
- Worker nodes handle proxy/thumbnail jobs
|
||||||
|
- Remote capture nodes run DeckLink cards off-host
|
||||||
|
- Heartbeat health monitoring
|
||||||
|
- Automatic failover and recovery
|
||||||
|
|
||||||
|
### 10. Admin & User Management
|
||||||
|
**Role-based access, token auth, and cluster monitoring**
|
||||||
|
|
||||||
|
- User creation and role assignment
|
||||||
|
- API token generation for integrations
|
||||||
|
- Container and cluster node status
|
||||||
|
- System health dashboard
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone
|
# Clone (repo renamed; old URL still redirects)
|
||||||
git clone https://forge.wilddragon.net/zgaetano/wild-dragon.git
|
git clone https://forge.wilddragon.net/zgaetano/dragonflight.git
|
||||||
cd wild-dragon
|
cd dragonflight
|
||||||
|
|
||||||
# Configure
|
# Configure
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env with your S3 credentials and secrets
|
# Edit .env — S3 credentials + SESSION_SECRET at minimum
|
||||||
|
|
||||||
# Launch
|
# Launch
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# Open
|
# Open
|
||||||
open http://localhost:8080
|
open http://localhost:47434
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
SDI Input (DeckLink) → capture service → dual FFmpeg streams
|
SDI / SRT / RTMP ──► capture (FFmpeg)
|
||||||
├─ HiRes (ProRes) → S3
|
├─ HLS preview tee ──► /live/<assetId>/index.m3u8
|
||||||
└─ Proxy (H.264) → S3
|
└─ master output
|
||||||
↓
|
├─ growing_enabled=true:
|
||||||
web-ui ← mam-api ← PostgreSQL ← worker (BullMQ)
|
│ /growing/<projectId>/<clip>.mov
|
||||||
├─ proxy_gen
|
│ (Premiere mounts SMB, edits live)
|
||||||
├─ thumbnail
|
│ └─► promotion worker uploads to S3
|
||||||
└─ conform (EDL → FFmpeg → export)
|
│
|
||||||
|
└─ growing_enabled=false:
|
||||||
|
multipart stream → S3
|
||||||
|
|
||||||
|
assets POST ──► proxy job ──► worker
|
||||||
|
├─ libx264 (CPU) or NVENC/VAAPI (GPU)
|
||||||
|
├─ thumbnail job
|
||||||
|
└─ status: ingesting → processing → ready
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Backend:** Node.js / Express
|
- **Runtime:** Node.js 22, Docker Compose
|
||||||
- **Frontend:** Vanilla HTML/CSS/JS
|
- **Backend:** Express, PostgreSQL 16, Redis 7 + BullMQ
|
||||||
- **Database:** PostgreSQL 16
|
- **Frontend:** Vanilla React via in-browser Babel (no bundler), hls.js
|
||||||
- **Queue:** Redis 7 + BullMQ
|
- **Media:** FFmpeg 7.1 with SDK 16 DeckLink patches
|
||||||
- **Storage:** S3-compatible (RustFS)
|
- **Codecs:** ProRes, H.264, H.265, DNxHR, MOV/MP4/MXF containers
|
||||||
- **Media Processing:** FFmpeg
|
- **Storage:** S3-compatible (RustFS) for masters, proxies, thumbnails
|
||||||
- **Capture:** Blackmagic DeckLink SDK
|
|
||||||
- **Deployment:** Docker Compose
|
## Services
|
||||||
|
|
||||||
|
| Service | Port | Purpose |
|
||||||
|
|---------|------|---------|
|
||||||
|
| **web-ui** | 47434 | Browser SPA + capture controls |
|
||||||
|
| **mam-api** | 47432 | REST API + recorder orchestration + scheduler |
|
||||||
|
| **capture** | 47433 / 9000 / 1935 | DeckLink/SRT/RTMP ingest sidecar |
|
||||||
|
| **worker** | — | BullMQ proxy + thumbnail workers |
|
||||||
|
| **db** | 5432 | PostgreSQL 16 |
|
||||||
|
| **queue** | 6379 | Redis 7 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow Example: Live-to-Edit
|
||||||
|
|
||||||
|
1. **Operator** schedules a recording on Recorder A for 14:00–15:30, assigns to "News/Segment-A" project
|
||||||
|
2. **Capture** starts at 14:00, writes ProRes master to SMB landing zone
|
||||||
|
3. **Editor** mounts SMB in Premiere, opens the live .mov file via the CEP panel
|
||||||
|
4. **Editor** trims and marks in/out points while capture is still writing
|
||||||
|
5. **Capture** finishes at 15:30, promotion worker uploads master to S3
|
||||||
|
6. **Editor** clicks "Relink to Master" in CEP panel
|
||||||
|
7. **Server** trims hi-res segment to exact in/out, stores for 24 hours
|
||||||
|
8. **Premiere** relinks proxy clips to trimmed master
|
||||||
|
9. **Editor** exports final timeline via FCP XML conform
|
||||||
|
|
||||||
|
Total time from end of capture to relinked master: ~2 minutes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
- `deploy/api-smoke.sh` — verify every API endpoint after deploy
|
||||||
|
- `deploy/onboard-node.sh` — provision a remote worker host
|
||||||
|
- `deploy/test-cluster.sh` — primary↔worker connectivity smoke test
|
||||||
|
- `docs/GROWING_FILES_QUICKSTART.md` — Premiere CEP panel install + growing-file flow
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Dragonflight uses local username/password authentication with two transports:
|
||||||
|
|
||||||
|
- **Browser:** session cookie (`dragonflight.sid`), 8 hour absolute + 1 hour idle timeout.
|
||||||
|
- **Premiere panel / scripts:** SHA-256-hashed bearer tokens issued from `Settings → API Tokens`.
|
||||||
|
|
||||||
|
### First-run setup
|
||||||
|
|
||||||
|
On a fresh install with `AUTH_ENABLED=true`, navigate to the web UI in a browser.
|
||||||
|
With no users in the database, the login screen renders a "First-run setup" form
|
||||||
|
instead — fill it in to create the first admin and you are logged in immediately.
|
||||||
|
|
||||||
|
Subsequent users are created from `Settings → Users` (any signed-in user can
|
||||||
|
create others — flat access).
|
||||||
|
|
||||||
|
### Dev mode
|
||||||
|
|
||||||
|
Setting `AUTH_ENABLED=false` disables all auth checks; a synthetic `dev` user
|
||||||
|
is attached to every request. **Never deploy this way.** The dev user row is
|
||||||
|
seeded with a hash that no real password can match, so flipping
|
||||||
|
`AUTH_ENABLED=true` later does not expose the dev account.
|
||||||
|
|
||||||
|
### Recovering a forgotten admin password
|
||||||
|
|
||||||
|
Any signed-in user can reset another user's password from `Settings → Users`.
|
||||||
|
If no one can sign in (all admins forgot their passwords), reset directly in
|
||||||
|
Postgres:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- generate a fresh bcrypt hash with:
|
||||||
|
-- node -e "import('bcrypt').then(b => b.default.hash(process.argv[1], 12).then(h => console.log(h)))" 'new-passphrase-here'
|
||||||
|
UPDATE users SET password_hash = '<bcrypt-hash>', password_updated_at = NOW()
|
||||||
|
WHERE username = 'admin';
|
||||||
|
```
|
||||||
|
|
||||||
|
### `AUTH_ENABLED` transition
|
||||||
|
|
||||||
|
When flipping `AUTH_ENABLED=false` → `true` on an existing install:
|
||||||
|
|
||||||
|
1. Ensure `SESSION_SECRET` is set to a stable value (rotating it logs everyone out).
|
||||||
|
2. Set `ALLOWED_ORIGINS` to the public origin(s) of the web UI.
|
||||||
|
3. Set `TRUST_PROXY=true` when behind nginx (required for rate-limit accuracy).
|
||||||
|
4. Restart `mam-api`.
|
||||||
|
5. Visit the UI — first-run setup will appear if no real users exist yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
Proprietary — Wild Dragon LLC, all rights reserved.
|
||||||
|
|
|
||||||
101
WORK_LOG_PLAYOUT.md
Normal file
101
WORK_LOG_PLAYOUT.md
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# Playout / Master Control — Implementation Work Log
|
||||||
|
|
||||||
|
**Branch:** `feat/playout-mcr` (off `main`)
|
||||||
|
**Started:** 2026-05-30
|
||||||
|
**Status:** Code complete, awaiting runtime validation
|
||||||
|
|
||||||
|
Tracks the build of the playout (MCR) subsystem against the design at
|
||||||
|
`docs/superpowers/specs/2026-05-30-playout-mcr-design.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit sequence
|
||||||
|
|
||||||
|
| # | Commit | Scope |
|
||||||
|
|---|--------|-------|
|
||||||
|
| 1 | `docs(playout)` | Design spec, §7 questions answered |
|
||||||
|
| 2 | `feat(mam-api): migration 029` | Six tables, failover columns, audio_normalized flag |
|
||||||
|
| 3 | `feat(worker): playout-stage` | S3 → /media + EBU R128 loudnorm + index.js wiring |
|
||||||
|
| 4 | `feat(playout): sidecar` | CasparCG image + AMCP shim, HLS preview consumer, fps-aware frame math |
|
||||||
|
| 5 | `feat(mam-api): /playout control plane + auto-failover` | Routes + scheduler health tick + restartChannel helper |
|
||||||
|
| 6 | `feat(web-ui): MCR page` | screens-playout, styles, app/shell/index.html wiring |
|
||||||
|
| 7 | `build(playout): compose wiring + .env knobs` | /media volume, queue addition, build-only service |
|
||||||
|
| 8 | `docs(playout): work log` | This file |
|
||||||
|
|
||||||
|
## Resolved §7 decisions (2026-05-30)
|
||||||
|
|
||||||
|
- **Audio loudness:** pre-normalize at stage time. ffmpeg `loudnorm` two-pass
|
||||||
|
(I=-23 LUFS, TP=-1 dBTP, LRA=11), linear mode preserves dynamics. Output
|
||||||
|
AAC 192k @ 48 kHz, video stream copied. Per-item `audio_normalized` flag
|
||||||
|
so re-stages of the same asset skip the pass.
|
||||||
|
- **Frame rate:** `1080p5994` default (was `1080i5994`). Per-channel
|
||||||
|
override allowed via `video_format`. `fpsFor(videoFormat)` helper in
|
||||||
|
the sidecar drives SEEK / LENGTH / transition-frames math.
|
||||||
|
- **Preview latency:** HLS v1. CasparCG runs a second FFMPEG consumer
|
||||||
|
alongside the primary output, writing `/media/live/<channel_id>/index.m3u8`
|
||||||
|
(~600 kbps, 2s segments, 6-window list). Web UI plays via the existing
|
||||||
|
HLS plumbing.
|
||||||
|
- **Failover:** auto-restart on healthy node for NDI/SRT/RTMP. Alert-only
|
||||||
|
for DeckLink (device-index pinning makes blind re-placement risky).
|
||||||
|
Scheduler tick (PG advisory lock, same lock as recorder schedules) polls
|
||||||
|
sidecar `/status`; ~3 missed checks → `restartChannel(id)` picks the most
|
||||||
|
recently-seen-online other node, bumps `restart_count`, calls `/start`.
|
||||||
|
|
||||||
|
## Architecture notes
|
||||||
|
|
||||||
|
**Sidecar model.** One CasparCG container per channel. Spawned by mam-api
|
||||||
|
via local Docker socket (primary node) or remote node-agent
|
||||||
|
`/sidecar/start`. Tracked in `playout_sidecars` plus `playout_channels.container_id`.
|
||||||
|
Killed on `/stop` or by `restartChannel` during failover.
|
||||||
|
|
||||||
|
**Media flow.**
|
||||||
|
```
|
||||||
|
S3 master/proxy → playout-stage worker → /media/playout/<assetId>.<ext>
|
||||||
|
(loudnormed, AAC@-23 LUFS)
|
||||||
|
↓
|
||||||
|
CasparCG channel #1
|
||||||
|
↓
|
||||||
|
primary consumer HLS consumer
|
||||||
|
(DeckLink/NDI/ ↓
|
||||||
|
SRT/RTMP) /media/live/<ch_id>/*.m3u8
|
||||||
|
```
|
||||||
|
|
||||||
|
**Port contention.** `assertDeckLinkFree()` blocks starting a SDI channel
|
||||||
|
when a recorder or another channel on the same node+device_index is active.
|
||||||
|
|
||||||
|
**Failover scope.** NDI/SRT/RTMP have no hardware tie, so any healthy
|
||||||
|
cluster_node is eligible. DeckLink channels surface an alert in the UI
|
||||||
|
(`status='error'` + `error_message`) and require operator intervention.
|
||||||
|
|
||||||
|
## Testing checklist
|
||||||
|
|
||||||
|
- [ ] Apply migration 029 on dev DB
|
||||||
|
- [ ] Build playout image: `docker compose --profile build-only build playout`
|
||||||
|
- [ ] Build web-ui (`screens-playout` joins the esbuild list automatically)
|
||||||
|
- [ ] Create channel via POST /api/v1/playout/channels (SRT first, no HW)
|
||||||
|
- [ ] Stage 2-3 assets to a playlist, verify loudnorm metadata in stderr
|
||||||
|
- [ ] Start channel → sidecar container appears in `docker ps`
|
||||||
|
- [ ] AMCP smoke: `telnet <host> 5250`, `VERSION`, `INFO`
|
||||||
|
- [ ] Play playlist; verify HLS at /media/live/<id>/index.m3u8
|
||||||
|
- [ ] Skip / pause / resume / stop
|
||||||
|
- [ ] As-run log: GET /api/v1/playout/channels/:id/asrun
|
||||||
|
- [ ] Kill sidecar container → scheduler should restart on another node
|
||||||
|
within ~3 ticks (~45s), restart_count increments
|
||||||
|
- [ ] DeckLink channel kill: status flips to 'error', NO restart attempt
|
||||||
|
- [ ] Try starting a decklink channel on a device_index already held by a
|
||||||
|
recorder → 409
|
||||||
|
- [ ] MCR UI smoke: nav entry visible, page renders, drag-drop adds items,
|
||||||
|
transport buttons hit the API
|
||||||
|
|
||||||
|
## Known gaps (deferred)
|
||||||
|
|
||||||
|
- No WebRTC preview (HLS-only v1 — 4-6s lag, fine for confidence monitor).
|
||||||
|
- No graphics/CG overlay layer in Phase A (templates land in Phase B).
|
||||||
|
- No Phase B scheduler / 24/7 wall-clock channel (schema is in place,
|
||||||
|
scheduler tick is not).
|
||||||
|
- No multi-channel grid view (one channel at a time per page).
|
||||||
|
- No timecode / remaining-duration overlay (would need CasparCG INFO poll).
|
||||||
|
- No audio level meters on the UI.
|
||||||
|
- `restartChannel` updates DB state and triggers `/start`; if the new node
|
||||||
|
also fails repeatedly, there's no exponential backoff yet — bounded only
|
||||||
|
by the manual stop button.
|
||||||
104
deploy/api-smoke.sh
Executable file
104
deploy/api-smoke.sh
Executable file
|
|
@ -0,0 +1,104 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Dragonflight MAM API smoke test
|
||||||
|
#
|
||||||
|
# Hits every read-only endpoint and a handful of safe write endpoints
|
||||||
|
# against a running mam-api. Reports per-endpoint HTTP code + a one-line
|
||||||
|
# pass/fail. Exits non-zero on any failure.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# deploy/api-smoke.sh # against http://localhost:47432
|
||||||
|
# API=http://10.0.0.25:47432 deploy/api-smoke.sh
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
API="${API:-http://localhost:47432}"
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
# Per-endpoint check. Args: METHOD PATH EXPECTED_HTTP_CODE [BODY]
|
||||||
|
# Treats anything < 500 as OK by default; auth-gated endpoints typically
|
||||||
|
# return 401 with AUTH_ENABLED, also acceptable.
|
||||||
|
hit() {
|
||||||
|
local method="$1" path="$2" expect="${3:-2..}" body="${4:-}"
|
||||||
|
local args=(-s -o /dev/null -w '%{http_code}' -X "$method" "${API}${path}")
|
||||||
|
if [ -n "$body" ]; then args+=(-H 'Content-Type: application/json' -d "$body"); fi
|
||||||
|
local code
|
||||||
|
code=$(curl "${args[@]}" 2>/dev/null || echo "000")
|
||||||
|
|
||||||
|
if [[ "$code" =~ ^(2|3|401|400)[0-9][0-9]$ ]]; then
|
||||||
|
printf " %s %-40s %s OK\n" "$method" "$path" "$code"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
printf " %s %-40s %s FAIL\n" "$method" "$path" "$code"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Dragonflight API smoke test — target ${API}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "── auth ──────────────────────────────────────────"
|
||||||
|
hit GET /api/v1/auth/me
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "── core lists ────────────────────────────────────"
|
||||||
|
hit GET /api/v1/projects
|
||||||
|
hit GET /api/v1/assets
|
||||||
|
hit GET /api/v1/assets?limit=5
|
||||||
|
hit GET /api/v1/recorders
|
||||||
|
hit GET /api/v1/jobs
|
||||||
|
hit GET /api/v1/bins
|
||||||
|
hit GET /api/v1/users
|
||||||
|
hit GET /api/v1/groups
|
||||||
|
hit GET /api/v1/cluster
|
||||||
|
hit GET /api/v1/cluster/containers
|
||||||
|
hit GET /api/v1/cluster/devices/blackmagic
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "── settings ──────────────────────────────────────"
|
||||||
|
hit GET /api/v1/settings/s3
|
||||||
|
hit GET /api/v1/settings/transcoding
|
||||||
|
hit GET /api/v1/settings/growing
|
||||||
|
hit GET /api/v1/settings/ampp
|
||||||
|
hit GET /api/v1/settings/hardware
|
||||||
|
hit GET /api/v1/settings/capture-service
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "── feature endpoints ─────────────────────────────"
|
||||||
|
hit GET /api/v1/metrics/home
|
||||||
|
hit GET /api/v1/metrics/home?hours=1
|
||||||
|
hit GET /api/v1/schedules
|
||||||
|
hit GET /api/v1/schedules?status=upcoming
|
||||||
|
hit GET /api/v1/sdk
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "── deep-link sanity (one asset) ──────────────────"
|
||||||
|
ASSET_ID=$(curl -s "${API}/api/v1/assets?limit=1" 2>/dev/null \
|
||||||
|
| sed -n 's/.*"id":"\([0-9a-f-]\{36\}\)".*/\1/p' | head -1)
|
||||||
|
if [ -n "$ASSET_ID" ]; then
|
||||||
|
echo " using asset_id=$ASSET_ID"
|
||||||
|
hit GET "/api/v1/assets/$ASSET_ID"
|
||||||
|
hit GET "/api/v1/assets/$ASSET_ID/comments"
|
||||||
|
hit GET "/api/v1/assets/$ASSET_ID/stream"
|
||||||
|
hit GET "/api/v1/assets/$ASSET_ID/thumbnail"
|
||||||
|
else
|
||||||
|
echo " (no assets to deep-link; skipping per-asset endpoints)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "── deep-link sanity (one recorder) ───────────────"
|
||||||
|
REC_ID=$(curl -s "${API}/api/v1/recorders" 2>/dev/null \
|
||||||
|
| sed -n 's/.*"id":"\([0-9a-f-]\{36\}\)".*/\1/p' | head -1)
|
||||||
|
if [ -n "$REC_ID" ]; then
|
||||||
|
echo " using recorder_id=$REC_ID"
|
||||||
|
hit GET "/api/v1/recorders/$REC_ID"
|
||||||
|
hit GET "/api/v1/recorders/$REC_ID/status"
|
||||||
|
else
|
||||||
|
echo " (no recorders to deep-link)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "── summary ───────────────────────────────────────"
|
||||||
|
echo " PASS: $PASS"
|
||||||
|
echo " FAIL: $FAIL"
|
||||||
|
[ "$FAIL" -eq 0 ]
|
||||||
197
deploy/onboard-node.sh
Normal file
197
deploy/onboard-node.sh
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Wild Dragon MAM — Cluster Node Onboarding
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Provisions a Linux machine as a cluster worker node in one command.
|
||||||
|
#
|
||||||
|
# Quick-start (pipe to bash):
|
||||||
|
# export MAM_API_URL=http://10.0.0.25:47432
|
||||||
|
# export NODE_TOKEN=wd_xxxx # create via Z-AMPP → Admin → Tokens
|
||||||
|
# curl -sL https://forge.wilddragon.net/zgaetano/wild-dragon/raw/branch/main/deploy/onboard-node.sh | bash
|
||||||
|
#
|
||||||
|
# Or run from a cloned repo:
|
||||||
|
# MAM_API_URL=http://10.0.0.25:47432 NODE_TOKEN=wd_xxxx ./deploy/onboard-node.sh
|
||||||
|
#
|
||||||
|
# Environment variables:
|
||||||
|
# MAM_API_URL REQUIRED Primary MAM API base URL
|
||||||
|
# NODE_TOKEN API bearer token (required if AUTH_ENABLED=true)
|
||||||
|
# NODE_ROLE Role tag reported to the cluster (default: worker)
|
||||||
|
# NODE_IP Override the LAN IP reported back (default: auto-detect)
|
||||||
|
# AGENT_PORT Host port for the node agent (default: 7436)
|
||||||
|
# INSTALL_DIR Where to clone/find the repo (default: /opt/wild-dragon)
|
||||||
|
# PROFILES Extra compose profiles, space-sep e.g. "worker capture"
|
||||||
|
# BMD_MODEL DeckLink card model name (e.g. "DeckLink Duo 2")
|
||||||
|
# REPO_URL Override the Forgejo clone URL
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Config ───────────────────────────────────────────────────────────────────
|
||||||
|
REPO_URL="${REPO_URL:-https://forge.wilddragon.net/zgaetano/wild-dragon.git}"
|
||||||
|
INSTALL_DIR="${INSTALL_DIR:-/opt/wild-dragon}"
|
||||||
|
MAM_API_URL="${MAM_API_URL:-}"
|
||||||
|
NODE_TOKEN="${NODE_TOKEN:-}"
|
||||||
|
NODE_ROLE="${NODE_ROLE:-worker}"
|
||||||
|
NODE_IP="${NODE_IP:-}"
|
||||||
|
AGENT_PORT="${AGENT_PORT:-7436}"
|
||||||
|
PROFILES="${PROFILES:-}"
|
||||||
|
BMD_MODEL="${BMD_MODEL:-}"
|
||||||
|
PROJECT_NAME="wild-dragon-worker"
|
||||||
|
|
||||||
|
# ── Colours ──────────────────────────────────────────────────────────────────
|
||||||
|
RED='\033[0;31m'; YEL='\033[1;33m'; GRN='\033[0;32m'; CYN='\033[0;36m'
|
||||||
|
BLD='\033[1m'; NC='\033[0m'
|
||||||
|
log() { echo -e "${GRN} ✓${NC} $*"; }
|
||||||
|
info() { echo -e "${CYN} ▶${NC} $*"; }
|
||||||
|
warn() { echo -e "${YEL} ⚠${NC} $*"; }
|
||||||
|
header() { echo -e "\n${BLD}${CYN}── $* ──────────────────────────────────────${NC}"; }
|
||||||
|
die() { echo -e "${RED} ✗ ERROR:${NC} $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# ── Auto-detect LAN IP ───────────────────────────────────────────────────────
|
||||||
|
# Node-agent runs in a container; os.networkInterfaces() inside the container
|
||||||
|
# returns the docker-bridge IP unless we pass NODE_IP through. We resolve the
|
||||||
|
# host's primary LAN IP here so the cluster page shows the right address.
|
||||||
|
detect_lan_ip() {
|
||||||
|
local ip=""
|
||||||
|
if command -v ip &>/dev/null; then
|
||||||
|
ip=$(ip -4 route get 1.1.1.1 2>/dev/null \
|
||||||
|
| awk '/src/ {for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}' \
|
||||||
|
|| true)
|
||||||
|
fi
|
||||||
|
if [[ -z "$ip" ]] && command -v hostname &>/dev/null; then
|
||||||
|
ip=$(hostname -I 2>/dev/null | awk '{print $1}' || true)
|
||||||
|
fi
|
||||||
|
echo "$ip"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Preflight ────────────────────────────────────────────────────────────────
|
||||||
|
echo -e "\n${BLD}${CYN}Wild Dragon MAM — Cluster Node Onboarding${NC}\n"
|
||||||
|
|
||||||
|
[[ -z "$MAM_API_URL" ]] && die "MAM_API_URL is required.\n\n Example:\n export MAM_API_URL=http://10.0.0.25:47432\n export NODE_TOKEN=wd_xxxx\n ./deploy/onboard-node.sh"
|
||||||
|
|
||||||
|
if [[ -z "$NODE_IP" ]]; then
|
||||||
|
NODE_IP="$(detect_lan_ip)"
|
||||||
|
if [[ -n "$NODE_IP" ]]; then
|
||||||
|
info "Auto-detected LAN IP: $NODE_IP"
|
||||||
|
else
|
||||||
|
warn "Could not auto-detect LAN IP — agent will fall back to interface heuristics."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Primary API : $MAM_API_URL"
|
||||||
|
info "Role : $NODE_ROLE"
|
||||||
|
info "Agent port : $AGENT_PORT"
|
||||||
|
info "Install dir : $INSTALL_DIR"
|
||||||
|
[[ -n "$NODE_IP" ]] && info "Node IP : $NODE_IP"
|
||||||
|
[[ -n "$BMD_MODEL" ]] && info "DeckLink : $BMD_MODEL"
|
||||||
|
[[ -n "$PROFILES" ]] && info "Profiles : $PROFILES"
|
||||||
|
|
||||||
|
if [[ -z "$NODE_TOKEN" ]]; then
|
||||||
|
warn "NODE_TOKEN is not set."
|
||||||
|
warn "If AUTH_ENABLED=true on the primary, heartbeats will be rejected."
|
||||||
|
warn "Create a token: Z-AMPP web UI → Admin → Tokens → New Token"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Step 1: Docker ───────────────────────────────────────────────────────────
|
||||||
|
header "1/4 Docker"
|
||||||
|
|
||||||
|
if ! command -v docker &>/dev/null; then
|
||||||
|
warn "Docker not found — installing via get.docker.com"
|
||||||
|
curl -fsSL https://get.docker.com | bash
|
||||||
|
systemctl enable --now docker 2>/dev/null || true
|
||||||
|
usermod -aG docker "${SUDO_USER:-$USER}" 2>/dev/null || true
|
||||||
|
log "Docker installed"
|
||||||
|
else
|
||||||
|
log "Docker $(docker --version | grep -oP '\d+\.\d+\.\d+' | head -1) already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker info &>/dev/null; then
|
||||||
|
die "Docker daemon not accessible.\n Try: sudo systemctl start docker\n Or add your user to the docker group and re-login."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Step 2: Repository ───────────────────────────────────────────────────────
|
||||||
|
header "2/4 Repository"
|
||||||
|
|
||||||
|
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
||||||
|
info "Updating $INSTALL_DIR"
|
||||||
|
git -C "$INSTALL_DIR" pull --ff-only
|
||||||
|
log "Repository up to date ($(git -C "$INSTALL_DIR" rev-parse --short HEAD))"
|
||||||
|
else
|
||||||
|
info "Cloning $REPO_URL → $INSTALL_DIR"
|
||||||
|
mkdir -p "$(dirname "$INSTALL_DIR")"
|
||||||
|
git clone "$REPO_URL" "$INSTALL_DIR"
|
||||||
|
log "Repository cloned"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Step 3: Environment ──────────────────────────────────────────────────────
|
||||||
|
header "3/4 Configuration"
|
||||||
|
|
||||||
|
ENV_FILE="$INSTALL_DIR/.env.worker"
|
||||||
|
info "Writing $ENV_FILE"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "# Wild Dragon worker node — generated $(date -u +%Y-%m-%dT%H:%M:%SZ) by onboard-node.sh"
|
||||||
|
echo "MAM_API_URL=$MAM_API_URL"
|
||||||
|
echo "NODE_TOKEN=$NODE_TOKEN"
|
||||||
|
echo "NODE_ROLE=$NODE_ROLE"
|
||||||
|
echo "NODE_IP=$NODE_IP"
|
||||||
|
echo "AGENT_PORT=$AGENT_PORT"
|
||||||
|
echo "HEARTBEAT_MS=30000"
|
||||||
|
[[ -n "$BMD_MODEL" ]] && echo "BMD_MODEL=$BMD_MODEL"
|
||||||
|
for v in REDIS_URL DATABASE_URL S3_ENDPOINT S3_BUCKET S3_ACCESS_KEY S3_SECRET_KEY S3_REGION; do
|
||||||
|
val="${!v:-}"
|
||||||
|
[[ -n "$val" ]] && echo "$v=$val"
|
||||||
|
done
|
||||||
|
} > "$ENV_FILE"
|
||||||
|
|
||||||
|
log "Env file written"
|
||||||
|
|
||||||
|
# ── Step 4: Start services ───────────────────────────────────────────────────
|
||||||
|
header "4/4 Starting services"
|
||||||
|
|
||||||
|
COMPOSE="docker compose -f $INSTALL_DIR/docker-compose.worker.yml --env-file $ENV_FILE --project-name $PROJECT_NAME"
|
||||||
|
|
||||||
|
PROFILE_FLAGS=""
|
||||||
|
for p in $PROFILES; do
|
||||||
|
PROFILE_FLAGS="$PROFILE_FLAGS --profile $p"
|
||||||
|
done
|
||||||
|
|
||||||
|
info "Building images (this may take a minute on first run)…"
|
||||||
|
$COMPOSE build
|
||||||
|
|
||||||
|
info "Starting containers…"
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
$COMPOSE $PROFILE_FLAGS up -d
|
||||||
|
|
||||||
|
# ── Verify ───────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
info "Waiting 6 seconds for agent to initialise…"
|
||||||
|
sleep 6
|
||||||
|
|
||||||
|
HEALTH_URL="http://localhost:$AGENT_PORT/health"
|
||||||
|
if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then
|
||||||
|
log "Node agent healthy at $HEALTH_URL"
|
||||||
|
REPORTED_IP=$(curl -sf "$HEALTH_URL" | sed -nE 's/.*"ip":"([^"]+)".*/\1/p')
|
||||||
|
[[ -n "$REPORTED_IP" ]] && log "Reporting IP: $REPORTED_IP"
|
||||||
|
else
|
||||||
|
warn "Could not reach $HEALTH_URL — check logs:"
|
||||||
|
warn " $COMPOSE logs node-agent"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Done ─────────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLD}${GRN}Onboarding complete!${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e " Node agent ${BLD}:$AGENT_PORT${NC} (heartbeating every 30s)"
|
||||||
|
echo -e " Primary API ${BLD}$MAM_API_URL${NC}"
|
||||||
|
echo -e " Role ${BLD}$NODE_ROLE${NC}"
|
||||||
|
[[ -n "$NODE_IP" ]] && echo -e " Node IP ${BLD}$NODE_IP${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${CYN}Useful commands:${NC}"
|
||||||
|
echo -e " Status : $COMPOSE ps"
|
||||||
|
echo -e " Logs : $COMPOSE logs -f"
|
||||||
|
echo -e " Stop : $COMPOSE down"
|
||||||
|
echo -e " Update : git -C $INSTALL_DIR pull && $COMPOSE build && $COMPOSE up -d"
|
||||||
|
echo ""
|
||||||
|
echo -e " Open the Z-AMPP web UI → ${BLD}Admin → Cluster${NC} to see this node."
|
||||||
186
deploy/test-api.sh
Normal file
186
deploy/test-api.sh
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Wild Dragon MAM — API Smoke Test
|
||||||
|
# =============================================================================
|
||||||
|
# Hits every major endpoint and reports pass/fail.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# MAM_API_URL=http://10.0.0.25:47432 ./deploy/test-api.sh
|
||||||
|
# MAM_API_URL=http://10.0.0.25:47432 NODE_TOKEN=wd_xxxx ./deploy/test-api.sh
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE="${MAM_API_URL:-http://localhost:47432}"
|
||||||
|
TOKEN="${NODE_TOKEN:-}"
|
||||||
|
|
||||||
|
PASS=0; FAIL=0; SKIP=0
|
||||||
|
|
||||||
|
GRN='\033[0;32m'; RED='\033[0;31m'; YEL='\033[1;33m'; CYN='\033[0;36m'; BLD='\033[1m'; NC='\033[0m'
|
||||||
|
|
||||||
|
pass() { PASS=$((PASS+1)); echo -e " ${GRN}PASS${NC} $1"; }
|
||||||
|
fail() { FAIL=$((FAIL+1)); echo -e " ${RED}FAIL${NC} $1 ${RED}← $2${NC}"; }
|
||||||
|
skip() { SKIP=$((SKIP+1)); echo -e " ${YEL}SKIP${NC} $1 ${YEL}($2)${NC}"; }
|
||||||
|
header() { echo -e "\n${BLD}$1${NC}"; }
|
||||||
|
|
||||||
|
AUTH_ARGS=()
|
||||||
|
[[ -n "$TOKEN" ]] && AUTH_ARGS+=(-H "Authorization: Bearer $TOKEN")
|
||||||
|
|
||||||
|
# GET — check HTTP status code (no -f so 4xx/5xx are visible as their real code)
|
||||||
|
check_status() {
|
||||||
|
local label="$1" path="$2" want="$3"
|
||||||
|
local got
|
||||||
|
got=$(curl -s -o /dev/null -w "%{http_code}" "${AUTH_ARGS[@]}" "$BASE$path" 2>/dev/null)
|
||||||
|
[[ -z "$got" ]] && got="000"
|
||||||
|
if [[ "$got" == "$want" ]]; then
|
||||||
|
pass "$label [HTTP $got]"
|
||||||
|
else
|
||||||
|
fail "$label [HTTP $got]" "expected $want"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# GET — check response body contains literal string (fgrep avoids regex interpretation)
|
||||||
|
check_body() {
|
||||||
|
local label="$1" path="$2" needle="$3"
|
||||||
|
local body
|
||||||
|
body=$(curl -s "${AUTH_ARGS[@]}" "$BASE$path" 2>/dev/null) || { fail "$label" "request failed"; return; }
|
||||||
|
if echo "$body" | grep -qF "$needle"; then
|
||||||
|
pass "$label"
|
||||||
|
else
|
||||||
|
fail "$label" "'$needle' not in response"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# POST — check HTTP status code
|
||||||
|
check_post() {
|
||||||
|
local label="$1" path="$2" data="$3" want="$4"
|
||||||
|
local got
|
||||||
|
got=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
"${AUTH_ARGS[@]}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-X POST -d "$data" \
|
||||||
|
"$BASE$path" 2>/dev/null)
|
||||||
|
[[ -z "$got" ]] && got="000"
|
||||||
|
if [[ "$got" == "$want" ]]; then
|
||||||
|
pass "$label [HTTP $got]"
|
||||||
|
else
|
||||||
|
fail "$label [HTTP $got]" "expected $want"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLD}${CYN}Wild Dragon MAM — API Smoke Test${NC}"
|
||||||
|
echo -e " Base URL : ${BLD}$BASE${NC}"
|
||||||
|
[[ -n "$TOKEN" ]] && echo -e " Auth : Bearer token" || echo -e " Auth : none"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Connectivity ─────────────────────────────────────────────────────────────
|
||||||
|
header "Connectivity"
|
||||||
|
CONNECT=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/health" 2>/dev/null)
|
||||||
|
if [[ "$CONNECT" == "200" ]]; then
|
||||||
|
pass "API server reachable [/health → 200]"
|
||||||
|
else
|
||||||
|
fail "API server reachable [HTTP $CONNECT]" "cannot reach $BASE"
|
||||||
|
echo -e "\n ${RED}Cannot reach the server — aborting.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Auth ─────────────────────────────────────────────────────────────────────
|
||||||
|
header "Auth"
|
||||||
|
check_status "GET /auth/me" "/api/v1/auth/me" 200
|
||||||
|
check_body "GET /auth/me returns username" "/api/v1/auth/me" '"username"'
|
||||||
|
check_post "POST /auth/login (missing body → 400)" "/api/v1/auth/login" '{}' 400
|
||||||
|
|
||||||
|
# ── Assets ───────────────────────────────────────────────────────────────────
|
||||||
|
header "Assets"
|
||||||
|
check_status "GET /assets" "/api/v1/assets" 200
|
||||||
|
check_body "GET /assets returns assets key" "/api/v1/assets" '"assets"'
|
||||||
|
check_status "GET /assets bogus id → 404" "/api/v1/assets/00000000-0000-0000-0000-000000000000" 404
|
||||||
|
|
||||||
|
# ── Projects ─────────────────────────────────────────────────────────────────
|
||||||
|
header "Projects"
|
||||||
|
check_status "GET /projects" "/api/v1/projects" 200
|
||||||
|
check_body "GET /projects returns array" "/api/v1/projects" '['
|
||||||
|
|
||||||
|
# ── Jobs ─────────────────────────────────────────────────────────────────────
|
||||||
|
header "Jobs"
|
||||||
|
check_status "GET /jobs" "/api/v1/jobs" 200
|
||||||
|
check_body "GET /jobs returns array" "/api/v1/jobs" '['
|
||||||
|
|
||||||
|
# ── Recorders ────────────────────────────────────────────────────────────────
|
||||||
|
header "Recorders"
|
||||||
|
check_status "GET /recorders" "/api/v1/recorders" 200
|
||||||
|
|
||||||
|
# ── Sequences (requires project_id param) ────────────────────────────────────
|
||||||
|
header "Sequences"
|
||||||
|
check_status "GET /sequences (no project_id → 400)" "/api/v1/sequences" 400
|
||||||
|
check_status "GET /sequences bogus project_id → 200" "/api/v1/sequences?project_id=00000000-0000-0000-0000-000000000000" 200
|
||||||
|
|
||||||
|
# ── Settings ─────────────────────────────────────────────────────────────────
|
||||||
|
header "Settings"
|
||||||
|
check_status "GET /settings/ampp" "/api/v1/settings/ampp" 200
|
||||||
|
|
||||||
|
# ── Cluster ──────────────────────────────────────────────────────────────────
|
||||||
|
header "Cluster"
|
||||||
|
check_status "GET /cluster" "/api/v1/cluster" 200
|
||||||
|
check_body "GET /cluster returns array" "/api/v1/cluster" '['
|
||||||
|
|
||||||
|
# Heartbeat: register a temporary smoke-test node, verify it appears, remove it
|
||||||
|
TEST_HOST="smoke-test-$(date +%s)"
|
||||||
|
check_post "POST /cluster/heartbeat" "/api/v1/cluster/heartbeat" \
|
||||||
|
"{\"hostname\":\"$TEST_HOST\",\"role\":\"smoketest\",\"cpu_usage\":0,\"mem_used_mb\":512,\"mem_total_mb\":4096}" \
|
||||||
|
200
|
||||||
|
|
||||||
|
NODE_ID=$(curl -s "${AUTH_ARGS[@]}" "$BASE/api/v1/cluster" 2>/dev/null \
|
||||||
|
| grep -o '"id":"[^"]*"' | head -1 | grep -o '[0-9a-f-]\{36\}' || true)
|
||||||
|
|
||||||
|
if [[ -n "$NODE_ID" ]]; then
|
||||||
|
pass "Cluster node visible in registry"
|
||||||
|
DEL=$(curl -s -o /dev/null -w "%{http_code}" "${AUTH_ARGS[@]}" \
|
||||||
|
-X DELETE "$BASE/api/v1/cluster/$NODE_ID" 2>/dev/null)
|
||||||
|
[[ "$DEL" == "200" ]] && pass "DELETE /cluster/:id (cleanup) [HTTP $DEL]" \
|
||||||
|
|| fail "DELETE /cluster/:id (cleanup)" "HTTP $DEL"
|
||||||
|
else
|
||||||
|
skip "Cluster node visible in registry" "could not parse node id from response"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── System / Containers ───────────────────────────────────────────────────────
|
||||||
|
header "System"
|
||||||
|
check_status "GET /system/containers" "/api/v1/system/containers" 200
|
||||||
|
check_body "Containers returns array" "/api/v1/system/containers" '['
|
||||||
|
|
||||||
|
# ── Capture (proxies to capture service) ─────────────────────────────────────
|
||||||
|
header "Capture"
|
||||||
|
# 200 = capture active and responding
|
||||||
|
# 404 = capture in sidecar/idle mode (no active recorder — expected in dev)
|
||||||
|
# 5xx = capture container unreachable
|
||||||
|
CAPTURE_CODE=$(curl -s -o /dev/null -w "%{http_code}" "${AUTH_ARGS[@]}" \
|
||||||
|
"$BASE/api/v1/capture/status" 2>/dev/null)
|
||||||
|
if [[ "$CAPTURE_CODE" == "200" ]]; then
|
||||||
|
pass "GET /capture/status [HTTP 200 — capture active]"
|
||||||
|
elif [[ "$CAPTURE_CODE" == "404" ]]; then
|
||||||
|
skip "GET /capture/status [HTTP 404]" "capture in idle/sidecar mode (normal when not recording)"
|
||||||
|
elif [[ "$CAPTURE_CODE" =~ ^5 ]]; then
|
||||||
|
skip "GET /capture/status [HTTP $CAPTURE_CODE]" "capture container unreachable"
|
||||||
|
else
|
||||||
|
fail "GET /capture/status [HTTP $CAPTURE_CODE]" "unexpected status"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Users / Tokens ───────────────────────────────────────────────────────────
|
||||||
|
header "Users / Tokens"
|
||||||
|
check_status "GET /users" "/api/v1/users" 200
|
||||||
|
check_status "GET /tokens" "/api/v1/tokens" 200
|
||||||
|
|
||||||
|
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||||
|
TOTAL=$((PASS + FAIL + SKIP))
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLD}Results:${NC} ${GRN}${PASS} passed${NC} / ${RED}${FAIL} failed${NC} / ${YEL}${SKIP} skipped${NC} / $TOTAL total"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ $FAIL -gt 0 ]]; then
|
||||||
|
echo -e "${RED}Some tests failed — check output above.${NC}"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo -e "${GRN}All tests passed.${NC}"
|
||||||
|
fi
|
||||||
183
deploy/test-cluster.sh
Executable file
183
deploy/test-cluster.sh
Executable file
|
|
@ -0,0 +1,183 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Wild Dragon MAM — Cluster Smoke Test
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Validates the cluster end-to-end from any node that can reach the primary.
|
||||||
|
# Designed to be run after `onboard-node.sh` finishes on every worker.
|
||||||
|
#
|
||||||
|
# MAM_API_URL=http://10.0.0.25:47432 ./deploy/test-cluster.sh
|
||||||
|
# MAM_API_URL=... AUTH_TOKEN=wd_xxxx ./deploy/test-cluster.sh
|
||||||
|
#
|
||||||
|
# Checks:
|
||||||
|
# 1. Primary API health
|
||||||
|
# 2. Cluster registry (no duplicate hostnames, IPs are real LAN addresses)
|
||||||
|
# 3. Each worker's /health endpoint
|
||||||
|
# 4. GPU detection (nvidia-smi exits clean on nodes that report GPUs)
|
||||||
|
# 5. NVENC encode probe (5s of synthetic h264_nvenc → /tmp)
|
||||||
|
# 6. Blackmagic device enumeration
|
||||||
|
#
|
||||||
|
# Exit 0 = all pass, 1 = any failure. Failures are logged inline.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
MAM_API_URL="${MAM_API_URL:-}"
|
||||||
|
AUTH_TOKEN="${AUTH_TOKEN:-}"
|
||||||
|
|
||||||
|
if [[ -z "$MAM_API_URL" ]]; then
|
||||||
|
echo "✗ MAM_API_URL is required" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
RED='\033[0;31m'; YEL='\033[1;33m'; GRN='\033[0;32m'; CYN='\033[0;36m'; BLD='\033[1m'; NC='\033[0m'
|
||||||
|
PASS=0; FAIL=0
|
||||||
|
pass() { echo -e "${GRN} ✓${NC} $*"; PASS=$((PASS+1)); }
|
||||||
|
fail() { echo -e "${RED} ✗${NC} $*"; FAIL=$((FAIL+1)); }
|
||||||
|
note() { echo -e "${CYN} ▶${NC} $*"; }
|
||||||
|
warn() { echo -e "${YEL} !${NC} $*"; }
|
||||||
|
|
||||||
|
api() {
|
||||||
|
local method="${1:-GET}"; shift
|
||||||
|
local path="$1"; shift
|
||||||
|
local args=(-sS -X "$method" -H 'Content-Type: application/json')
|
||||||
|
[[ -n "$AUTH_TOKEN" ]] && args+=(-H "Authorization: Bearer $AUTH_TOKEN")
|
||||||
|
curl "${args[@]}" "$@" "${MAM_API_URL}${path}"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo -e "${BLD}${CYN}Wild Dragon — Cluster Smoke Test${NC}"
|
||||||
|
echo -e "Primary: $MAM_API_URL"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 1. Primary API health ───────────────────────────────────────────────
|
||||||
|
echo -e "${BLD}1. Primary API health${NC}"
|
||||||
|
if api GET /health | grep -q '"status":"ok"'; then
|
||||||
|
pass "primary /health responds"
|
||||||
|
else
|
||||||
|
fail "primary /health did not return ok"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 2. Cluster registry ─────────────────────────────────────────────────
|
||||||
|
echo -e "${BLD}2. Cluster registry${NC}"
|
||||||
|
NODES_JSON=$(api GET /api/v1/cluster || echo '[]')
|
||||||
|
TOTAL=$(echo "$NODES_JSON" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))' 2>/dev/null || echo 0)
|
||||||
|
note "$TOTAL nodes registered"
|
||||||
|
|
||||||
|
if [[ "$TOTAL" -gt 0 ]]; then
|
||||||
|
# No duplicate hostnames
|
||||||
|
DUP=$(echo "$NODES_JSON" | python3 -c '
|
||||||
|
import sys, json
|
||||||
|
nodes = json.load(sys.stdin)
|
||||||
|
seen = {}
|
||||||
|
dups = []
|
||||||
|
for n in nodes:
|
||||||
|
h = n.get('hostname')
|
||||||
|
if h in seen: dups.append(h)
|
||||||
|
seen[h] = True
|
||||||
|
print(",".join(sorted(set(dups))))' 2>/dev/null)
|
||||||
|
if [[ -z "$DUP" ]]; then
|
||||||
|
pass "no duplicate hostnames"
|
||||||
|
else
|
||||||
|
fail "duplicate hostnames: $DUP — run migration 007"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# No private docker IPs (172.16.0.0/12 reserved for docker bridges)
|
||||||
|
BAD_IPS=""
|
||||||
|
while IFS=$'\t' read -r host ip; do
|
||||||
|
[[ -z "$ip" ]] && continue
|
||||||
|
first="${ip%%.*}"; rest="${ip#*.}"; second="${rest%%.*}"
|
||||||
|
if [[ "$first" == "172" && "$second" == "17" ]]; then
|
||||||
|
BAD_IPS+="${host}=${ip},"
|
||||||
|
fi
|
||||||
|
done < <(echo "$NODES_JSON" | jq -r '.[] | [.hostname, (.ip_address // "")] | @tsv')
|
||||||
|
BAD_IPS="${BAD_IPS%,}"
|
||||||
|
if [[ -z "$BAD_IPS" ]]; then
|
||||||
|
pass "all node IPs are real LAN addresses"
|
||||||
|
else
|
||||||
|
fail "nodes still reporting docker bridge IPs: $BAD_IPS"
|
||||||
|
warn " → set NODE_IP in .env.worker and restart the node-agent"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# All nodes recently seen
|
||||||
|
STALE=$(echo "$NODES_JSON" | python3 -c '
|
||||||
|
import sys, json
|
||||||
|
nodes = json.load(sys.stdin)
|
||||||
|
stale = [n["hostname"] for n in nodes if float(n.get("stale_seconds") or 9999) > 120]
|
||||||
|
print(",".join(stale))' 2>/dev/null)
|
||||||
|
if [[ -z "$STALE" ]]; then
|
||||||
|
pass "all nodes heartbeated within 2 min"
|
||||||
|
else
|
||||||
|
warn "stale nodes (>2 min since heartbeat): $STALE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 3. Per-node /health probes ──────────────────────────────────────────
|
||||||
|
echo -e "${BLD}3. Worker agent /health endpoints${NC}"
|
||||||
|
echo "$NODES_JSON" | python3 -c '
|
||||||
|
import sys, json
|
||||||
|
for n in json.load(sys.stdin):
|
||||||
|
if n.get("role") == "primary": continue
|
||||||
|
print(n["id"], n["hostname"], n.get("api_url") or "")
|
||||||
|
' 2>/dev/null | while read -r ID HOST URL; do
|
||||||
|
[[ -z "$URL" ]] && { warn "$HOST: no api_url registered"; continue; }
|
||||||
|
if curl -sf --max-time 4 "$URL/health" >/dev/null 2>&1; then
|
||||||
|
pass "$HOST ($URL/health)"
|
||||||
|
else
|
||||||
|
fail "$HOST agent unreachable at $URL/health"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 4. Local GPU + NVENC probe (when run on a GPU node) ─────────────────
|
||||||
|
echo -e "${BLD}4. Local GPU + NVENC${NC}"
|
||||||
|
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||||
|
GPU_COUNT=$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | wc -l)
|
||||||
|
if [[ "$GPU_COUNT" -gt 0 ]]; then
|
||||||
|
pass "$GPU_COUNT NVIDIA GPU(s) visible to host"
|
||||||
|
if command -v ffmpeg >/dev/null 2>&1; then
|
||||||
|
if ffmpeg -hide_banner -loglevel error \
|
||||||
|
-f lavfi -i testsrc=duration=5:size=1280x720:rate=30 \
|
||||||
|
-c:v h264_nvenc -preset p1 -b:v 4M \
|
||||||
|
-t 5 -f null - 2>/tmp/wd-nvenc.log; then
|
||||||
|
pass "NVENC encode test succeeded"
|
||||||
|
else
|
||||||
|
fail "NVENC encode failed — see /tmp/wd-nvenc.log"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "ffmpeg not installed locally — skipping NVENC encode test"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "nvidia-smi found but reports 0 GPUs"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "nvidia-smi not present (not a GPU node)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 5. Blackmagic device enumeration ────────────────────────────────────
|
||||||
|
echo -e "${BLD}5. Blackmagic devices (cluster-wide)${NC}"
|
||||||
|
BMD_JSON=$(api GET /api/v1/cluster/devices/blackmagic || echo '[]')
|
||||||
|
BMD_COUNT=$(echo "$BMD_JSON" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))' 2>/dev/null || echo 0)
|
||||||
|
if [[ "$BMD_COUNT" -gt 0 ]]; then
|
||||||
|
pass "$BMD_COUNT DeckLink port(s) registered"
|
||||||
|
echo "$BMD_JSON" | jq -r '.[] | " \(.hostname) port=\(.index) model=\(.model // "unknown") online=\(.online)"'
|
||||||
|
else
|
||||||
|
warn "no DeckLink devices reported by any node"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 6. Local Blackmagic device files ────────────────────────────────────
|
||||||
|
echo -e "${BLD}6. Local /dev/blackmagic${NC}"
|
||||||
|
if [[ -d /dev/blackmagic ]]; then
|
||||||
|
ls /dev/blackmagic/ | sed 's/^/ /'
|
||||||
|
pass "$(ls /dev/blackmagic/ | wc -l) device node(s) under /dev/blackmagic"
|
||||||
|
else
|
||||||
|
warn "no /dev/blackmagic on this machine"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Summary ─────────────────────────────────────────────────────────────
|
||||||
|
echo -e "${BLD}Summary:${NC} ${GRN}$PASS pass${NC} ${RED}$FAIL fail${NC}"
|
||||||
|
[[ "$FAIL" -gt 0 ]] && exit 1 || exit 0
|
||||||
33
docker-compose.gpu.yml
Normal file
33
docker-compose.gpu.yml
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Wild Dragon MAM — GPU overlay
|
||||||
|
# Apply on top of docker-compose.yml on nodes with NVIDIA GPUs.
|
||||||
|
#
|
||||||
|
# Prerequisites: NVIDIA Container Toolkit installed on the host.
|
||||||
|
# Install: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/
|
||||||
|
#
|
||||||
|
# Usage (core MAM node with GPUs):
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
|
||||||
|
#
|
||||||
|
# Usage (worker node with GPUs):
|
||||||
|
# docker compose -f docker-compose.worker.yml -f docker-compose.gpu.yml --profile worker up -d
|
||||||
|
#
|
||||||
|
# This overlay:
|
||||||
|
# - Rebuilds worker from Dockerfile.gpu (CUDA base + ffmpeg NVENC)
|
||||||
|
# - Passes all NVIDIA GPUs into the worker container
|
||||||
|
# - Sets NVENC_ENABLED=true so the worker prioritises h264_nvenc/hevc_nvenc
|
||||||
|
|
||||||
|
services:
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: ./services/worker
|
||||||
|
dockerfile: Dockerfile.gpu
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: all
|
||||||
|
capabilities: [gpu]
|
||||||
|
environment:
|
||||||
|
NVENC_ENABLED: "true"
|
||||||
|
NVIDIA_VISIBLE_DEVICES: all
|
||||||
|
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
|
||||||
130
docker-compose.worker.yml
Normal file
130
docker-compose.worker.yml
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
# Wild Dragon MAM — Worker Node Stack
|
||||||
|
# ─────────────────────────────────────
|
||||||
|
# Deploy on any machine you want to join the cluster as a worker.
|
||||||
|
# The primary stack (mam-api, db, redis) continues running on TrueNAS.
|
||||||
|
#
|
||||||
|
# Required env vars (set in .env.worker or export before running):
|
||||||
|
# MAM_API_URL URL of the primary MAM API e.g. http://10.0.0.25:47432
|
||||||
|
# NODE_TOKEN Bearer token from the primary's Tokens page
|
||||||
|
# NODE_IP Host LAN IP to report (set by onboard-node.sh)
|
||||||
|
#
|
||||||
|
# Optional hardware overrides (if Docker can't see /dev directly):
|
||||||
|
# GPU_COUNT Number of NVIDIA GPUs on this node (default: auto-detect from /dev/nvidia*)
|
||||||
|
# BMD_COUNT Number of Blackmagic DeckLink cards (default: auto-detect from /dev/blackmagic/)
|
||||||
|
# BMD_MODEL Marketed card name (e.g. "DeckLink Duo 2") — drives the port-diagram UI
|
||||||
|
#
|
||||||
|
# Optional env vars (needed only if starting the worker or capture profiles):
|
||||||
|
# REDIS_URL, DATABASE_URL, S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY
|
||||||
|
# BMD_DEVICE_0 DeckLink device path (default: /dev/blackmagic/dv0)
|
||||||
|
# (DeckLink IO / Quad cards expose /dev/blackmagic/io* instead — set BMD_DEVICE_PREFIX=io)
|
||||||
|
# BMD_DEVICE_1 DeckLink device path (default: /dev/blackmagic/dv1)
|
||||||
|
# BMD_DEVICE_PREFIX Naming prefix for synthesized BMD_COUNT-based devices (default: dv). Use 'io' for IO/Quad.
|
||||||
|
# LIVE_DIR Host path for HLS live segments (default: /mnt/NVME/MAM/wild-dragon-live)
|
||||||
|
#
|
||||||
|
# Profiles:
|
||||||
|
# (default) node-agent only — cluster visibility + hardware heartbeat
|
||||||
|
# --profile worker + CPU/GPU job worker (proxy generation, transcoding)
|
||||||
|
# --profile capture + SDI capture service (requires Blackmagic DeckLink card)
|
||||||
|
#
|
||||||
|
# To enable GPU transcoding, also apply docker-compose.gpu.yml:
|
||||||
|
# docker compose -f docker-compose.worker.yml -f docker-compose.gpu.yml --profile worker up -d
|
||||||
|
#
|
||||||
|
# NOTE: The node-agent mounts /var/run/docker.sock to spawn on-demand SDI
|
||||||
|
# capture sidecars when the primary mam-api routes a recorder to this node.
|
||||||
|
# Build the capture image before first use:
|
||||||
|
# docker compose -f docker-compose.worker.yml build capture
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
# node-agent runs in host network mode so it can see the real host
|
||||||
|
# interfaces, GPU devices and DeckLink cards without bridging tricks.
|
||||||
|
# The reported IP / hostname will be the host's, not the container's.
|
||||||
|
node-agent:
|
||||||
|
build: ./services/node-agent
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
MAM_API_URL: ${MAM_API_URL}
|
||||||
|
NODE_TOKEN: ${NODE_TOKEN:-}
|
||||||
|
NODE_ROLE: ${NODE_ROLE:-worker}
|
||||||
|
NODE_IP: ${NODE_IP:-}
|
||||||
|
AGENT_PORT: ${AGENT_PORT:-7436}
|
||||||
|
HEARTBEAT_MS: ${HEARTBEAT_MS:-30000}
|
||||||
|
GPU_COUNT: ${GPU_COUNT:--1}
|
||||||
|
BMD_COUNT: ${BMD_COUNT:--1}
|
||||||
|
BMD_MODEL: ${BMD_MODEL:-}
|
||||||
|
BMD_DEVICE_PREFIX: ${BMD_DEVICE_PREFIX:-dv}
|
||||||
|
LIVE_DIR: ${LIVE_DIR:-/mnt/NVME/MAM/wild-dragon-live}
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- /dev:/dev:ro
|
||||||
|
- /mnt/NVME/MAM/wild-dragon-live:/mnt/NVME/MAM/wild-dragon-live:ro
|
||||||
|
devices:
|
||||||
|
- /dev/blackmagic:/dev/blackmagic
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build: ./services/worker
|
||||||
|
profiles: [worker]
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
REDIS_URL: ${REDIS_URL}
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
S3_ENDPOINT: ${S3_ENDPOINT}
|
||||||
|
S3_BUCKET: ${S3_BUCKET}
|
||||||
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||||
|
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||||
|
S3_REGION: ${S3_REGION:-us-east-1}
|
||||||
|
NVENC_ENABLED: ${NVENC_ENABLED:-false}
|
||||||
|
networks:
|
||||||
|
- wild-dragon-worker
|
||||||
|
|
||||||
|
# SDI capture service — only start on nodes with Blackmagic DeckLink cards
|
||||||
|
# Set BMD_DEVICE_0 in .env.worker to the actual device path, e.g. /dev/blackmagic/dv0
|
||||||
|
capture:
|
||||||
|
build: ./services/capture
|
||||||
|
profiles: [capture]
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
REDIS_URL: ${REDIS_URL}
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
S3_ENDPOINT: ${S3_ENDPOINT}
|
||||||
|
S3_BUCKET: ${S3_BUCKET}
|
||||||
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||||
|
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||||
|
CAPTURE_PORT: 3001
|
||||||
|
devices:
|
||||||
|
- ${BMD_DEVICE_0:-/dev/blackmagic/dv0}:/dev/blackmagic/dv0
|
||||||
|
- ${BMD_DEVICE_1:-/dev/blackmagic/dv1}:/dev/blackmagic/dv1
|
||||||
|
ports:
|
||||||
|
- "${CAPTURE_PORT:-7437}:3001"
|
||||||
|
networks:
|
||||||
|
- wild-dragon-worker
|
||||||
|
|
||||||
|
# worker-l4: HEAVY tier (proxy/conform/trim) on the L4 (NVENC). Talks to
|
||||||
|
# zampp1's Redis/Postgres/S3 over the LAN (.200). No promotion scanner here.
|
||||||
|
worker-l4:
|
||||||
|
build:
|
||||||
|
context: ./services/worker
|
||||||
|
dockerfile: Dockerfile.gpu
|
||||||
|
image: wild-dragon-worker-gpu:latest
|
||||||
|
runtime: nvidia
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
REDIS_URL: ${REDIS_URL}
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
S3_ENDPOINT: ${S3_ENDPOINT}
|
||||||
|
S3_BUCKET: ${S3_BUCKET}
|
||||||
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||||
|
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||||
|
S3_REGION: ${S3_REGION:-us-east-1}
|
||||||
|
WORKER_QUEUES: proxy,conform,trim
|
||||||
|
PROXY_CONCURRENCY: "3"
|
||||||
|
NVIDIA_VISIBLE_DEVICES: GPU-13acf439-8bf4-a5e0-7804-c1071bca547a
|
||||||
|
WORKER_LABEL: "zampp2 / L4"
|
||||||
|
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
|
||||||
|
networks:
|
||||||
|
- wild-dragon-worker
|
||||||
|
|
||||||
|
networks:
|
||||||
|
wild-dragon-worker:
|
||||||
|
driver: bridge
|
||||||
|
|
@ -5,6 +5,8 @@ services:
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
ports:
|
||||||
|
- "${PORT_DB:-5432}:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
- ./services/mam-api/src/db/schema.sql:/docker-entrypoint-initdb.d/001-schema.sql:ro
|
- ./services/mam-api/src/db/schema.sql:/docker-entrypoint-initdb.d/001-schema.sql:ro
|
||||||
|
|
@ -18,6 +20,8 @@ services:
|
||||||
|
|
||||||
queue:
|
queue:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "${PORT_REDIS:-6379}:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
networks:
|
networks:
|
||||||
|
|
@ -34,6 +38,14 @@ services:
|
||||||
- "${PORT_MAM_API:-7432}:3000"
|
- "${PORT_MAM_API:-7432}:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- /mnt/NVME/MAM/wild-dragon-live:/live
|
||||||
|
- /mnt/NVME/MAM/wild-dragon-growing:/growing
|
||||||
|
- /mnt/NVME/MAM/wild-dragon-media:/media
|
||||||
|
- /mnt/NVME/MAM/sdk:/sdk
|
||||||
|
- /dev/shm:/dev/shm
|
||||||
|
- /run/dbus:/run/dbus
|
||||||
|
- /run/systemd:/run/systemd
|
||||||
|
- /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
REDIS_URL: ${REDIS_URL}
|
REDIS_URL: ${REDIS_URL}
|
||||||
|
|
@ -44,7 +56,21 @@ services:
|
||||||
S3_REGION: ${S3_REGION:-us-east-1}
|
S3_REGION: ${S3_REGION:-us-east-1}
|
||||||
SESSION_SECRET: ${SESSION_SECRET}
|
SESSION_SECRET: ${SESSION_SECRET}
|
||||||
AUTH_ENABLED: ${AUTH_ENABLED:-false}
|
AUTH_ENABLED: ${AUTH_ENABLED:-false}
|
||||||
|
TRUST_PROXY: ${TRUST_PROXY:-false}
|
||||||
|
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-}
|
||||||
DOCKER_NETWORK: wild-dragon_wild-dragon
|
DOCKER_NETWORK: wild-dragon_wild-dragon
|
||||||
|
NODE_IP: ${NODE_IP}
|
||||||
|
NODE_HOSTNAME: ${NODE_HOSTNAME:-}
|
||||||
|
CAPTURE_TOKEN: ${CAPTURE_TOKEN}
|
||||||
|
PLAYOUT_IMAGE: ${PLAYOUT_IMAGE:-wild-dragon-playout:latest}
|
||||||
|
PLAYOUT_AMCP_BASE_PORT: ${PLAYOUT_AMCP_BASE_PORT:-5250}
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: all
|
||||||
|
capabilities: [gpu]
|
||||||
networks:
|
networks:
|
||||||
- wild-dragon
|
- wild-dragon
|
||||||
|
|
||||||
|
|
@ -64,11 +90,23 @@ services:
|
||||||
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||||
S3_REGION: ${S3_REGION:-us-east-1}
|
S3_REGION: ${S3_REGION:-us-east-1}
|
||||||
MAM_API_URL: ${MAM_API_URL:-http://mam-api:3000}
|
MAM_API_URL: ${MAM_API_URL:-http://mam-api:3000}
|
||||||
|
volumes:
|
||||||
|
- /mnt/NVME/MAM/wild-dragon-live:/live
|
||||||
|
- /dev/shm:/dev/shm
|
||||||
|
- /run/dbus:/run/dbus
|
||||||
|
- /run/systemd:/run/systemd
|
||||||
networks:
|
networks:
|
||||||
- wild-dragon
|
- wild-dragon
|
||||||
|
|
||||||
worker:
|
# ── GPU worker pool (capability-routed) ──────────────────────────────
|
||||||
build: ./services/worker
|
# worker-p4: HEAVY tier (proxy/conform/trim) on the Tesla P4 (NVENC).
|
||||||
|
# Also runs the promotion scanner (RUN_PROMOTION) — exactly one worker must.
|
||||||
|
worker-p4:
|
||||||
|
build:
|
||||||
|
context: ./services/worker
|
||||||
|
dockerfile: Dockerfile.gpu
|
||||||
|
image: wild-dragon-worker-gpu:latest
|
||||||
|
runtime: nvidia
|
||||||
depends_on:
|
depends_on:
|
||||||
- queue
|
- queue
|
||||||
- db
|
- db
|
||||||
|
|
@ -80,6 +118,60 @@ services:
|
||||||
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||||
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||||
S3_REGION: ${S3_REGION:-us-east-1}
|
S3_REGION: ${S3_REGION:-us-east-1}
|
||||||
|
GROWING_PATH: /growing
|
||||||
|
# Includes `import` (YouTube importer): the import queue had no consumer
|
||||||
|
# after the capability-routing split, so import jobs sat unprocessed and
|
||||||
|
# assets stayed `ingesting` forever. import is concurrency-1 + network-
|
||||||
|
# bound, so one consumer (this heavy/primary worker) is sufficient.
|
||||||
|
WORKER_QUEUES: proxy,conform,trim,import,playout-stage
|
||||||
|
RUN_PROMOTION: "true"
|
||||||
|
PROXY_CONCURRENCY: "2"
|
||||||
|
PLAYOUT_MEDIA_DIR: /media
|
||||||
|
NVIDIA_VISIBLE_DEVICES: GPU-79afca3e-2ab2-a6ea-1c44-706c1f0a26d6
|
||||||
|
WORKER_LABEL: "zampp1 / Tesla P4"
|
||||||
|
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
|
||||||
|
volumes:
|
||||||
|
- /mnt/NVME/MAM/wild-dragon-growing:/growing
|
||||||
|
- /mnt/NVME/MAM/wild-dragon-media:/media
|
||||||
|
networks:
|
||||||
|
- wild-dragon
|
||||||
|
|
||||||
|
# worker-p400a/b: LIGHT tier (thumbnail/filmstrip) on the two Quadro P400s.
|
||||||
|
worker-p400a:
|
||||||
|
image: wild-dragon-worker-gpu:latest
|
||||||
|
runtime: nvidia
|
||||||
|
depends_on: [queue, db, worker-p4]
|
||||||
|
environment:
|
||||||
|
REDIS_URL: ${REDIS_URL}
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
S3_ENDPOINT: ${S3_ENDPOINT}
|
||||||
|
S3_BUCKET: ${S3_BUCKET}
|
||||||
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||||
|
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||||
|
S3_REGION: ${S3_REGION:-us-east-1}
|
||||||
|
WORKER_QUEUES: thumbnail,filmstrip
|
||||||
|
NVIDIA_VISIBLE_DEVICES: GPU-331c53ea-2ed9-0007-e364-c1451775948f
|
||||||
|
WORKER_LABEL: "zampp1 / P400 #1"
|
||||||
|
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
|
||||||
|
networks:
|
||||||
|
- wild-dragon
|
||||||
|
|
||||||
|
worker-p400b:
|
||||||
|
image: wild-dragon-worker-gpu:latest
|
||||||
|
runtime: nvidia
|
||||||
|
depends_on: [queue, db, worker-p4]
|
||||||
|
environment:
|
||||||
|
REDIS_URL: ${REDIS_URL}
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
S3_ENDPOINT: ${S3_ENDPOINT}
|
||||||
|
S3_BUCKET: ${S3_BUCKET}
|
||||||
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||||
|
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||||
|
S3_REGION: ${S3_REGION:-us-east-1}
|
||||||
|
WORKER_QUEUES: thumbnail,filmstrip
|
||||||
|
NVIDIA_VISIBLE_DEVICES: GPU-b514a592-9077-44bd-d9e8-9efa0591ef88
|
||||||
|
WORKER_LABEL: "zampp1 / P400 #2"
|
||||||
|
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
|
||||||
networks:
|
networks:
|
||||||
- wild-dragon
|
- wild-dragon
|
||||||
|
|
||||||
|
|
@ -87,9 +179,24 @@ services:
|
||||||
build: ./services/web-ui
|
build: ./services/web-ui
|
||||||
ports:
|
ports:
|
||||||
- "${PORT_WEB_UI:-7434}:80"
|
- "${PORT_WEB_UI:-7434}:80"
|
||||||
|
volumes:
|
||||||
|
- /mnt/NVME/MAM/wild-dragon-live:/live
|
||||||
|
- /mnt/NVME/MAM/wild-dragon-media:/media:ro
|
||||||
|
- /dev/shm:/dev/shm
|
||||||
|
- /run/dbus:/run/dbus
|
||||||
|
- /run/systemd:/run/systemd
|
||||||
networks:
|
networks:
|
||||||
- wild-dragon
|
- wild-dragon
|
||||||
|
|
||||||
|
# Build-only: the CasparCG sidecar image. mam-api spawns these on-demand per
|
||||||
|
# channel (one container per playout channel), so this service is never up'd —
|
||||||
|
# it exists so `docker compose build playout` produces the image the API tags
|
||||||
|
# via PLAYOUT_IMAGE. Profile excludes it from default `up`.
|
||||||
|
playout:
|
||||||
|
profiles: ["build-only"]
|
||||||
|
build: ./services/playout
|
||||||
|
image: wild-dragon-playout:latest
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
|
|
||||||
186
docs/DESCRIPTION.md
Normal file
186
docs/DESCRIPTION.md
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
# Dragonflight · Feature Overview
|
||||||
|
|
||||||
|
Dragonflight is a self-hosted broadcast media-asset management system that replaces legacy tools like Grass Valley AMPP and FramelightX. It handles live ingest, growing-file editing, scheduling, transcoding, and asset management in a single operator-focused interface.
|
||||||
|
|
||||||
|
## Home Dashboard
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The home screen provides quick access to all major features and displays system status at a glance:
|
||||||
|
- **Library** — Browse projects, bins, and assets with hover-scrub previews
|
||||||
|
- **Recorders** — View configured capture devices and their status
|
||||||
|
- **Editor** — Timeline editor with cross-clip preview and render queue
|
||||||
|
- **Jobs** — Proxy and thumbnail queue with retry controls
|
||||||
|
- **Settings** — Configure storage, encoder, growing files, and capture SDK
|
||||||
|
- **Dashboard** — Operations view showing recent activity, job queue, and cluster health
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Features
|
||||||
|
|
||||||
|
### 1. Live Ingest & Capture
|
||||||
|
**Multi-protocol source capture with per-recorder codec settings**
|
||||||
|
|
||||||
|
Dragonflight ingests from multiple sources simultaneously:
|
||||||
|
- **SRT** (Secure Reliable Transport) — caller and listener modes
|
||||||
|
- **RTMP** — standard streaming protocol
|
||||||
|
- **SDI** — via Blackmagic DeckLink cards with FFmpeg SDK 16.x patches
|
||||||
|
|
||||||
|
Each recorder can be configured with independent codec settings:
|
||||||
|
- ProRes (hi-res masters)
|
||||||
|
- H.264 / H.265 (proxies)
|
||||||
|
- DNxHR (Avid compatibility)
|
||||||
|
|
||||||
|
Audio routing and per-source configuration ensure flexibility for multi-camera productions.
|
||||||
|
|
||||||
|
### 2. Growing-File Editing
|
||||||
|
**Live editing in Premiere Pro while capture is still writing**
|
||||||
|
|
||||||
|
Editors mount the SMB landing zone directly in Premiere Pro and edit the live master file as it's being written. The included CEP (Custom Extension Panel) provides:
|
||||||
|
- Real-time clip detection and frame-accurate trimming
|
||||||
|
- One-click relink to final S3 master after promotion
|
||||||
|
- No waiting for capture to finish before editorial begins
|
||||||
|
|
||||||
|
### 3. Recorder Scheduler
|
||||||
|
**Time-windowed recording automation**
|
||||||
|
|
||||||
|
Schedule recordings with:
|
||||||
|
- One-shot, daily, or weekly recurrence
|
||||||
|
- Automatic start/stop via 15-second tick loop
|
||||||
|
- Conflict detection across recorders
|
||||||
|
- Project and bin assignment at schedule time
|
||||||
|
|
||||||
|
### 4. Library & Asset Management
|
||||||
|
**Browse, search, and organize captured footage**
|
||||||
|
|
||||||
|
The Library screen provides:
|
||||||
|
- Project and bin hierarchy
|
||||||
|
- Asset detail view with frame-anchored persistent comments
|
||||||
|
- Right-click context menu (move-to-bin, rename, delete)
|
||||||
|
- Global cmd/ctrl-K search across assets, projects, recorders, jobs, and users
|
||||||
|
- Hover-scrub preview with HLS playback
|
||||||
|
|
||||||
|
### 5. Jobs Queue
|
||||||
|
**BullMQ-backed proxy and thumbnail generation**
|
||||||
|
|
||||||
|
Automated background processing:
|
||||||
|
- Per-job retry logic with exponential backoff
|
||||||
|
- Bulk "retry all failed" for batch recovery
|
||||||
|
- Inline error messages with actionable diagnostics
|
||||||
|
- Status tracking: ingesting → processing → ready
|
||||||
|
|
||||||
|
Proxy encoder options:
|
||||||
|
- CPU-based: libx264 (H.264)
|
||||||
|
- GPU-accelerated: NVENC (NVIDIA) or VAAPI (AMD/Intel)
|
||||||
|
|
||||||
|
### 6. Timeline Conform & Export
|
||||||
|
**FCP XML export with server-side FFmpeg rendering**
|
||||||
|
|
||||||
|
The Premiere Pro panel exports FCP XML with:
|
||||||
|
- Server-side conform via FFmpeg
|
||||||
|
- Multiple output formats: H.264, H.265, ProRes
|
||||||
|
- Resolution presets: Broadcast, Web, Archive
|
||||||
|
- Batch processing with job queue integration
|
||||||
|
|
||||||
|
### 7. Hi-Res Auto-Relink
|
||||||
|
**One-click batch relink of proxy clips to frame-accurate server-trimmed masters**
|
||||||
|
|
||||||
|
After editing on proxies:
|
||||||
|
- Select clips in Premiere
|
||||||
|
- Trigger relink from the CEP panel
|
||||||
|
- Server trims hi-res segments to exact in/out points
|
||||||
|
- Concurrent trim worker pool for speed
|
||||||
|
- 24-hour TTL with automatic cleanup
|
||||||
|
|
||||||
|
### 8. Settings & Configuration
|
||||||
|
**Centralized control for storage, encoding, and capture**
|
||||||
|
|
||||||
|
Configure:
|
||||||
|
- **S3 Storage** — endpoint, bucket, credentials (with env-var fallback)
|
||||||
|
- **Proxy Encoder** — CPU vs GPU, bitrate, resolution
|
||||||
|
- **Growing Files** — SMB path, retention, auto-promotion
|
||||||
|
- **Capture SDK** — Blackmagic, AJA, or Deltacast uploader selection
|
||||||
|
|
||||||
|
### 9. Cluster & Distributed Capture
|
||||||
|
**Primary + worker topology with remote DeckLink nodes**
|
||||||
|
|
||||||
|
- Primary node runs API, scheduler, and web UI
|
||||||
|
- Worker nodes handle proxy/thumbnail jobs
|
||||||
|
- Remote capture nodes run DeckLink cards off-host
|
||||||
|
- Heartbeat health monitoring
|
||||||
|
- Automatic failover and recovery
|
||||||
|
|
||||||
|
### 10. Admin & User Management
|
||||||
|
**Role-based access, token auth, and cluster monitoring**
|
||||||
|
|
||||||
|
- User creation and role assignment
|
||||||
|
- API token generation for integrations
|
||||||
|
- Container and cluster node status
|
||||||
|
- System health dashboard
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
SDI / SRT / RTMP ──► capture (FFmpeg)
|
||||||
|
├─ HLS preview tee ──► /live/<assetId>/index.m3u8
|
||||||
|
└─ master output
|
||||||
|
├─ growing_enabled=true:
|
||||||
|
│ /growing/<projectId>/<clip>.mov
|
||||||
|
│ (Premiere mounts SMB, edits live)
|
||||||
|
│ └─► promotion worker uploads to S3
|
||||||
|
│
|
||||||
|
└─ growing_enabled=false:
|
||||||
|
multipart stream → S3
|
||||||
|
|
||||||
|
assets POST ──► proxy job ──► worker
|
||||||
|
├─ libx264 (CPU) or NVENC/VAAPI (GPU)
|
||||||
|
├─ thumbnail job
|
||||||
|
└─ status: ingesting → processing → ready
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Runtime:** Node.js 22, Docker Compose
|
||||||
|
- **Backend:** Express, PostgreSQL 16, Redis 7 + BullMQ
|
||||||
|
- **Frontend:** Vanilla React via in-browser Babel (no bundler), hls.js
|
||||||
|
- **Media:** FFmpeg 7.1 with SDK 16 DeckLink patches
|
||||||
|
- **Codecs:** ProRes, H.264, H.265, DNxHR, MOV/MP4/MXF containers
|
||||||
|
- **Storage:** S3-compatible (RustFS) for masters, proxies, thumbnails
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
| Service | Port | Purpose |
|
||||||
|
|---------|------|---------|
|
||||||
|
| **web-ui** | 47434 | Browser SPA + capture controls |
|
||||||
|
| **mam-api** | 47432 | REST API + recorder orchestration + scheduler |
|
||||||
|
| **capture** | 47433 / 9000 / 1935 | DeckLink/SRT/RTMP ingest sidecar |
|
||||||
|
| **worker** | — | BullMQ proxy + thumbnail workers |
|
||||||
|
| **db** | 5432 | PostgreSQL 16 |
|
||||||
|
| **queue** | 6379 | Redis 7 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow Example: Live-to-Edit
|
||||||
|
|
||||||
|
1. **Operator** schedules a recording on Recorder A for 14:00–15:30, assigns to "News/Segment-A" project
|
||||||
|
2. **Capture** starts at 14:00, writes ProRes master to SMB landing zone
|
||||||
|
3. **Editor** mounts SMB in Premiere, opens the live .mov file via the CEP panel
|
||||||
|
4. **Editor** trims and marks in/out points while capture is still writing
|
||||||
|
5. **Capture** finishes at 15:30, promotion worker uploads master to S3
|
||||||
|
6. **Editor** clicks "Relink to Master" in CEP panel
|
||||||
|
7. **Server** trims hi-res segment to exact in/out, stores for 24 hours
|
||||||
|
8. **Premiere** relinks proxy clips to trimmed master
|
||||||
|
9. **Editor** exports final timeline via FCP XML conform
|
||||||
|
|
||||||
|
Total time from end of capture to relinked master: ~2 minutes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
- `deploy/api-smoke.sh` — verify every API endpoint after deploy
|
||||||
|
- `deploy/onboard-node.sh` — provision a remote worker host
|
||||||
|
- `deploy/test-cluster.sh` — primary↔worker connectivity smoke test
|
||||||
|
- `docs/GROWING_FILES_QUICKSTART.md` — Premiere CEP panel install + growing-file flow
|
||||||
99
docs/GROWING_FILES_QUICKSTART.md
Normal file
99
docs/GROWING_FILES_QUICKSTART.md
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
# Growing Files + Premiere Panel — Test Plan
|
||||||
|
|
||||||
|
A local SMB landing zone for capture so Premiere can edit the master while
|
||||||
|
it is still recording. The promotion worker uploads the finalized file to S3
|
||||||
|
and the panel relinks Premiere to the hi-res original.
|
||||||
|
|
||||||
|
## Cluster state (deployed 2026-05-22)
|
||||||
|
|
||||||
|
- TrueNAS dataset: `NVME/MAM-growing` (LZ4, 0777)
|
||||||
|
- TrueNAS SMB share: `mam-growing` → `/mnt/NVME/MAM-growing`
|
||||||
|
- Host symlink for docker compose: `/mnt/NVME/MAM/wild-dragon-growing` → the dataset
|
||||||
|
- mam-api + worker containers mount it at `/growing`
|
||||||
|
- Settings (live): `growing_enabled=true`, `growing_smb_url=smb://10.0.0.25/mam-growing`
|
||||||
|
|
||||||
|
## Capture flow (when growing_enabled=true)
|
||||||
|
|
||||||
|
1. Recorder starts. mam-api spawns a capture sidecar with `GROWING_ENABLED=true`
|
||||||
|
and binds `/mnt/NVME/MAM/wild-dragon-growing:/growing`.
|
||||||
|
2. FFmpeg writes the hi-res master directly to
|
||||||
|
`/growing/<projectId>/<clipName>.<ext>` (no S3 stream).
|
||||||
|
3. The HLS tee continues to publish `/live/<assetId>/index.m3u8`, so the
|
||||||
|
Recorders + Monitors pages get a real video preview.
|
||||||
|
4. On stop — or when the file's mtime is idle for
|
||||||
|
`growing_promote_after_seconds` — the promotion worker:
|
||||||
|
- uploads the local file to S3 at `projects/<projectId>/masters/<clipName>.<ext>`
|
||||||
|
- queues a proxy job
|
||||||
|
- flips the asset to `status=ready`
|
||||||
|
- deletes the local copy.
|
||||||
|
|
||||||
|
## Premiere panel install
|
||||||
|
|
||||||
|
Grab the latest release artifact and run it — the installer handles the file
|
||||||
|
copy, registry/plist debug-mode flip, and removes any legacy
|
||||||
|
`com.wilddragon.mam.panel` install:
|
||||||
|
|
||||||
|
- **Windows:** `dragonflight-premiere-panel-<version>-windows-setup.exe`
|
||||||
|
- **macOS / Win:** `dragonflight-premiere-panel-<version>.zxp` — install via
|
||||||
|
[Anastasiy's ZXP Installer](https://install.anastasiy.com/) (free GUI)
|
||||||
|
|
||||||
|
Releases live at
|
||||||
|
<https://forge.wilddragon.net/zgaetano/dragonflight/releases>.
|
||||||
|
|
||||||
|
Building locally (requires Windows for the `.exe`, any OS for the `.zxp`):
|
||||||
|
|
||||||
|
```
|
||||||
|
cd services/premiere-plugin/build
|
||||||
|
npm install
|
||||||
|
powershell -File build-all.ps1 # or: node build-zxp.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
The Windows installer takes care of `PlayerDebugMode`. If you installed the
|
||||||
|
ZXP and the panel does not appear in **Window → Extensions**, enable debug
|
||||||
|
mode manually:
|
||||||
|
|
||||||
|
```
|
||||||
|
# macOS
|
||||||
|
defaults write com.adobe.CSXS.11 PlayerDebugMode 1
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
reg add "HKCU\Software\Adobe\CSXS.11" /v PlayerDebugMode /t REG_SZ /d 1 /f
|
||||||
|
```
|
||||||
|
|
||||||
|
Mount the SMB share once at OS level: `smb://10.0.0.25/mam-growing`.
|
||||||
|
|
||||||
|
In Premiere: Window → Extensions → Wild Dragon MAM.
|
||||||
|
|
||||||
|
## Test the live → finalized swap
|
||||||
|
|
||||||
|
1. Start a recorder (Ingest → Recorders → Record).
|
||||||
|
2. The Recorder row's preview becomes a real HLS `<video>` element.
|
||||||
|
3. In Premiere, with the growing asset selected (status=live), click
|
||||||
|
**Mount Live**. The panel calls `GET /api/v1/assets/:id/live-path`,
|
||||||
|
resolves the SMB UNC path, and `app.project.importFiles()` it. Premiere
|
||||||
|
imports the still-growing MOV.
|
||||||
|
4. Stop the recorder. After `growing_promote_after_seconds` of mtime
|
||||||
|
inactivity, the promotion worker uploads to S3 and flips status.
|
||||||
|
5. The panel polls every 5 s. When it sees `status=ready` it surfaces
|
||||||
|
**Relink to Hi-Res** — clicking that downloads the finalized hi-res
|
||||||
|
and calls `ProjectItem.changeMediaPath()` to relink in place. Timeline
|
||||||
|
cuts are preserved.
|
||||||
|
|
||||||
|
## Knobs (Settings → Growing files (SMB))
|
||||||
|
|
||||||
|
- `growing_enabled` — master switch
|
||||||
|
- `growing_path` — container mount path (default `/growing`)
|
||||||
|
- `growing_smb_url` — what the Premiere panel hands to the editor
|
||||||
|
- `growing_promote_after_seconds` — idle threshold for promotion
|
||||||
|
|
||||||
|
## What's NOT yet here
|
||||||
|
|
||||||
|
- Auth on the SMB share — currently passwordless. Add Samba auth via
|
||||||
|
`midclt call sharing.smb.update` and put creds in the editor's keychain
|
||||||
|
before exposing this beyond the LAN.
|
||||||
|
- Concurrent S3 backup of the growing file. Today's MVP writes to SMB only;
|
||||||
|
S3 happens at promotion. If you need belt-and-suspenders, add `-f tee` in
|
||||||
|
capture-manager to fan out to both destinations.
|
||||||
|
- Cleanup for stranded files (e.g. recorder crashes mid-capture). The idle
|
||||||
|
threshold will eventually promote them, but a stale-file sweeper would be
|
||||||
|
more graceful.
|
||||||
116
docs/WORK_LOG_2026_05.md
Normal file
116
docs/WORK_LOG_2026_05.md
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
# Work Log — May 2026
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Session focused on auth system architecture, dashboard redesign, and audio track inspector. Multiple iterations on auth approach; settled on simplified local-account model with RBAC. Dashboard rebuilt as control-room status board. Audio tab completed with full metering and fader controls.
|
||||||
|
|
||||||
|
## Auth System Work
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- `002e5ac` — auth: top-to-bottom rework — local accounts, RBAC + client tag, audit log, env-bootstrap
|
||||||
|
- `d1f9557` — auth: park login flow — circle back
|
||||||
|
- `9726dbb` — Revert "auth: top-to-bottom rework..."
|
||||||
|
- `4172b0d` — rip out entire auth/login flow
|
||||||
|
|
||||||
|
### What Happened
|
||||||
|
Attempted comprehensive auth rewrite including:
|
||||||
|
- Local user account system with bcrypt hashing
|
||||||
|
- Role-based access control (admin/editor/viewer)
|
||||||
|
- Client tagging for audit trails
|
||||||
|
- Environment-based bootstrap (AUTH_ENABLED flag)
|
||||||
|
- Session management with PostgreSQL backing
|
||||||
|
|
||||||
|
**Decision**: Reverted entire auth work. Reason: complexity vs. current product stage. System was over-engineered for self-hosted use case where auth is optional.
|
||||||
|
|
||||||
|
**Current State**: Auth disabled by default (AUTH_ENABLED=false). When enabled, system returns synthetic /auth/me endpoint. No persistent user management yet.
|
||||||
|
|
||||||
|
### Related Fixes
|
||||||
|
- `cfcbec0` — fix(auth): make AUTH_ENABLED=true workable end-to-end
|
||||||
|
- `e71c330` — fix(auth): remove manual session.save() — was suppressing Set-Cookie header
|
||||||
|
- `65684aa` — fix(auth): ensure sessions table exists + log session.save errors
|
||||||
|
|
||||||
|
## Dashboard Redesign
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- `a48e1d9` — dashboard: rebuild as control-room status board (on air / up next / attention / work)
|
||||||
|
- `e5e0656` — dashboard: redesign stat cards, compress header, improve density
|
||||||
|
- `5de1e3d` — dashboard: add dense stat cards, cluster bars, job rows, sparkline fixes
|
||||||
|
- `48d54a3` — dashboard: add missing dash-* CSS classes; cluster: add stat-row/stat-card CSS
|
||||||
|
|
||||||
|
### What Changed
|
||||||
|
Transformed dashboard from generic metrics view to broadcast control-room interface:
|
||||||
|
- **On Air** section: live stream status, bitrate, duration
|
||||||
|
- **Up Next** section: queued clips/segments
|
||||||
|
- **Attention** section: warnings, errors, resource alerts
|
||||||
|
- **Work** section: active jobs, encoding progress
|
||||||
|
|
||||||
|
Added visual components:
|
||||||
|
- Dense stat cards with icon + value + trend
|
||||||
|
- Cluster health bars (CPU, memory, disk per node)
|
||||||
|
- Job progress rows with ETA
|
||||||
|
- Sparkline charts for trend visualization
|
||||||
|
|
||||||
|
CSS infrastructure added for consistent spacing/sizing across dashboard components.
|
||||||
|
|
||||||
|
## Audio Tab Implementation
|
||||||
|
|
||||||
|
### Commit
|
||||||
|
- `c48c7e6` — feat(audio-tab): full audio track inspector with meters, mute/solo, faders
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- Per-track audio meters (VU-style, real-time)
|
||||||
|
- Mute/solo buttons per track
|
||||||
|
- Fader controls (0-100 dB range)
|
||||||
|
- Master output meter
|
||||||
|
- Track naming/labeling
|
||||||
|
- Visual feedback for clipping/peaks
|
||||||
|
|
||||||
|
## Other Work
|
||||||
|
|
||||||
|
### Storage & Admin
|
||||||
|
- `64d739b` — feat(admin): unified Storage settings page with mount/bucket health diagnostics
|
||||||
|
- `a44d8bd` — feat(admin): live video-presence indicators on cluster DeckLink ports
|
||||||
|
|
||||||
|
### Player & Streaming
|
||||||
|
- `a86c1c7` — fix(player): stitch S3 ranges around RustFS empty-body bug (#143)
|
||||||
|
- `d257a19` — fix(player): buffer indicator + 416 instead of 500 on out-of-range S3
|
||||||
|
- `37247fd` — fix(video): direct S3 signed URL for streaming + proxy bitrate 1.5Mbps
|
||||||
|
- `e4d4c00` — feat(proxy): VBR 500k-1M encoding for proxy generation
|
||||||
|
|
||||||
|
### Cluster & Hardware
|
||||||
|
- `55ff2e7` — feat(cluster): full hardware breakdown per node
|
||||||
|
- `5ff507b` — fix(node-agent): use nsenter to run nvidia-smi in host mount namespace
|
||||||
|
- `558c18e` — fix(node-agent): detect GPUs via docker run --gpus all ubuntu:22.04
|
||||||
|
- `a6f045b` — fix(node-agent): probe GPU via Docker API async at startup, cache result
|
||||||
|
|
||||||
|
### Release & Cleanup
|
||||||
|
- `04ce096` — chore: 1.2 ship-prep sweep — close 38 issues
|
||||||
|
- `f0f6156` — release: add v1.1.0 ZXP artifact (Growing tab + visual system alignment)
|
||||||
|
|
||||||
|
## Blockers / Open Questions
|
||||||
|
|
||||||
|
### Auth System
|
||||||
|
- **Decision needed**: Should auth be mandatory for production? Current design assumes optional.
|
||||||
|
- **API endpoints missing**: `/users`, `/auth/me`, `/groups` routes not yet implemented in mam-api
|
||||||
|
- **Frontend expects**: Users list, groups management, role-based UI filtering
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
- Real data integration needed (currently mock data)
|
||||||
|
- Cluster stats endpoint integration
|
||||||
|
- Job queue polling/WebSocket updates
|
||||||
|
|
||||||
|
### Audio
|
||||||
|
- Backend audio processing pipeline not yet connected
|
||||||
|
- Metering data source undefined
|
||||||
|
- Fader changes need routing to encoder
|
||||||
|
|
||||||
|
## Next Steps (Recommended)
|
||||||
|
|
||||||
|
1. **Clarify auth requirements**: Is user management needed for v1.2? If yes, implement `/users` and `/groups` endpoints.
|
||||||
|
2. **Connect dashboard to live data**: Wire cluster stats, job queue, stream status to real endpoints.
|
||||||
|
3. **Audio backend integration**: Define audio processing pipeline and metering data flow.
|
||||||
|
4. **Testing**: Add integration tests for auth flow, dashboard data binding, audio control.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Session ended**: 2026-05-27 06:31 CDT
|
||||||
|
**Status**: Work logged, auth decision documented, next steps identified
|
||||||
197
docs/design/2026-05-29-all-intra-hevc-ingest.md
Normal file
197
docs/design/2026-05-29-all-intra-hevc-ingest.md
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
# All-Intra HEVC (NVENC) Growing-File Ingest
|
||||||
|
|
||||||
|
Date: 2026-05-29 | Status: design, pending validation gate (see §8)
|
||||||
|
Authors: Zac + Claude
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
Replace the CPU-bound ProRes capture encode with **All-Intra HEVC on NVENC**
|
||||||
|
as the growing-file master codec, so we can:
|
||||||
|
|
||||||
|
- **Offload ingest encode from CPU to GPU** (the current scaling wall), and
|
||||||
|
- **Keep edit-while-record** (all-intra => growing file stays editable), and
|
||||||
|
- **Scale to up to 8 simultaneous signals per machine**, across Blackmagic
|
||||||
|
today and Deltacast + AJA later.
|
||||||
|
|
||||||
|
This doc captures the target design AND the current working system it builds on,
|
||||||
|
so it is self-contained for whoever implements it.
|
||||||
|
|
||||||
|
## 2. Why this codec
|
||||||
|
|
||||||
|
Growing-file editing (Premiere/Avid mounting a still-recording file over SMB)
|
||||||
|
requires two things: **intra-frame** (every frame a keyframe, so a partial file
|
||||||
|
is decodable to the last whole frame) and a **container whose index is not
|
||||||
|
deferred to EOF**. ProRes/DNxHR satisfy this but are CPU-only (NVIDIA has no
|
||||||
|
ProRes encoder). Long-GOP H.264/HEVC/AV1 do NOT work for edit-while-record.
|
||||||
|
|
||||||
|
**All-Intra HEVC (`-g 1 -bf 0`) via `hevc_nvenc`** is the one path that is both
|
||||||
|
GPU-accelerated AND all-intra: it breaks the "ProRes must be CPU" constraint
|
||||||
|
without losing edit-while-record. Trade-off: All-Intra bitrate approaches
|
||||||
|
ProRes, so the win is **CPU offload, not storage**. AV1 is rejected (no NLE
|
||||||
|
edit support; av1_nvenc absent from our ffmpeg builds).
|
||||||
|
|
||||||
|
## 3. Current working system (what we build on)
|
||||||
|
|
||||||
|
### Topology
|
||||||
|
- **zampp1** (172.18.91.200): primary. Runs db (postgres), queue (redis),
|
||||||
|
mam-api (:47432), web-ui (:47434), and the GPU worker pool. GPUs: Tesla P4 +
|
||||||
|
2x Quadro P400. Repo at /opt/wild-dragon (its own clone).
|
||||||
|
- **zampp2** (172.18.91.216): worker/capture node. 12-vCPU QEMU VM, NVIDIA L4,
|
||||||
|
4x Blackmagic DeckLink (exposed as /dev/blackmagic/io0..io3). Runs node-agent
|
||||||
|
(:7436). Repo at /opt/wild-dragon (separate clone).
|
||||||
|
- The repo is checked out independently on BOTH nodes; node-specific files
|
||||||
|
(node-agent, capture, worker overlay) are edited on the node that runs them.
|
||||||
|
|
||||||
|
### Capture (current)
|
||||||
|
mam-api `POST /recorders/:id/start` pre-creates a `live` asset and dispatches
|
||||||
|
`POST /sidecar/start` to the recorder's node-agent, which spawns a
|
||||||
|
`wild-dragon-capture:latest` container (host network, privileged,
|
||||||
|
/dev/blackmagic bound). The capture ffmpeg:
|
||||||
|
- input: `-f decklink -i "DeckLink Duo (N)"`
|
||||||
|
- filter: `yadif` (CPU deinterlace)
|
||||||
|
- output 0 (master): `prores_ks` (CPU) -> S3 (pipe) or growing SMB file
|
||||||
|
- output 1 (preview): `libx264` veryfast HLS -> /live/{assetId} (CPU)
|
||||||
|
DeckLink does capture (cheap); BOTH encodes are CPU. ~5 vCPU per 1080i signal
|
||||||
|
=> ~2 signals saturate the 12-vCPU VM. GPUs are idle during capture.
|
||||||
|
|
||||||
|
### Stop / finalize (working)
|
||||||
|
node-agent stops the sidecar with a **180s grace** (was 10s -> SIGKILL bug).
|
||||||
|
Capture's SIGTERM handler finalises the session and calls
|
||||||
|
`POST /assets/:id/finalize` (the live asset id passed as ASSET_ID), which flips
|
||||||
|
the asset out of `live`, records duration + S3 keys, and kicks the
|
||||||
|
proxy -> thumbnail -> filmstrip chain. (Earlier 409 bug: it used to POST a new
|
||||||
|
asset and collide with the live row.)
|
||||||
|
|
||||||
|
### Live monitor (working)
|
||||||
|
SDI HLS preview is a 2nd output of the capture ffmpeg (one DeckLink read ->
|
||||||
|
split -> ProRes + H.264 HLS), written to /live/{assetId} on the capture node.
|
||||||
|
node-agent serves GET /live/* over HTTP; mam-api proxies
|
||||||
|
GET /api/v1/recorders/:id/live/* to the recorder's node-agent; the web-ui
|
||||||
|
HlsPreview loads the proxied URL. Browser auth is the session cookie
|
||||||
|
(same-origin).
|
||||||
|
|
||||||
|
### GPU worker pool (working, post-capture)
|
||||||
|
BullMQ on shared Redis; queues are type-named (proxy/thumbnail/filmstrip/
|
||||||
|
conform/trim). Workers are capability-routed by `WORKER_QUEUES`, one GPU-pinned
|
||||||
|
container per card (`NVIDIA_VISIBLE_DEVICES` by UUID):
|
||||||
|
- HEAVY (proxy/conform/trim): Tesla P4 (zampp1) + L4 (zampp2), `h264_nvenc`.
|
||||||
|
- LIGHT (thumbnail/filmstrip): 2x Quadro P400 (zampp1).
|
||||||
|
DB setting `gpu_transcode_enabled=true` + `gpu_codec=h264_nvenc` enable NVENC.
|
||||||
|
Each worker stamps `WORKER_LABEL` onto job data -> Jobs UI "Node" column.
|
||||||
|
`RUN_PROMOTION=true` on exactly one worker runs the growing-files->S3 scan.
|
||||||
|
The worker GPU image is built from services/worker/Dockerfile.gpu (CUDA base +
|
||||||
|
Ubuntu ffmpeg with h264/hevc_nvenc; NO av1_nvenc).
|
||||||
|
|
||||||
|
### Deploy gotchas (learned)
|
||||||
|
- Service source is BAKED into images; edits need rebuild + recreate (or the
|
||||||
|
GPU image rebuild reuses cached layers so only final COPY changes -> fast).
|
||||||
|
- The capture image can only build on zampp2 (DeckLink SDK present there).
|
||||||
|
- Per-node `.env`: zampp2's REDIS_URL/DATABASE_URL/S3_* now point at zampp1
|
||||||
|
(.200); secrets live only in .env, never in committed compose.
|
||||||
|
- Clear all containers on both nodes before a full redeploy (user preference).
|
||||||
|
|
||||||
|
## 4. Target design
|
||||||
|
|
||||||
|
### 4.1 Capture ffmpeg gains NVENC
|
||||||
|
The capture image's custom FFmpeg 7.1 is currently built WITHOUT nvenc (only
|
||||||
|
prores_ks/dnxhd/libx264). Rebuild `services/capture/Dockerfile` ffmpeg with:
|
||||||
|
`--enable-cuda-nvcc --enable-libnpp --enable-nvenc --enable-cuvid` plus
|
||||||
|
nv-codec-headers (ffnvcodec) installed before configure. Keep `--enable-decklink`
|
||||||
|
and the existing codecs (ProRes stays available as a selectable fallback).
|
||||||
|
Verify `ffmpeg -encoders | grep nvenc` shows hevc_nvenc/h264_nvenc afterwards.
|
||||||
|
|
||||||
|
### 4.2 Capture sidecar gets a GPU
|
||||||
|
node-agent `handleSidecarStart` currently spawns the capture container with no
|
||||||
|
GPU. Add NVIDIA runtime + device pinning to the sidecar create spec:
|
||||||
|
`HostConfig.Runtime='nvidia'` (or DeviceRequests with the node's GPU) and env
|
||||||
|
`NVIDIA_VISIBLE_DEVICES=<uuid>` + `NVIDIA_DRIVER_CAPABILITIES=video,compute,utility`.
|
||||||
|
The capture node's GPU is shared with its worker-l4 (see capacity, §5).
|
||||||
|
|
||||||
|
### 4.3 Encode parameters (master)
|
||||||
|
All-Intra HEVC on NVENC:
|
||||||
|
`-c:v hevc_nvenc -preset p4 -rc vbr -g 1 -bf 0 -profile:v main10 -pix_fmt p010le` (10-bit 4:2:2 is not NVENC-native; NVENC HEVC is 4:2:0 8/10-bit.
|
||||||
|
If 4:2:2 mezzanine is required, that is a HARD blocker for NVENC and we stay on
|
||||||
|
ProRes for those feeds — see §8). Bitrate target tuned per format (1080i59.94
|
||||||
|
~100-160 Mbps to rival ProRes HQ). `-g 1 -bf 0` => every frame IDR (all-intra).
|
||||||
|
|
||||||
|
### 4.4 Container (growing-file)
|
||||||
|
Write the master to a growing file on the SMB share (GROWING_PATH), same path
|
||||||
|
the promotion worker already uploads on EOF. Container candidates, in order of
|
||||||
|
preference for Premiere growing-file mounts:
|
||||||
|
1. **MXF OP1a** (`-f mxf`) — broadcast standard, designed for growing/edit-while-
|
||||||
|
ingest; best Avid/Premiere support. HEVC-in-MXF support in Premiere is the
|
||||||
|
key unknown to validate (§8).
|
||||||
|
2. **Fragmented MOV/MP4** (`-movflags +frag_keyframe+empty_moov+default_base_moof`)
|
||||||
|
— no moov-at-EOF, readable while growing; fallback if MXF+HEVC is unsupported.
|
||||||
|
The HLS preview path is unchanged except it can also move to h264_nvenc now that
|
||||||
|
capture has NVENC (frees the last libx264 CPU cost).
|
||||||
|
|
||||||
|
## 5. Capacity & scaling (8 signals/machine)
|
||||||
|
|
||||||
|
After the move, per-signal CPU is just: DeckLink capture + yadif + mux + frame
|
||||||
|
upload to the GPU. The heavy HEVC encode is on NVENC. The constraint shifts from
|
||||||
|
CPU to **NVENC throughput + GPU memory + PCIe/host bandwidth**:
|
||||||
|
- The **L4 is a datacenter card => unlimited NVENC sessions** (no consumer
|
||||||
|
3-session cap). 8x 1080i HEVC-I encode sessions are well within an L4.
|
||||||
|
- GPU memory: ~8 concurrent 1080 NVENC sessions + frame buffers fit in 24 GB.
|
||||||
|
- The capture node's L4 is shared between capture (per-signal HEVC-I) and the
|
||||||
|
worker-l4 proxy jobs. Under 8-signal load, give capture priority; consider
|
||||||
|
moving worker-l4 (post-record proxies) to zampp1's P4 only, or gate worker-l4
|
||||||
|
intake while signals are live.
|
||||||
|
- yadif on CPU is still ~0.5-1 vCPU/signal; consider `yadif_cuda`/`bwdif_cuda`
|
||||||
|
(GPU deinterlace) once frames are uploaded to the GPU, keeping CPU near-idle.
|
||||||
|
|
||||||
|
**Node sizing:** a 12-vCPU VM was the ProRes wall; with GPU encode the same VM
|
||||||
|
should carry many more signals, but for 8x SDI + GPU + card passthrough prefer a
|
||||||
|
larger VM or bare metal with proper PCIe passthrough. Or spread signals across
|
||||||
|
multiple capture nodes (the node-agent model already supports N nodes; mam-api
|
||||||
|
routes each recorder to its node).
|
||||||
|
|
||||||
|
## 6. Multi-vendor capture (Blackmagic / Deltacast / AJA)
|
||||||
|
|
||||||
|
Today capture is hard-wired to `-f decklink`. Before three vendors accrue
|
||||||
|
special-cases, introduce a **source-backend abstraction** in capture-manager:
|
||||||
|
each backend returns ffmpeg input args + device discovery.
|
||||||
|
- **Blackmagic**: `-f decklink -i "<name>"` (current). Devices via
|
||||||
|
`ffmpeg -sources decklink`.
|
||||||
|
- **Deltacast**: VideoMaster SDK. No native ffmpeg demuxer upstream — needs an
|
||||||
|
SDK-backed capture (their SDK -> pipe to ffmpeg, or a small grabber). Plan a
|
||||||
|
`deltacast` backend that shells their tool into ffmpeg stdin (rawvideo).
|
||||||
|
- **AJA**: libajantv2. Also no upstream ffmpeg input; AJA ships `ntv2` capture
|
||||||
|
tools. Plan an `aja` backend feeding rawvideo into ffmpeg.
|
||||||
|
All backends converge on the SAME encode/output stage (HEVC-I NVENC + HLS), so
|
||||||
|
only the input differs. node-agent already binds the right /dev nodes per
|
||||||
|
sourceType (decklink/deltacast); extend for AJA.
|
||||||
|
|
||||||
|
## 7. Risks
|
||||||
|
|
||||||
|
- **4:2:2 / 10-bit chroma:** NVENC HEVC is 4:2:0 (8/10-bit). ProRes HQ is 4:2:2
|
||||||
|
10-bit. If a workflow REQUIRES 4:2:2 mezzanine, NVENC HEVC cannot match it and
|
||||||
|
those feeds stay on ProRes (CPU). Decide per-workflow.
|
||||||
|
- **Premiere growing HEVC support:** edit-while-record for HEVC-in-MXF (or frag
|
||||||
|
MOV) is unproven in our stack — this is the make-or-break validation (§8).
|
||||||
|
- **GPU contention** between live capture and post-record proxies on the same
|
||||||
|
L4; mitigate by prioritising capture / relocating proxy load.
|
||||||
|
- **Storage:** All-Intra HEVC bitrate ~ ProRes; expect similar disk usage.
|
||||||
|
- **Editor performance:** HEVC-I decode in Premiere is heavier than ProRes on
|
||||||
|
the edit workstation (decode cost moves to the editor). Validate scrubbing.
|
||||||
|
- **NVENC quality at all-intra** vs ProRes for archival; tune bitrate/preset.
|
||||||
|
|
||||||
|
## 8. Validation gate (do FIRST, before building the pipeline)
|
||||||
|
|
||||||
|
Prove the editor story on ONE channel before wiring 8:
|
||||||
|
1. Rebuild capture ffmpeg with NVENC; give the sidecar the L4.
|
||||||
|
2. Capture one DeckLink feed to All-Intra HEVC, writing a GROWING file to the
|
||||||
|
SMB share in (a) MXF OP1a, then (b) fragmented MOV.
|
||||||
|
3. While still recording, mount it in Premiere over SMB and confirm:
|
||||||
|
edit-while-record works, scrubbing is acceptable, audio in sync, file remains
|
||||||
|
valid after stop. Pick the container that works; if neither does, HEVC-I is
|
||||||
|
capture-only (no growing edit) and we keep ProRes for growing workflows.
|
||||||
|
|
||||||
|
## 9. Rollout
|
||||||
|
1. Validation gate (§8) on one channel.
|
||||||
|
2. Make capture codec/container a recorder setting; default growing feeds to
|
||||||
|
HEVC-I NVENC, keep ProRes selectable.
|
||||||
|
3. Move HLS preview to h264_nvenc.
|
||||||
|
4. Source-backend abstraction (§6) — land before Deltacast/AJA hardware.
|
||||||
|
5. GPU deinterlace + capacity test to 8 signals; finalise node sizing.
|
||||||
BIN
docs/screenshots/01-home.png
Normal file
BIN
docs/screenshots/01-home.png
Normal file
Binary file not shown.
2246
docs/superpowers/plans/2026-05-18-nle-editor.md
Normal file
2246
docs/superpowers/plans/2026-05-18-nle-editor.md
Normal file
File diff suppressed because it is too large
Load diff
57
docs/superpowers/plans/2026-05-21-cluster-codec-revamp.md
Normal file
57
docs/superpowers/plans/2026-05-21-cluster-codec-revamp.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# Cluster Hardening + Codec Settings Revamp
|
||||||
|
|
||||||
|
> Status: **mostly shipped 2026-05-21**. One follow-up remains: the recorders.html UI rewrite. See "Pending" below.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Four user-driven asks from 2026-05-20:
|
||||||
|
1. Fix cluster page: workers were registering with docker bridge IPs, and three duplicate "zampp2" rows kept appearing.
|
||||||
|
2. Expand recorder codec settings: per-recorder control over bitrate, framerate, audio channels, container format.
|
||||||
|
3. Better DeckLink port picker: "BM1/BM2" dropdown was unusable -- diagram the card so operators pick a port visually.
|
||||||
|
4. Validate the cluster end-to-end now that GPUs are in place.
|
||||||
|
|
||||||
|
## What shipped (commit list)
|
||||||
|
| Commit | Area | Summary |
|
||||||
|
|---|---|---|
|
||||||
|
| `a39c983` | mam-api | Migration 007 -- dedupe `cluster_nodes` rows + unique index on `hostname`. |
|
||||||
|
| `049beb8` | mam-api | Migration 008 -- expanded codec columns on `recorders` (video/audio bitrate, framerate, audio channels, container, plus `node_id` / `device_index` pinning). |
|
||||||
|
| `3b4af6e` | node-agent | Prefer `NODE_IP` env override; skip docker bridge / veth / cni interfaces when auto-detecting. Version bumped to 1.1.0. |
|
||||||
|
| `0efef0d` | mam-api | `routes/cluster.js`: `pickIp()` fallback to request source IP. New `GET /api/v1/cluster/devices/blackmagic` flattens every node's DeckLink capabilities. |
|
||||||
|
| `40a66ba` | compose | `docker-compose.worker.yml`: `network_mode: host` for node-agent so it inherits host hostname + LAN IP. |
|
||||||
|
| `0ebb3cf` | deploy | `onboard-node.sh`: auto-detect host LAN IP and write `NODE_IP` + `BMD_MODEL` to `.env.worker`. |
|
||||||
|
| `f4a83ee` | capture | `capture-manager.js`: dynamic ffmpeg args. Exports `VIDEO_CODECS`, `AUDIO_CODECS`, `CONTAINER_FMT`, `CONTAINER_EXT`. |
|
||||||
|
| `485af25` | capture | `index.js` bootstrap forwards every codec env var to `captureManager.start()`. |
|
||||||
|
| `4c65753` | mam-api | `routes/recorders.js`: full codec field whitelist; `/start` passes settings to the capture sidecar. |
|
||||||
|
| `d39f86d` | web-ui | `services/web-ui/public/js/bmd-card.js` -- SVG renderer for DeckLink port selection. Models: Duo 2, Quad 2, Mini Recorder 4K, Mini Monitor 4K, UltraStudio 4K Mini. |
|
||||||
|
| `8aa3783` | deploy | `deploy/test-cluster.sh` cluster smoke test. |
|
||||||
|
| `4a3a672` | cluster | `mam-api` self-heartbeat reads `NODE_HOSTNAME` (otherwise every restart spawns a new primary row). Smoke test rewritten with `jq` after Python f-strings were found to silently false-pass the docker-bridge check. Bridge alarm narrowed to 172.17.x since this LAN occupies 172.18.0.0/16. |
|
||||||
|
|
||||||
|
## Verified cluster state (post-deploy, 2026-05-21)
|
||||||
|
```
|
||||||
|
$ MAM_API_URL=http://localhost:47432 bash deploy/test-cluster.sh
|
||||||
|
6 pass 0 fail
|
||||||
|
```
|
||||||
|
Two nodes registered, no duplicate hostnames, real LAN IPs (zampp1=172.18.91.216 primary, zampp2=172.18.91.217 worker), fresh heartbeats, 3 NVIDIA GPUs visible on zampp1, DeckLink Duo 2 reporting all 4 ports on zampp2.
|
||||||
|
|
||||||
|
## Deploy state
|
||||||
|
- **zampp1**: at `4a3a672`, rebuilt `mam-api`/`web-ui`/`worker`/`capture`, migrations 007+008 applied at startup. `.env` has `NODE_HOSTNAME=zampp1`, `NODE_IP=172.18.91.216`.
|
||||||
|
- **zampp2**: at `4a3a672`, rebuilt `node-agent` + `worker`. `.env` has `NODE_IP=172.18.91.217`, `BMD_COUNT=4`, `BMD_MODEL="DeckLink Duo 2"`, `BMD_DEVICE_0..3` populated.
|
||||||
|
- **Forgejo PAT** is at `/root/.git-credentials` on zampp1 (mode 600). Pushes from zampp1 need `HOME=/root`.
|
||||||
|
|
||||||
|
## LAN topology gotcha
|
||||||
|
The user's LAN is **172.18.91.0/24** -- inside Docker's reserved 172.16.0.0/12 range. Any heuristic that flags all of 172.16-172.31 as "docker bridge" will produce false positives. The smoke test now alarms only on 172.17.x (default docker0). The server-side `pickIp()` in `routes/cluster.js` has the same vulnerability but the node-agent's `NODE_IP` env-var override masks it in practice.
|
||||||
|
|
||||||
|
## Pending
|
||||||
|
- [ ] **`services/web-ui/public/recorders.html` rewrite.** The supporting pieces are in `main` but the HTML wiring was lost to a context-compaction event mid-session. Required UI:
|
||||||
|
- Tabbed codec settings (Video / Audio / Container) for both master and proxy.
|
||||||
|
- SDI source picker: node dropdown + inline `BMDCards.render(...)` SVG with click-to-select.
|
||||||
|
- Load BMD card data from `GET /api/v1/cluster/devices/blackmagic`.
|
||||||
|
- `<script src="js/bmd-card.js?v=1"></script>` in the head.
|
||||||
|
- SVG styles (`.bmd-card-svg`, `.bmd-port-ring`, `.bmd-port-group.is-selected`, ...) inlined or split into a CSS file.
|
||||||
|
- [ ] **Visual polish pass** with flyonui MCP -- the user noted the current UI "still looks AI-designed". Should happen AFTER the recorders.html rewrite.
|
||||||
|
|
||||||
|
## How to pick this up
|
||||||
|
1. `cd /opt/wild-dragon && git pull` on zampp1 (or zampp2).
|
||||||
|
2. Read this file end-to-end. Then `services/web-ui/public/js/bmd-card.js` (top JSDoc explains the API) and `services/capture/src/capture-manager.js` (codec catalogs).
|
||||||
|
3. Inspect `recorders.html` -- it still has the pre-revamp "BM1/BM2" dropdown and flat codec fields. Compare against the `recorders` table columns in `008-codec-settings.sql` for the full field set the UI should drive.
|
||||||
|
4. Iterate against a live deployment: `bash deploy/test-cluster.sh` for regression check, plus the actual `/recorders.html` page in a browser (web-ui on port 8080, mam-api on 47432).
|
||||||
|
5. Commit through Forgejo MCP if the diff is small; otherwise push from zampp1 (see Deploy state above for creds location). **Cloudflare WAF blocks large MCP uploads** (the blocked domain is `anthropic.com`, not Forgejo) -- pushing from a host with creds is faster for anything over ~3 KB.
|
||||||
2086
docs/superpowers/plans/2026-05-21-ui-shell-rework-wave-1-plan.md
Normal file
2086
docs/superpowers/plans/2026-05-21-ui-shell-rework-wave-1-plan.md
Normal file
File diff suppressed because it is too large
Load diff
502
docs/superpowers/plans/2026-05-21-ui-shell-rework-wave-2-plan.md
Normal file
502
docs/superpowers/plans/2026-05-21-ui-shell-rework-wave-2-plan.md
Normal file
|
|
@ -0,0 +1,502 @@
|
||||||
|
# UI Shell Rework — Wave 2 (Low-risk page migrations) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or executing-plans. Steps use `- [ ]` checkboxes.
|
||||||
|
|
||||||
|
**Goal:** Migrate the 6 lowest-risk shell pages (login, home, settings, tokens, users, containers) from `css/common.css` + bespoke per-page CSS to the new `/dist/app.css` primitive bundle from wave 1. Each page goes from "old look" to "new look" with the same functionality. Also fold in the deferred token cleanups from the wave-1 code review.
|
||||||
|
|
||||||
|
**Architecture:** Each page migration is a self-contained markup rewrite. Pattern: swap the `<link>` to `/dist/app.css`, replace the sidebar + topbar markup with `wd-*` primitives, restyle page-specific content with `wd-card-asset` / `wd-card-op` / `wd-list-row` / `wd-form-*` / `wd-btn` / etc. Preserve every existing `<script>` and `id` so JS keeps working. Deploy after each page; check.
|
||||||
|
|
||||||
|
**Tech Stack:** Tailwind+flyon-ui bundle from wave 1 (already live), nginx static, no JS changes expected.
|
||||||
|
|
||||||
|
**Reference spec:** `docs/superpowers/specs/2026-05-21-ui-shell-rework-design.md`
|
||||||
|
**Wave 1 plan:** `docs/superpowers/plans/2026-05-21-ui-shell-rework-wave-1-plan.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
**Files this wave modifies:**
|
||||||
|
|
||||||
|
```
|
||||||
|
services/web-ui/
|
||||||
|
├── public/
|
||||||
|
│ ├── login.html (REWRITE: small, no sidebar, just form)
|
||||||
|
│ ├── home.html (REWRITE: hero + stat tiles, has sidebar)
|
||||||
|
│ ├── settings.html (REWRITE: tabbed settings forms, has sidebar)
|
||||||
|
│ ├── tokens.html (REWRITE: list of tokens + create panel, has sidebar)
|
||||||
|
│ ├── users.html (REWRITE: user list + edit slide-panel, has sidebar)
|
||||||
|
│ └── containers.html (REWRITE: docker container list + logs, has sidebar)
|
||||||
|
└── src/css/components/
|
||||||
|
└── tokens.css (MODIFY: add deferred token cleanups)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files this wave does NOT touch:** the other 9 pages (index, projects, upload, jobs, api-tokens, recorders, cluster, capture, edit, editor, player). They're wave 3 / 4 / excluded.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### Task 0: Fold deferred token cleanups into tokens.css
|
||||||
|
|
||||||
|
Address items 1, 2, 4, 6 from the wave-1 code review BEFORE the page migrations multiply duplication of raw oklch values.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/web-ui/src/css/components/tokens.css`
|
||||||
|
- Modify: `services/web-ui/src/css/components/button.css` (use new tokens)
|
||||||
|
- Modify: `services/web-ui/src/css/components/card-operational.css` (use new tokens)
|
||||||
|
- Modify: `services/web-ui/src/css/components/sidebar.css`, `topbar.css`, `slide-panel.css`, `card-asset.css`, `form-controls.css`, `field-group.css`, `list-row.css`, `toast.css` (use shared --ease / --dur tokens)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Extend tokens.css with the missing tokens**
|
||||||
|
|
||||||
|
Append to `services/web-ui/src/css/components/tokens.css` inside `:root`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Hover-darker variants of accent + signals — promoted from
|
||||||
|
* inline oklch() arithmetic that was duplicated across button.css
|
||||||
|
* and card-operational.css */
|
||||||
|
--accent-hover: oklch(52% 0.20 266);
|
||||||
|
--accent-bright: oklch(70% 0.18 266);
|
||||||
|
--signal-bad-hover: oklch(68% 0.22 25);
|
||||||
|
--signal-good-hover: oklch(74% 0.18 148);
|
||||||
|
--signal-warn-hover: oklch(84% 0.16 90);
|
||||||
|
/* Pure-black-ish tinted toward brand hue for thumbnails & overlays.
|
||||||
|
* Numerically still ~black but the hue channel is set so future
|
||||||
|
* derivations stay on-brand. */
|
||||||
|
--thumb-black: oklch(0% 0 266);
|
||||||
|
--overlay: oklch(8% 0.010 266 / 0.65);
|
||||||
|
--shadow: oklch(0% 0 266 / 0.5);
|
||||||
|
|
||||||
|
/* Motion + ease tokens — promoted from raw cubic-bezier strings
|
||||||
|
* that appeared in 8 of 12 primitive files */
|
||||||
|
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
|
||||||
|
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--dur-fast: 120ms;
|
||||||
|
--dur-normal: 180ms;
|
||||||
|
--dur-slide: 240ms;
|
||||||
|
|
||||||
|
/* Z layers — promoted from sidebar/topbar where 30 was hard-coded */
|
||||||
|
--z-topbar: 30;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Find-and-replace raw oklch hover values across primitives**
|
||||||
|
|
||||||
|
For each of these files, replace the literal oklch string with the new token. Use `sed -i` for the substitutions, but verify each file afterward.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/wild-dragon/services/web-ui/src/css/components
|
||||||
|
|
||||||
|
# button.css
|
||||||
|
sed -i 's|background: oklch(52% 0.20 266);|background: var(--accent-hover);|' button.css
|
||||||
|
sed -i 's|background: oklch(68% 0.22 25);|background: var(--signal-bad-hover);|' button.css
|
||||||
|
|
||||||
|
# card-operational.css — gradient stop in signal-strip-fill
|
||||||
|
sed -i 's|oklch(70% 0.18 266)|var(--accent-bright)|' card-operational.css
|
||||||
|
|
||||||
|
# card-asset.css — pure-black thumb background
|
||||||
|
sed -i 's|background: oklch(0% 0 0);|background: var(--thumb-black);|' card-asset.css
|
||||||
|
|
||||||
|
# slide-panel.css — overlay color
|
||||||
|
sed -i 's|oklch(8% 0.010 266 / 0.65)|var(--overlay)|' slide-panel.css
|
||||||
|
|
||||||
|
# toast.css — shadow
|
||||||
|
sed -i 's|oklch(0% 0 0 / 0.7)|var(--shadow)|' toast.css
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace raw cubic-bezier strings with --ease + --dur tokens**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/wild-dragon/services/web-ui/src/css/components
|
||||||
|
# Replace exact "120ms cubic-bezier(0.25, 1, 0.5, 1)" with the tokens
|
||||||
|
for f in sidebar.css topbar.css slide-panel.css card-asset.css card-operational.css form-controls.css field-group.css list-row.css button.css; do
|
||||||
|
sed -i 's|120ms cubic-bezier(0\.25, 1, 0\.5, 1)|var(--dur-fast) var(--ease-out-quart)|g' "$f"
|
||||||
|
done
|
||||||
|
# slide-panel slide-in (240ms ease-out-expo)
|
||||||
|
sed -i 's|240ms cubic-bezier(0\.16, 1, 0\.3, 1)|var(--dur-slide) var(--ease-out-expo)|' slide-panel.css
|
||||||
|
# Tab indicator
|
||||||
|
sed -i 's|240ms cubic-bezier(0\.25, 1, 0\.5, 1)|var(--dur-slide) var(--ease-out-quart)|' slide-panel.css
|
||||||
|
sed -i 's|200ms cubic-bezier(0\.25, 1, 0\.5, 1)|200ms var(--ease-out-quart)|g' form-controls.css
|
||||||
|
sed -i 's|240ms cubic-bezier(0\.16, 1, 0\.3, 1)|var(--dur-slide) var(--ease-out-expo)|' card-operational.css
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Replace hard-coded z-index 30 with --z-topbar**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/wild-dragon/services/web-ui/src/css/components
|
||||||
|
sed -i 's|z-index: 30;|z-index: var(--z-topbar);|' topbar.css
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Rebuild + verify primitives still ship correctly**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/wild-dragon
|
||||||
|
docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
|
||||||
|
sleep 4
|
||||||
|
docker exec wild-dragon-web-ui-1 grep -c '.wd-' /usr/share/nginx/html/dist/app.css
|
||||||
|
# Expect: same large number (~116+) — no rules dropped
|
||||||
|
docker exec wild-dragon-web-ui-1 grep -c '\-\-accent-hover\|\-\-ease-out-quart\|\-\-z-topbar' /usr/share/nginx/html/dist/app.css
|
||||||
|
# Expect: at least 3 hits (tokens now defined + referenced)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Visual regression check on smoke page**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sk -o /dev/null -w 'smoke=%{http_code}/%{size_download}\n' http://localhost:47434/_primitives-smoke.html
|
||||||
|
# Expect: HTTP 200, ~12 KB (unchanged from wave 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
Manually load the smoke page in a browser; everything should look identical to wave 1. If anything changed visually, the sed substitutions introduced a regression.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit + push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/wild-dragon
|
||||||
|
HOME=/root git add services/web-ui/src/css/components/
|
||||||
|
HOME=/root git diff --cached --stat
|
||||||
|
HOME=/root git -c user.email=zgaetano@wilddragon.net -c user.name='Zac Gaetano' commit -m 'web-ui: token cleanups from wave-1 code review
|
||||||
|
|
||||||
|
- Promote --accent-hover, --signal-bad-hover, --signal-good-hover,
|
||||||
|
--signal-warn-hover, --accent-bright tokens (were duplicated raw
|
||||||
|
oklch arithmetic in button.css / card-operational.css)
|
||||||
|
- Promote --thumb-black, --overlay, --shadow tokens (tinted toward
|
||||||
|
brand hue 266 so future derivations stay on-brand)
|
||||||
|
- Promote --ease-out-quart, --ease-out-expo, --dur-fast/normal/slide
|
||||||
|
tokens (cubic-bezier strings appeared in 8 of 12 primitive files)
|
||||||
|
- Promote --z-topbar (was hard-coded 30 in topbar.css while every
|
||||||
|
other layer was tokenized)
|
||||||
|
- Replace all usages across the 12 primitive files via sed.
|
||||||
|
|
||||||
|
Bundle byte count unchanged (~138 KB); visual regression on smoke
|
||||||
|
page = zero. Code-review concerns from wave 1 now resolved before
|
||||||
|
wave 2 page migrations begin.'
|
||||||
|
HOME=/root git push 2>&1 | tail -3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Migrate login.html
|
||||||
|
|
||||||
|
Smallest page. No sidebar, no topbar — just a centered card with email/password. Migrating first because if it breaks nothing else does.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/web-ui/public/login.html`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the current page to see what's there**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/wild-dragon/services/web-ui/public
|
||||||
|
cat login.html | head -80
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: form ids, input names, any inline JS handlers. Preserve all of them.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the new login.html**
|
||||||
|
|
||||||
|
The new structure:
|
||||||
|
- `<link rel="stylesheet" href="/dist/app.css">` instead of the old `common.css`
|
||||||
|
- Centered `<main>` with a single `.wd-card-op`-shaped panel (operational card primitive, sized small)
|
||||||
|
- Inside: brand logo + "Z-AMPP" wordmark at top, then `<form>` with two `.wd-form-group` (email + password), then `.wd-btn.wd-btn--primary.wd-btn--md` submit
|
||||||
|
- Keep every existing `id`, `name`, `type`, and `<script>` tag from the old file
|
||||||
|
- If there's an "error message" div, replace its class with `.wd-toast.wd-toast--error` (inline, not floating)
|
||||||
|
|
||||||
|
Replace the entire `<head>` and `<body>` with the new shell. JS at the bottom stays as-is.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Deploy on zampp1 (no Docker rebuild needed — HTML is static)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Actually nginx serves from the image's filesystem, not the host's
|
||||||
|
# /opt/wild-dragon/services/web-ui/public/. So we DO need a rebuild.
|
||||||
|
cd /opt/wild-dragon
|
||||||
|
docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
|
||||||
|
sleep 4
|
||||||
|
curl -sk -o /dev/null -w 'login=%{http_code}/%{size_download}\n' http://localhost:47434/login.html
|
||||||
|
# Confirm new bundle is referenced
|
||||||
|
curl -sk http://localhost:47434/login.html | grep -E 'dist/app.css|common.css'
|
||||||
|
# Expect: dist/app.css present, common.css absent
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Visual + functional check**
|
||||||
|
|
||||||
|
Open `http://172.18.91.216:47434/login.html` in a browser. Verify:
|
||||||
|
- Page renders with new brand styling
|
||||||
|
- Email + password fields look like the wd-input primitive
|
||||||
|
- Submit button looks like wd-btn--primary
|
||||||
|
- Logging in still actually works (POST to /api/v1/auth/login)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit + push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HOME=/root git add services/web-ui/public/login.html
|
||||||
|
HOME=/root git commit -m 'web-ui(wave 2): migrate login.html to new primitives'
|
||||||
|
HOME=/root git push 2>&1 | tail -3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Migrate home.html
|
||||||
|
|
||||||
|
Has sidebar + topbar + dashboard stat tiles. The first page that exercises the full shell.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/web-ui/public/home.html`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the current page**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/wild-dragon/services/web-ui/public
|
||||||
|
wc -l home.html
|
||||||
|
head -80 home.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Identify: page title, what's in the topbar right side, the stat tile structure, any chart libraries, and the bottom `<script>` blocks. Preserve all script references and JS state.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Migrate the markup**
|
||||||
|
|
||||||
|
The migration recipe for every shell page:
|
||||||
|
|
||||||
|
1. **`<head>`**: replace `<link rel=stylesheet href=css/common.css>` with `<link rel=stylesheet href=/dist/app.css>`. Keep favicon, viewport meta.
|
||||||
|
2. **`<body>` root**: wrap in `<div class="wd-shell">` (style inline: `display:flex;min-height:100vh`).
|
||||||
|
3. **Sidebar**: copy verbatim from the smoke page's `<nav class="wd-sidebar">` block. Mark the active nav item with `is-active` on Home.
|
||||||
|
4. **Right column**: `<div style="flex:1;display:flex;flex-direction:column;">`
|
||||||
|
5. **Topbar**: `<header class="wd-topbar">` with breadcrumb in `.wd-topbar-left` containing just "Home", any existing right-side button as `.wd-btn.wd-btn--primary.wd-btn--sm`.
|
||||||
|
6. **Main content**: `<main style="padding:20px 20px 32px;">`
|
||||||
|
7. **Stat tiles**: replace with `.wd-card-op-grid` containing `.wd-card-op` (small, content-only — no footer needed if there's no action).
|
||||||
|
8. **Auth-guard script** at the bottom — stays exactly as-is.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Deploy + check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/wild-dragon
|
||||||
|
docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
|
||||||
|
sleep 4
|
||||||
|
curl -sk -o /dev/null -w 'home=%{http_code}/%{size_download}\n' http://localhost:47434/home.html
|
||||||
|
curl -sk http://localhost:47434/home.html | grep -c 'wd-sidebar\|wd-topbar\|wd-card'
|
||||||
|
# Expect: 8+ matches
|
||||||
|
```
|
||||||
|
|
||||||
|
Load `http://172.18.91.216:47434/home.html` in a browser. Sidebar should be the new one, breadcrumb shows "Home", stat tiles render as operational cards.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit + push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HOME=/root git add services/web-ui/public/home.html
|
||||||
|
HOME=/root git commit -m 'web-ui(wave 2): migrate home.html to new primitives'
|
||||||
|
HOME=/root git push 2>&1 | tail -3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Migrate settings.html
|
||||||
|
|
||||||
|
System settings form. Lots of form-groups, possibly tabbed.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/web-ui/public/settings.html`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read current page + identify all form sections**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/wild-dragon/services/web-ui/public
|
||||||
|
grep -E '<h[12]|form-group|form-section-label' settings.html | head -30
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Migrate using the standard recipe**
|
||||||
|
|
||||||
|
Same recipe as task 2, except for the form content:
|
||||||
|
- Replace each form section with a `.wd-field-group` (header + body, no tabs unless the section is genuinely tabbed)
|
||||||
|
- Replace every `<input>` with `class="wd-input"`, every `<select>` with `class="wd-select"`, every `<label>` with `class="wd-label"`
|
||||||
|
- Replace every `<button>` with `class="wd-btn wd-btn--primary wd-btn--md"` (or `--secondary` / `--ghost` / `--danger` as appropriate)
|
||||||
|
- Wrap rows of inputs in `.wd-form-row`
|
||||||
|
- Preserve every `id`, `name`, `type`, and JS handler
|
||||||
|
|
||||||
|
Set `.wd-nav-item.is-active` on Settings.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Deploy + check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/wild-dragon && docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
|
||||||
|
sleep 4
|
||||||
|
curl -sk -o /dev/null -w 'settings=%{http_code}/%{size_download}\n' http://localhost:47434/settings.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Load in browser. Verify the form actually saves (test by changing one value and clicking save).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit + push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HOME=/root git add services/web-ui/public/settings.html
|
||||||
|
HOME=/root git commit -m 'web-ui(wave 2): migrate settings.html to new primitives'
|
||||||
|
HOME=/root git push 2>&1 | tail -3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Migrate tokens.html
|
||||||
|
|
||||||
|
Lists API tokens, allows creation of new ones with a slide-panel. First page that exercises the slide-panel primitive in the migrated context.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/web-ui/public/tokens.html`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read + identify the slide-panel structure**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/wild-dragon/services/web-ui/public
|
||||||
|
grep -E 'slide-panel|slide-overlay|wd-list-row' tokens.html | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Migrate**
|
||||||
|
|
||||||
|
- Standard shell recipe (sidebar with `is-active` on Tokens, topbar with "Tokens" breadcrumb and "New token" primary button)
|
||||||
|
- Token list → `.wd-list` containing `.wd-list-row` for each token: name (cell--name), created date (cell--meta), badge for scope, action buttons in cell--actions
|
||||||
|
- Create-token form moves into `.wd-slide-panel` (with overlay, header, body, footer pattern exactly as in the smoke page's field-group)
|
||||||
|
- Preserve every JS handler — especially the copy-to-clipboard one for the newly-generated token
|
||||||
|
|
||||||
|
- [ ] **Step 3: Deploy + check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/wild-dragon && docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
|
||||||
|
sleep 4
|
||||||
|
curl -sk -o /dev/null -w 'tokens=%{http_code}/%{size_download}\n' http://localhost:47434/tokens.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Load + verify: clicking "New token" opens the slide-panel (codec-clipping bug fix from wave 1 applies — body should scroll if it overflows), creating a token shows the copy-once display.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit + push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HOME=/root git add services/web-ui/public/tokens.html
|
||||||
|
HOME=/root git commit -m 'web-ui(wave 2): migrate tokens.html to new primitives'
|
||||||
|
HOME=/root git push 2>&1 | tail -3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Migrate users.html
|
||||||
|
|
||||||
|
User management. List + edit slide-panel. Pattern matches tokens.html closely.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/web-ui/public/users.html`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read + identify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/wild-dragon/services/web-ui/public
|
||||||
|
grep -E '<h[12]|wd-list|slide-panel' users.html | head
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Migrate using the tokens.html recipe**
|
||||||
|
|
||||||
|
Identical pattern to task 4: shell + list + slide-panel for create/edit. Mark Users active. Preserve every JS handler (role dropdown, password reset, etc.).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Deploy + check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/wild-dragon && docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
|
||||||
|
sleep 4
|
||||||
|
curl -sk -o /dev/null -w 'users=%{http_code}/%{size_download}\n' http://localhost:47434/users.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify: list renders, edit panel opens, save works.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit + push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HOME=/root git add services/web-ui/public/users.html
|
||||||
|
HOME=/root git commit -m 'web-ui(wave 2): migrate users.html to new primitives'
|
||||||
|
HOME=/root git push 2>&1 | tail -3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Migrate containers.html
|
||||||
|
|
||||||
|
Docker container list. List rows with status badges + action buttons (logs / restart). No slide-panel (logs typically opens in a separate tab or inline).
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/web-ui/public/containers.html`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read + identify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/wild-dragon/services/web-ui/public
|
||||||
|
head -80 containers.html
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Migrate**
|
||||||
|
|
||||||
|
- Standard shell recipe (Containers active)
|
||||||
|
- Container list → `.wd-list` with `.wd-list-row` per container:
|
||||||
|
- cell--name: container name
|
||||||
|
- cell with image: cell--meta
|
||||||
|
- cell with status: `.wd-badge.wd-badge--good` (Up) / `.wd-badge--bad` (Down) / `.wd-badge--warn` (Restarting)
|
||||||
|
- cell--actions: ghost buttons for Logs / Restart / Stop
|
||||||
|
- Auto-refresh polling JS stays unchanged
|
||||||
|
|
||||||
|
- [ ] **Step 3: Deploy + check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/wild-dragon && docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
|
||||||
|
sleep 4
|
||||||
|
curl -sk -o /dev/null -w 'containers=%{http_code}/%{size_download}\n' http://localhost:47434/containers.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Load + verify: all containers visible, status badges color-coded, Logs button still opens logs.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit + push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HOME=/root git add services/web-ui/public/containers.html
|
||||||
|
HOME=/root git commit -m 'web-ui(wave 2): migrate containers.html to new primitives'
|
||||||
|
HOME=/root git push 2>&1 | tail -3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Wave-2 user QA gate
|
||||||
|
|
||||||
|
- [ ] **Step 1: Verify all 6 migrated pages serve correctly**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for p in login home settings tokens users containers; do
|
||||||
|
printf ' %s.html: HTTP=%s\n' "$p" "$(curl -sk -o /dev/null -w '%{http_code}' http://localhost:47434/$p.html)"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all 200.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify all 6 pages reference /dist/app.css and NOT common.css**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for p in login home settings tokens users containers; do
|
||||||
|
CNT_NEW=$(curl -sk http://localhost:47434/$p.html | grep -c dist/app.css)
|
||||||
|
CNT_OLD=$(curl -sk http://localhost:47434/$p.html | grep -c common.css)
|
||||||
|
printf ' %s.html: new=%s old=%s\n' "$p" "$CNT_NEW" "$CNT_OLD"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: new=1 old=0 for every page.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify wave-3 / wave-4 pages are STILL on the old CSS (no accidental change)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for p in index projects upload jobs api-tokens recorders cluster capture editor; do
|
||||||
|
CNT_OLD=$(curl -sk http://localhost:47434/$p.html | grep -c common.css)
|
||||||
|
printf ' %s.html: still-on-old=%s\n' "$p" "$CNT_OLD"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: still-on-old=1 for every page (none of them migrated yet).
|
||||||
|
|
||||||
|
- [ ] **Step 4: User visual QA**
|
||||||
|
|
||||||
|
Stop. Ask user to load each of the 6 migrated pages and confirm the new look is correct, navigation still works, forms still save, lists still poll. If anything looks wrong, fix it before wave 3 starts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes
|
||||||
|
|
||||||
|
- **Spec coverage**: Every page in the wave-2 list from the design spec is in the plan. Token cleanups from wave-1 review are folded in as task 0.
|
||||||
|
- **Placeholders**: none. Every step has the actual command / file change.
|
||||||
|
- **Type consistency**: every migrated page uses `.wd-shell` / `.wd-sidebar` / `.wd-topbar` / `.wd-nav-item.is-active` / `.wd-card-op` / `.wd-list` / `.wd-list-row` / `.wd-btn` / `.wd-input` / `.wd-select` / `.wd-label` / `.wd-form-row` / `.wd-field-group` — exact class names from the wave-1 bundle.
|
||||||
|
- **Open risk**: each page migration is a manual markup rewrite. The implementer subagent needs to actually read each existing page before rewriting, not work from the description alone, because each page has page-specific JS handlers that must be preserved verbatim.
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
# NLE Editor: React SPA Polish — Phases 1–3 Implementation Plan
|
||||||
|
|
||||||
|
> **Date:** 2026-05-24
|
||||||
|
> **Status:** Phase 1 IN PROGRESS
|
||||||
|
> **Progress:** Tasks 1.1–1.6 code-complete, pending test/deploy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Core Editor (IN PROGRESS)
|
||||||
|
|
||||||
|
### Task 1.1: Sequence API helpers in data.jsx ✅
|
||||||
|
- Added `getSequences`, `createSequence`, `getSequence`, `updateSequence`, `deleteSequence`, `syncSequenceClips`, `exportSequenceEDL` to `data.jsx`
|
||||||
|
- All exported on `window.ZAMPP_API`
|
||||||
|
|
||||||
|
### Task 1.2: Timecode.js wired into SPA ✅
|
||||||
|
- Added `<script src="js/timecode.js">` and `<script src="js/timeline.js">` to `index.html` before `screens-editor.jsx`
|
||||||
|
- `window.TC` available globally for 59.94 DF timecode math
|
||||||
|
|
||||||
|
### Task 1.3: TimelinePanel React component ✅
|
||||||
|
- `tlRef` container div in editor layout
|
||||||
|
- `useEffect` mounts `window.Timeline.init()` on first render
|
||||||
|
- `useEffect` pushes scale changes via `window.Timeline.setScale()`
|
||||||
|
- `onClipsChanged` / `onPlayheadMoved` callbacks connect timeline engine to React state
|
||||||
|
|
||||||
|
### Task 1.4: screens-editor.jsx rewrite ✅ (455 lines, was 162)
|
||||||
|
Full rewrite with:
|
||||||
|
- **App state**: `projectId`, `sequences`, `currentSeq`, `assets`, `sourceAsset`, `srcIn`/`srcOut`, `playheadFrames`, `history` (undo stack), `scale`, `tool`, `saveStatus`, `isDirty`
|
||||||
|
- **Source monitor**: `<video>` + `apiFetch('/assets/:id/stream')` + Mark In/Out + Insert button
|
||||||
|
- **Program monitor**: Virtual clip-by-clip playback — loads V1 clips sorted by `timeline_in_frames`, advances on `timeUpdate`/`ended` events
|
||||||
|
- **Media panel**: Asset list from `ZAMPP_DATA.ASSETS`, filter by bin, `AssetThumb` thumbnails, double-click loads source
|
||||||
|
- **Sequence management**: Picker `<select>`, "New sequence" modal, `openSequence()` loads via API
|
||||||
|
- **Auto-save**: `markDirty()` → debounce 2s → `syncSequenceClips()` → status updates
|
||||||
|
- **Undo/redo**: 50-step history stack, Ctrl+Z / Ctrl+Shift+Z
|
||||||
|
- **EDL export**: Button triggers `window.ZAMPP_API.exportSequenceEDL()`
|
||||||
|
- **Tool toolbar**: V/C/H buttons synced with `Timeline.setTool()`
|
||||||
|
- **Zoom slider**: Range input driving `window.Timeline.setScale()`
|
||||||
|
- **Keyboard handler**: I/O, V/C/H, Ctrl+Z/Shift+Z, Ctrl+S
|
||||||
|
|
||||||
|
### Task 1.5: "In Development" overlay removed ✅
|
||||||
|
- Deleted the `position: absolute; inset: 0; backdropFilter: blur(6px)` overlay div (was lines 98-117)
|
||||||
|
- Removed `FauxFrame` component reference
|
||||||
|
- All buttons are now functional
|
||||||
|
|
||||||
|
### Task 1.6: Editor nav badge removed ✅
|
||||||
|
- `shell.jsx`: `{ id: "editor", label: "Editor", icon: "editor" }` — no more `badge: { kind: "dev", text: "DEV" }`
|
||||||
|
|
||||||
|
### NEXT: Task 1.7 — Test & Deploy
|
||||||
|
1. `docker compose up -d --build web-ui`
|
||||||
|
2. Navigate to Editor route in browser
|
||||||
|
3. Verify: source monitor loads video, timeline renders with 4 track rows, Insert places clip, auto-save fires, EDL export downloads
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — UX Polish & Growing File (PENDING)
|
||||||
|
|
||||||
|
- [ ] 2.1 Multi-track refinements (ripple delete, snap, locking, overlap prevention)
|
||||||
|
- [ ] 2.2 Zoom slider + adaptive ruler
|
||||||
|
- [ ] 2.3 JKL transport + frame stepping
|
||||||
|
- [ ] 2.4 Waveform display on audio tracks
|
||||||
|
- [ ] 2.5 Inspector panel wiring
|
||||||
|
- [ ] 2.6 Style migration to Tailwind primitives
|
||||||
|
- [ ] 2.7 HLS live preview during capture
|
||||||
|
|
||||||
|
## Phase 3 — Export, Conform & Features (PENDING)
|
||||||
|
|
||||||
|
- [ ] 3.1 FCP XML export + conform queue
|
||||||
|
- [ ] 3.2 Hi-Res Auto-Relink
|
||||||
|
- [ ] 3.3 Timecoded Comments
|
||||||
|
- [ ] 3.4 Player Rebuild (P1)
|
||||||
|
- [ ] 3.5 Subclips (P2)
|
||||||
|
- [ ] 3.6 Multi-select & Bulk Ops (P3)
|
||||||
|
- [ ] 3.7 Smart Bins (P6)
|
||||||
|
- [ ] 3.8 Metadata Templates (P7)
|
||||||
2888
docs/superpowers/plans/2026-05-27-auth-system.md
Normal file
2888
docs/superpowers/plans/2026-05-27-auth-system.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,356 @@
|
||||||
|
# Wild Dragon MAM — Editor, Growing File Preview & Feature Expansion
|
||||||
|
**Date:** 2026-05-18
|
||||||
|
**Status:** APPROVED — ready for implementation planning
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resolved Decisions
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
|----------|----------|
|
||||||
|
| Save strategy | **Auto-save** (debounced 2s after any change) |
|
||||||
|
| Sequences per project | **Multiple named sequences** (like Premiere's project panel) |
|
||||||
|
| HLS temp disk | **3 TB available** — no constraint |
|
||||||
|
| Primary frame rate | **59.94 fps** |
|
||||||
|
| Program monitor fidelity | **Brief gap at clip boundaries is acceptable for v1** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Three sequenced phases:
|
||||||
|
|
||||||
|
1. **NLE Editor** (`editor.html`) — Premiere-style timeline editor with razor blade, in/out marking, and EDL export
|
||||||
|
2. **Growing File Workflow** — HLS live preview during SDI/SRT/RTMP capture, with rewind support
|
||||||
|
3. **Feature Additions** — Subclips, improved player, bulk ops, waveform, timecoded comments, smart bins, metadata templates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. NLE Editor
|
||||||
|
|
||||||
|
### 1.1 Layout
|
||||||
|
|
||||||
|
A new page `editor.html`. `player.html` is kept as the lightweight browse/metadata view; the editor is the full-screen creative environment.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ SOURCE MONITOR ──────┬─ PROGRAM MONITOR ──────┐ ← 40vh
|
||||||
|
│ [video preview] │ [video preview] │
|
||||||
|
│ TC: 00:00:00;00 │ TC: 00:00:00;00 │
|
||||||
|
│ [────scrub bar────] │ [────scrub bar────] │
|
||||||
|
│ [IN] [OUT] [Insert] │ [J] [K] [L] [⏩] │
|
||||||
|
├─ MEDIA PANEL ─────────┴─ TIMELINE ─────────────┤ ← 60vh
|
||||||
|
│ [sequence picker] │ [V] [C] [H] [zoom] │
|
||||||
|
│ [bin tree] │ ruler: 00:00 00:05 … │
|
||||||
|
│ [asset list] │ V1 ░░░░[clip A]░░░░ │
|
||||||
|
│ │ V2 │
|
||||||
|
│ │ A1 ░░░░[clip A]░░░░ │
|
||||||
|
│ │ A2 │
|
||||||
|
└───────────────────────┴─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Accessed from the library via an "Open in Editor" action on each asset card. Opens `editor.html?project=<id>&asset=<id>` — loads the asset into the source monitor and opens the project's most-recent sequence (or creates one named "Sequence 1" if none exists).
|
||||||
|
|
||||||
|
The sidebar nav gains an **Editor** link (between Library and Ingest).
|
||||||
|
|
||||||
|
### 1.2 Source Monitor
|
||||||
|
|
||||||
|
- Displays the currently loaded clip (double-click in media panel, or via `?asset=` param)
|
||||||
|
- Native `<video>` element with a **custom** transport bar: scrub slider, current-time / duration label, play/pause button
|
||||||
|
- **Mark In (`I`):** stores `sourceIn` as seconds; shown as left handle on the scrub bar
|
||||||
|
- **Mark Out (`O`):** stores `sourceOut` as seconds; shown as right handle on the scrub bar
|
||||||
|
- In/out range highlighted on scrub bar (accent color tint)
|
||||||
|
- **Insert:** drops marked range at timeline playhead, shifts downstream clips right
|
||||||
|
- **Overwrite:** drops marked range at timeline playhead, overwrites what's there
|
||||||
|
- No marks set → uses full clip duration
|
||||||
|
|
||||||
|
### 1.3 Program Monitor
|
||||||
|
|
||||||
|
- Plays the timeline from the current playhead position
|
||||||
|
- **Virtual playback:** single `<video>` element. On each clip start, set `src` = that clip's signed proxy URL and `currentTime` = `sourceIn`. When `currentTime` reaches `sourceOut`, load the next clip. Brief load gap at clip boundaries is acceptable for v1.
|
||||||
|
- Timecode display: `HH:MM:SS;FF` at **59.94 fps** (drop-frame notation, semicolon separator)
|
||||||
|
- Transport shortcuts: `Space` (play/pause), `J` (reverse), `K` (stop), `L` (forward), `←`/`→` (± 1 frame at 59.94), `Home` (jump to start)
|
||||||
|
|
||||||
|
### 1.4 Timeline
|
||||||
|
|
||||||
|
**Ruler:** Timecode-marked horizontal ruler. Default scale: 100px/s. Zoom: `Ctrl + scroll wheel` or zoom slider (range: 20px/s → 500px/s).
|
||||||
|
|
||||||
|
**Tracks:** V1, V2 (video), A1, A2 (audio). Each track row: 48px tall. Track header (40px wide, left): track label, lock toggle.
|
||||||
|
|
||||||
|
**Clips:** Absolutely positioned `<div>` elements within track rows.
|
||||||
|
- `left` = `timelineIn_seconds × scale`
|
||||||
|
- `width` = `(timelineOut - timelineIn)_seconds × scale`
|
||||||
|
- Shows: clip name (truncated), source TC range
|
||||||
|
- If width > 120px: asset thumbnail centered in clip body
|
||||||
|
|
||||||
|
**Playhead:** Red vertical line spanning all tracks. Draggable. Clicking the ruler jumps playhead.
|
||||||
|
|
||||||
|
**Tools:**
|
||||||
|
|
||||||
|
| Tool | Key | Behavior |
|
||||||
|
|------|-----|----------|
|
||||||
|
| Select | `V` | Click to select (accent border). Drag body to move horizontally. Drag left/right edge to trim source in/out. |
|
||||||
|
| Razor | `C` | Click on a clip → splits into two clips at that exact frame. |
|
||||||
|
| Hand | `H` | Click-drag to pan timeline horizontally. |
|
||||||
|
|
||||||
|
**Editing:**
|
||||||
|
- `Delete` / `Backspace`: remove selected clip(s)
|
||||||
|
- `Ctrl+Z` / `Ctrl+Shift+Z`: undo / redo (local history stack, max 50 steps)
|
||||||
|
- Clips cannot overlap within the same track (enforced on move/razor)
|
||||||
|
|
||||||
|
**Auto-save:** Debounced 2s after any timeline change. Visual indicator: subtle "Saving…" / "Saved" text in the timeline toolbar.
|
||||||
|
|
||||||
|
### 1.5 Data Model
|
||||||
|
|
||||||
|
**New migration: `schema_patch_editor.sql`**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE sequences (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL DEFAULT 'Sequence 1',
|
||||||
|
frame_rate NUMERIC(6,3) NOT NULL DEFAULT 59.94,
|
||||||
|
width INTEGER NOT NULL DEFAULT 1920,
|
||||||
|
height INTEGER NOT NULL DEFAULT 1080,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sequences_project_id ON sequences(project_id);
|
||||||
|
|
||||||
|
CREATE TABLE sequence_clips (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
sequence_id UUID NOT NULL REFERENCES sequences ON DELETE CASCADE,
|
||||||
|
asset_id UUID NOT NULL REFERENCES assets ON DELETE CASCADE,
|
||||||
|
track INTEGER NOT NULL DEFAULT 0,
|
||||||
|
-- track encoding: 0=V1, 1=V2, 100=A1, 101=A2
|
||||||
|
timeline_in_frames BIGINT NOT NULL,
|
||||||
|
timeline_out_frames BIGINT NOT NULL,
|
||||||
|
source_in_frames BIGINT NOT NULL DEFAULT 0,
|
||||||
|
source_out_frames BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sequence_clips_sequence_id ON sequence_clips(sequence_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frame math:** All frame counts use **59.94 fps** (= 60000/1001). Timecode display uses SMPTE drop-frame (`;` separator). Conversion helpers:
|
||||||
|
- `framesToSeconds(f)` = `f / 59.94`
|
||||||
|
- `secondsToFrames(s)` = `Math.round(s * 59.94)`
|
||||||
|
- Drop-frame TC calculation uses standard SMPTE DF algorithm
|
||||||
|
|
||||||
|
### 1.6 API Routes
|
||||||
|
|
||||||
|
New file: `services/mam-api/src/routes/sequences.js`
|
||||||
|
|
||||||
|
| Method | Path | Body / Params | Notes |
|
||||||
|
|--------|------|--------------|-------|
|
||||||
|
| GET | `/api/v1/sequences` | `?project_id=` | List sequences, ordered by `updated_at DESC` |
|
||||||
|
| POST | `/api/v1/sequences` | `{ project_id, name, frame_rate?, width?, height? }` | Create |
|
||||||
|
| GET | `/api/v1/sequences/:id` | — | Sequence + all clips joined with asset (`display_name`, `fps`, `duration_ms`, `proxy_s3_key`, `thumbnail_s3_key`) |
|
||||||
|
| PUT | `/api/v1/sequences/:id` | `{ name?, frame_rate?, width?, height? }` | Update metadata |
|
||||||
|
| DELETE | `/api/v1/sequences/:id` | — | Cascade deletes clips |
|
||||||
|
| PUT | `/api/v1/sequences/:id/clips` | `[ { asset_id, track, timeline_in_frames, timeline_out_frames, source_in_frames, source_out_frames } ]` | **Full replace** — deletes existing clips, inserts new array in one transaction |
|
||||||
|
| POST | `/api/v1/sequences/:id/export/edl` | — | Returns CMX3600 EDL as `text/plain; charset=utf-8`, `Content-Disposition: attachment` |
|
||||||
|
|
||||||
|
### 1.7 EDL Export
|
||||||
|
|
||||||
|
`generateEDL(sequenceName, clips, fps)` function produces CMX3600. Timecode math reuses the logic from `worker/src/edl/parser.js` (copied into a shared util or duplicated in the API route).
|
||||||
|
|
||||||
|
```
|
||||||
|
TITLE: My Sequence
|
||||||
|
|
||||||
|
001 clip_filename V C 00:00:00;00 00:00:05;00 00:00:00;00 00:00:05;00
|
||||||
|
002 other_clip V C 00:00:02;00 00:00:08;00 00:00:05;00 00:00:11;00
|
||||||
|
```
|
||||||
|
|
||||||
|
Conforms via the existing `conform.js` BullMQ worker unchanged.
|
||||||
|
|
||||||
|
### 1.8 `api.js` Additions
|
||||||
|
|
||||||
|
```js
|
||||||
|
getSequences(projectId)
|
||||||
|
createSequence(data)
|
||||||
|
getSequence(sequenceId)
|
||||||
|
updateSequence(sequenceId, data)
|
||||||
|
deleteSequence(sequenceId)
|
||||||
|
syncSequenceClips(sequenceId, clipsArray)
|
||||||
|
exportSequenceEDL(sequenceId) // triggers file download
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Growing File Workflow
|
||||||
|
|
||||||
|
### 2.1 Problem
|
||||||
|
|
||||||
|
During SDI capture, proxy is piped to S3 via multipart upload — invisible until `CompleteMultipartUpload`. No live preview is possible. For SRT/RTMP, no proxy exists at all until the BullMQ worker runs post-stop.
|
||||||
|
|
||||||
|
### 2.2 Solution: HLS Segments During Capture
|
||||||
|
|
||||||
|
Write proxy as HLS segments to local disk during recording. Serve them live from the capture service. On stop: stitch segments → single MP4 → upload to S3 as proxy.
|
||||||
|
|
||||||
|
**Why HLS:** Growing playlists are the browser-native live video mechanism. Accumulated segments give free rewind. `hls.js` is a small CDN-loadable polyfill for Chrome/Firefox. FFmpeg's `hls` muxer is proven in production.
|
||||||
|
|
||||||
|
### 2.3 FFmpeg Args Change
|
||||||
|
|
||||||
|
**SDI (second FFmpeg process — proxy process):**
|
||||||
|
```bash
|
||||||
|
# Before: fragmented MP4 → pipe → S3
|
||||||
|
ffmpeg [decklink] -c:v libx264 -preset fast -b:v 10M -c:a aac -b:a 192k \
|
||||||
|
-movflags +frag_keyframe+empty_moov -f mp4 pipe:1
|
||||||
|
|
||||||
|
# After: HLS segments → local dir
|
||||||
|
ffmpeg [decklink] -c:v libx264 -preset fast -b:v 10M -c:a aac -b:a 192k \
|
||||||
|
-hls_time 2 -hls_list_size 0 -hls_flags append_list \
|
||||||
|
-hls_segment_filename '$HLS_DIR/<sessionId>/seg%05d.ts' \
|
||||||
|
$HLS_DIR/<sessionId>/index.m3u8
|
||||||
|
```
|
||||||
|
|
||||||
|
**SRT/RTMP (single FFmpeg process — two output pads):**
|
||||||
|
```bash
|
||||||
|
ffmpeg [srt/rtmp input] \
|
||||||
|
-c:v prores_ks -profile:v 3 -c:a pcm_s24le \
|
||||||
|
-movflags +frag_keyframe+empty_moov -f mov pipe:1 \
|
||||||
|
-c:v libx264 -preset fast -b:v 10M -c:a aac -b:a 192k \
|
||||||
|
-hls_time 2 -hls_list_size 0 -hls_flags append_list \
|
||||||
|
-hls_segment_filename '$HLS_DIR/<sessionId>/seg%05d.ts' \
|
||||||
|
$HLS_DIR/<sessionId>/index.m3u8
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 Capture Service Changes
|
||||||
|
|
||||||
|
**`capture-manager.js`:**
|
||||||
|
- New env var: `HLS_SESSION_DIR` (default: `/tmp/wd-hls`). 3 TB disk — no constraint.
|
||||||
|
- `start()`: `mkdir -p $HLS_SESSION_DIR/<sessionId>/`, spawn FFmpeg with HLS output. Add `liveUrl: /capture/live/<sessionId>/index.m3u8` to session state.
|
||||||
|
- `stop()`:
|
||||||
|
1. `SIGINT` FFmpeg (existing)
|
||||||
|
2. Wait for process exit (FFmpeg writes final segment + closes playlist before exiting)
|
||||||
|
3. `ffmpeg -i $HLS_DIR/<sessionId>/index.m3u8 -c copy <tmp>.mp4` (stitch)
|
||||||
|
4. Upload stitched MP4 to S3 as proxy key (existing `uploadToS3` helper)
|
||||||
|
5. `rm -rf $HLS_DIR/<sessionId>/`
|
||||||
|
- Startup: scan `$HLS_SESSION_DIR/` and delete dirs older than 24h
|
||||||
|
|
||||||
|
**New capture routes:**
|
||||||
|
```
|
||||||
|
GET /capture/live/:sessionId/index.m3u8 → serve HLS playlist file
|
||||||
|
GET /capture/live/:sessionId/:file → serve .ts segment files
|
||||||
|
```
|
||||||
|
Both routes: check session exists (active or recently stopped with dir still present), stream file from disk with correct `Content-Type` (`application/vnd.apple.mpegurl` / `video/mp2t`).
|
||||||
|
|
||||||
|
**Updated `/capture/status` response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"recording": true,
|
||||||
|
"sessionId": "abc123",
|
||||||
|
"liveUrl": "/capture/live/abc123/index.m3u8",
|
||||||
|
"startedAt": "...",
|
||||||
|
"duration": 42,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**nginx:** Add location block for `/capture/live/` → proxy_pass to capture service (same pattern as existing `/capture/` block).
|
||||||
|
|
||||||
|
### 2.5 Live Preview in `capture.html`
|
||||||
|
|
||||||
|
When status poll returns `recording: true` with `liveUrl`:
|
||||||
|
1. Show collapsible **Live Preview** panel beneath capture controls
|
||||||
|
2. Load hls.js from CDN: `cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.15/hls.min.js`
|
||||||
|
3. `new Hls()` → `hls.loadSource(status.liveUrl)` → `hls.attachMedia(videoEl)`
|
||||||
|
4. `videoEl.play()` on `MANIFEST_PARSED` (plays at live edge)
|
||||||
|
5. **⏮ Rewind** button: `videoEl.currentTime = 0`
|
||||||
|
6. Elapsed time counter from `status.startedAt`
|
||||||
|
|
||||||
|
On recording stop: `hls.destroy()`, hide preview panel. Asset appears in library after proxy upload + thumbnail job complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Feature Additions (Prioritized)
|
||||||
|
|
||||||
|
### P1 — Improved Player (`player.html` rebuild)
|
||||||
|
- Migrate from old CSS variables (`--color-bg-tertiary`) to current design tokens (`--bg-panel`, etc.)
|
||||||
|
- Replace browser default controls with custom transport bar: scrub slider, timecode display `HH:MM:SS;FF` at 59.94 fps, frame-step buttons, J/K/L shortcuts
|
||||||
|
- Inline rename: click `display_name` to edit in place → auto-save
|
||||||
|
- "Open in Editor" button → `editor.html?asset=<id>`
|
||||||
|
|
||||||
|
### P2 — Subclips
|
||||||
|
- Player shows In/Out markers → "Create Subclip" button
|
||||||
|
- `POST /api/v1/assets` with `{ parent_asset_id, subclip_in_ms, subclip_out_ms, display_name }`
|
||||||
|
- New columns: `parent_asset_id UUID REFERENCES assets`, `subclip_in_ms BIGINT`, `subclip_out_ms BIGINT`
|
||||||
|
- Library shows subclip cards with ✂ badge; player pre-seeks to `subclip_in_ms`
|
||||||
|
- Subclips use parent's proxy S3 key — no re-transcode
|
||||||
|
|
||||||
|
### P3 — Multi-select & Bulk Ops
|
||||||
|
- Shift-click or checkbox (visible on hover) to select multiple asset cards
|
||||||
|
- Floating action bar: **Move to bin | Add tags | Delete**
|
||||||
|
- Move to bin: slide panel with bin tree, `PATCH /assets/:id { bin_id }` for each
|
||||||
|
- Add tags: appends to all selected assets
|
||||||
|
- Bulk delete: confirm modal → soft-delete
|
||||||
|
|
||||||
|
### P4 — Waveform Display in Editor
|
||||||
|
- In proxy worker (`proxy.js`): after transcode, run FFmpeg `astats` filter to generate per-second peak arrays → store as `waveforms/<assetId>.json` in S3
|
||||||
|
- New `waveform_s3_key TEXT` column on `assets`
|
||||||
|
- Editor timeline: fetch waveform JSON for audio track clips, render as SVG polyline within clip block
|
||||||
|
|
||||||
|
### P5 — Timecoded Comments
|
||||||
|
```sql
|
||||||
|
CREATE TABLE asset_comments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
asset_id UUID NOT NULL REFERENCES assets ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users,
|
||||||
|
timecode_seconds NUMERIC(10,3),
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
Player sidebar: "Comments" tab with "Post at current TC" button. Clickable entries seek video to that timecode.
|
||||||
|
|
||||||
|
### P6 — Smart Bins
|
||||||
|
```sql
|
||||||
|
ALTER TABLE bins ADD COLUMN is_smart BOOLEAN DEFAULT false;
|
||||||
|
ALTER TABLE bins ADD COLUMN smart_query JSONB;
|
||||||
|
-- smart_query shape: { "tags": ["interview"], "media_type": "video", "created_after": "2026-01-01" }
|
||||||
|
```
|
||||||
|
Smart bin assets: dynamically queried from `assets` table. Shows ✦ icon in bin tree.
|
||||||
|
|
||||||
|
### P7 — Metadata Templates
|
||||||
|
```sql
|
||||||
|
CREATE TABLE project_metadata_fields (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
|
||||||
|
field_key TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
field_type TEXT NOT NULL DEFAULT 'text',
|
||||||
|
options JSONB,
|
||||||
|
required BOOLEAN DEFAULT false,
|
||||||
|
sort_order INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
ALTER TABLE assets ADD COLUMN metadata JSONB DEFAULT '{}';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Sequencing
|
||||||
|
|
||||||
|
### Phase 1 — Editor
|
||||||
|
1. DB migration (`schema_patch_editor.sql`)
|
||||||
|
2. API (`sequences.js` route — CRUD + clip sync + EDL export)
|
||||||
|
3. `api.js` — sequence helpers
|
||||||
|
4. `editor.html` — 4-panel shell + CSS (sidebar nav update)
|
||||||
|
5. Timeline engine — ruler, playhead, track rows, clip rendering
|
||||||
|
6. Select tool — click-select, drag-move, drag-edge trim
|
||||||
|
7. Razor tool — click-to-split
|
||||||
|
8. Source monitor — video + transport + in/out marking + Insert/Overwrite
|
||||||
|
9. Program monitor — virtual playback + 59.94 drop-frame timecode
|
||||||
|
10. Auto-save (debounced 2s) + sequence picker in media panel
|
||||||
|
11. Library "Open in Editor" action + sidebar link
|
||||||
|
|
||||||
|
### Phase 2 — Growing File
|
||||||
|
1. Capture service: HLS output, `$HLS_SESSION_DIR` env var
|
||||||
|
2. Capture service: `/capture/live/:sessionId/` routes + nginx config
|
||||||
|
3. Capture service: on-stop stitch + S3 upload + cleanup
|
||||||
|
4. `capture.html`: live preview panel (hls.js), rewind button
|
||||||
|
|
||||||
|
### Phase 3 — Feature Additions
|
||||||
|
P1 → P2 → P3 → P4 → P5 → P6 → P7
|
||||||
198
docs/superpowers/specs/2026-05-21-ui-shell-rework-design.md
Normal file
198
docs/superpowers/specs/2026-05-21-ui-shell-rework-design.md
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
# UI Shell Rework — Design Spec
|
||||||
|
|
||||||
|
> Status: **design approved 2026-05-21**, awaiting user review of the spec before the implementation plan is written.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Wild Dragon MAM web-ui currently ships 15 static HTML pages served by nginx, sharing a single hand-written `common.css`. The token system is already strong (oklch palette, brand hue 266, 5-step depth surfaces, semantic signal tokens, 4pt spacing). What does not work is the gap between tokens and execution: cards across pages have undifferentiated spacing, generic chrome, weak hierarchy, and identical shape regardless of role. The user feedback that prompted this work was "the UI still looks AI-designed."
|
||||||
|
|
||||||
|
The rework adopts flyon-ui (Tailwind plugin) as the component primitive layer, ports the oklch palette into a custom flyon-ui theme so brand identity is preserved, and rebuilds every page that uses the standard shell against the new primitives. The personality target is *quiet pro tool* — closer to Sony Media Cloud and DaVinci Resolve than to a consumer SaaS dashboard.
|
||||||
|
|
||||||
|
## Goals & non-goals
|
||||||
|
|
||||||
|
**Goals**
|
||||||
|
- A coherent visual system across every shell page (15 pages minus 3 excluded).
|
||||||
|
- Higher information density at every screen — closer to Sony / DaVinci than to today's spacing.
|
||||||
|
- Four distinct card families so the eye reads role from shape.
|
||||||
|
- A theme port that preserves brand hue 266 and the existing oklch palette under flyon-ui.
|
||||||
|
- Accessibility floor: WCAG AA contrast, full keyboard nav, reduced-motion honored.
|
||||||
|
|
||||||
|
**Non-goals**
|
||||||
|
- Mobile UX. Phones get an explicit "desktop only" splash. Tablet gets a collapsed icon-rail sidebar but no further accommodation.
|
||||||
|
- Replacing the brand color, the font stack, or the dark theme.
|
||||||
|
- Animations beyond functional state transitions (no celebrations, no page-fade, no sound design).
|
||||||
|
- Adding new pages or features. This is purely visual / structural.
|
||||||
|
- Rebuilding `edit.html`, `editor.html`, or `player.html` (deliberately excluded — see Rollout).
|
||||||
|
|
||||||
|
## Personality, scene & color strategy
|
||||||
|
|
||||||
|
- **Register:** product (app UI, design serves the product), not brand.
|
||||||
|
- **Theme scene sentence:** "MAM operator at a 27-inch monitor in a dim control room, scanning a grid of 100+ video assets at 2am while a live recording timer runs." Forces dark, low-chroma, tabular-numeric trust signals.
|
||||||
|
- **Color strategy:** restrained. Tinted neutrals (chroma 0.010–0.015, hue 266) plus a single amber accent used in ≤10% of surfaces — for active states, recording indicators, primary CTAs, focus rings.
|
||||||
|
- **Anti-patterns explicitly banned:** glassmorphism as default, gradient text, side-stripe borders (>1px on the side of cards/rows/callouts), hero-metric SaaS template, identical card grids across roles, em dashes in copy. Cliché category-reflex check passes: this design lands as DAW / NLE / NLE-adjacent operational tool, not "dark blue observability dashboard."
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### 1. Build system & theme port
|
||||||
|
|
||||||
|
The `services/web-ui` Docker image gains a Node build stage. During `docker build`, `tailwindcss --minify` runs once, scans `public/**/*.html` for class usage, and emits `public/dist/app.[hash].css`. The runtime stage stays nginx-static — no runtime Node, longer startup, or extra moving parts.
|
||||||
|
|
||||||
|
A `tailwind.config.js` at `services/web-ui/` defines a custom flyon-ui theme that maps the existing oklch palette into flyon-ui's color slots. Brand hue 266 is preserved; the 5-step depth surfaces become Tailwind's `bg-deep` / `bg-base` / `bg-panel` / `bg-surface` / `bg-raised` / `bg-hover` utility chain. Signal tokens (`signal-good` / `signal-bad` / `signal-warn` / `signal-idle`) map directly. Spacing scale uses Tailwind's default 4pt scale, which already matches the existing `--sp-*` tokens — utilities like `p-3` and `gap-4` replace `var(--sp-3)` and `gap: var(--sp-4)`.
|
||||||
|
|
||||||
|
Fonts (Inter + JetBrains Mono) move from Google CDN to self-hosted woff2 in `public/fonts/`. The four "legacy alias" entries in the current `:root` (`--status-amber`, `--status-amber-bg`, etc.) get cleaned up during the port.
|
||||||
|
|
||||||
|
The custom theme also disables flyon-ui utility classes for the banned patterns: no `glass-*`, no `gradient-text`, no card-shadow defaults.
|
||||||
|
|
||||||
|
### 2. Sidebar
|
||||||
|
|
||||||
|
- **Dimensions:** 200px wide (down from 220px). Items 28px tall (down from ~36px), 8px horizontal padding, 4px vertical.
|
||||||
|
- **Type:** Inter 13px / 500 for items. Section labels 10px / 600 / 0.14em tracked / uppercase / `text-tertiary`.
|
||||||
|
- **Header:** 18px dragon logo + Inter 13px / 600 / -0.01em "Z-AMPP" wordmark. Total header height 48px to align with topbar.
|
||||||
|
- **Active state:** `bg-surface` background + `text-primary` text + 4px leading accent dot (8px tall, vertically centered). No side-stripe border (banned). No accent background fill.
|
||||||
|
- **Hover:** `bg-hover` fade-in over 120ms ease-out. No transform.
|
||||||
|
- **IN DEV badge** (injected by `auth-guard.js`): retained, restyled as 9px / 700 / 0.12em tracked amber pill.
|
||||||
|
- **Footer user widget:** 28px round avatar, name + role stacked, logout button reveals on row hover.
|
||||||
|
|
||||||
|
### 3. Topbar
|
||||||
|
|
||||||
|
- **Dimensions:** 48px tall. Padding 16px left / 12px right. Bottom border `border-faint`.
|
||||||
|
- **Left:** breadcrumb pattern, not flat title. Inter 13px / 500 / `text-secondary` for ancestor crumbs, 13px / 600 / `text-primary` for current. 10px chevron separator in `text-tertiary` with 8px gutters.
|
||||||
|
- **Center:** page-scoped search input on pages that have searchable content (Library, Recorders, Projects, Jobs, Cluster). 360px wide, 28px tall, leading magnifier, monospace placeholder.
|
||||||
|
- **Right:** primary CTA rightmost (28px button with 12px leading icon), 1px vertical divider, then 28px-square icon-only ghost buttons for secondary actions (filter, sort, view-toggle).
|
||||||
|
- **Sticky:** `position: sticky; top: 0; z-index: 30` inside `.main`. Sidebar does not scroll separately; topbar stays visible while content scrolls.
|
||||||
|
|
||||||
|
### 4. Card families
|
||||||
|
|
||||||
|
Four distinct card shapes, each with one clear job. Same-shape repetition is banned.
|
||||||
|
|
||||||
|
**Asset card** — Library, Projects asset grid, Recorders recording cards
|
||||||
|
- 16:9 thumbnail, full-bleed. Duration chip bottom-right (JetBrains Mono 10px, `bg-deep` 70% opacity). Comment-count chip bottom-left (when >0). Selection checkbox top-left (only on hover or when any are selected). Version badge top-right when applicable.
|
||||||
|
- Metadata: filename (Inter 13px / 500), then `{author} · {date}` row (11px / `text-tertiary`, mono numerics).
|
||||||
|
- Role pill at bottom: full-width, light tint of role color, 10px / 600 / 0.08em tracked / uppercase. Dotted-border placeholder when unset.
|
||||||
|
- 1px `border-faint`, 6px radius, `bg-panel`. Hover: thumbnail +4% brightness, border lifts to `border`. No scale, no shadow.
|
||||||
|
|
||||||
|
**Operational card** — Recorders cards, Cluster nodes, Jobs queue
|
||||||
|
- Header / content / footer rows. Wider than tall (min 8:3 ratio). 14px padding, 10px row gap.
|
||||||
|
- Header: 16px name + status pill (semantic signal tokens).
|
||||||
|
- Content: role-specific. Recorder gets live preview + signal strip + timer; cluster node gets CPU/mem mini-bars; job gets progress strip.
|
||||||
|
- Footer: 1px top hairline, metadata-left + actions-right.
|
||||||
|
- Border 1px `border-faint`, becomes 1px `accent-border` when active (recording / online / running). Color change only — no glow, no shadow, no animation.
|
||||||
|
|
||||||
|
**Inline list row** — Containers, Users, Tokens, API tokens
|
||||||
|
- Not a card. Table row with extra breathing room. 44px tall, hairline divider (`border-faint`).
|
||||||
|
- Hover: `bg-hover` row tint. Selected: `bg-surface` tint + 4px leading accent dot (same indicator language as sidebar active state).
|
||||||
|
|
||||||
|
**Empty state**
|
||||||
|
- Centered 28px line icon (`text-tertiary`), 14px / 600 title, 13px body, primary action button. No card chrome — the empty state IS the page.
|
||||||
|
- Declarative copy. No exclamation points, no emojis.
|
||||||
|
|
||||||
|
### 5. Grids
|
||||||
|
|
||||||
|
- Asset grid: `repeat(auto-fill, minmax(220px, 1fr))` with 12px gap.
|
||||||
|
- Operational grid: `repeat(auto-fill, minmax(380px, 1fr))` with 14px gap.
|
||||||
|
- Page content padding: 20px sides, 16px top, 32px bottom.
|
||||||
|
|
||||||
|
### 6. Forms, slide-panels & inputs
|
||||||
|
|
||||||
|
**Slide-panel structure (the codec-clipping bug fix codified as a primitive):**
|
||||||
|
- 460px wide. `height: 100vh; display: flex; flex-direction: column; overflow: hidden`. Header `flex-shrink: 0`. Body `flex: 1; min-height: 0; overflow-y: auto`. Footer `flex-shrink: 0; bg-deep`.
|
||||||
|
- Header 52px, 18px padding, title + close button. Bottom border `border-faint`.
|
||||||
|
- Body 18px padding, `display: flex; flex-direction: column; gap: 16px`.
|
||||||
|
|
||||||
|
**Form primitives:**
|
||||||
|
- Label: 11px / 600 / 0.08em tracked / uppercase / `text-tertiary`.
|
||||||
|
- Input / select / textarea: 32px tall, 10px horizontal padding, 13px text, 1px `border` outline, 4px radius. Focus: `accent-border` outline + 2px `accent-subtle` ring.
|
||||||
|
- Form hint: 11px / `text-tertiary` / 1.5 line-height. JetBrains Mono code spans.
|
||||||
|
- Form row: `grid-template-columns: 1fr 1fr; gap: 14px`.
|
||||||
|
|
||||||
|
**Field-group (tabbed sections, generalized from the codec-block pattern):**
|
||||||
|
- Titled header strip (36px, `bg-surface`) + tab row (32px, `bg-deep`) + tab panels (14px padding).
|
||||||
|
- Active tab: 2px `accent` bottom border, text shifts from `text-tertiary` to `accent`. Tab switches are instant — no animation.
|
||||||
|
|
||||||
|
**Buttons:**
|
||||||
|
- Sizes: `sm` 28px / `md` 32px / `lg` 36px.
|
||||||
|
- Variants: `primary` (accent bg), `secondary` (`bg-surface` + border), `ghost` (transparent + secondary text, `bg-hover` on hover), `danger` (status-red bg).
|
||||||
|
- Leading icon: 12px svg, 6px gap. Disabled: 40% opacity. Active press: 60ms `opacity: 0.85`. No gradient, no shadow, no scale.
|
||||||
|
|
||||||
|
**Toggle:** 34×18, track `bg-hover` → `accent`, 200ms ease-out on dot only.
|
||||||
|
|
||||||
|
**Date / time inputs:** native `<input type="date">` styled to match the input primitive. No third-party picker library.
|
||||||
|
|
||||||
|
### 7. States, motion & feedback
|
||||||
|
|
||||||
|
**Loading:** skeleton blocks matched to content shape. Asset grid → 12 placeholder cards with 1.8s gradient shimmer (not opacity pulse). In-card actions get inline 12px ring spinner. In-button: label replaced by spinner, width preserved.
|
||||||
|
|
||||||
|
**Empty states:** fade in 240ms on first load; instant when user-initiated.
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
- *Toast* (bottom-right, 320px): `bg-panel` + 1px `status-red` border + 4px `status-red` top strip. Auto-dismiss 4s success / 8s warning / manual error. Stack up to 3.
|
||||||
|
- *Inline*: red 11px text below offending field. No icon, no shake.
|
||||||
|
- *Page-level*: full-page card with icon + plain-English title + Retry + Get-help buttons. Never blocks the sidebar.
|
||||||
|
|
||||||
|
**Success:** "Recorder saved" toast. Affected card briefly tints (200ms `accent-subtle` background, fades back over 1.2s). One-time. No checkmark celebrations.
|
||||||
|
|
||||||
|
**Live / realtime (recording-in-progress):**
|
||||||
|
- Signal strip shimmer 1.8s ease-in-out (down from 2.4s linear).
|
||||||
|
- "LIVE" preview-stamp dot stutter pattern: bright 0.9s / dim 0.3s / bright 0.9s (broadcast tally light).
|
||||||
|
- Timer: 600 weight, `status-red` while recording. Already correct.
|
||||||
|
|
||||||
|
**Hover / focus:**
|
||||||
|
- All transitions 120ms ease-out on `border-color` and `background-color` only. Never on `width`, `height`, `transform`.
|
||||||
|
- Focus ring: 2px `accent-subtle` outline, 1px offset, `:focus-visible` only.
|
||||||
|
|
||||||
|
**Page transitions:** none. Click nav → page renders. Slide-panel keeps 240ms slide-in from right.
|
||||||
|
|
||||||
|
**Notifications:** no bell, no global status banner. Failures surface inline on affected pages.
|
||||||
|
|
||||||
|
### 8. Accessibility & responsive
|
||||||
|
|
||||||
|
**A11y floor:**
|
||||||
|
- WCAG AA contrast on every text/background pair. `text-tertiary` lightness bumped from 52% to 56% to clear AA cleanly.
|
||||||
|
- `:focus-visible` ring on every interactive element.
|
||||||
|
- Full keyboard nav. Slide-panel traps focus while open; Esc closes overlays.
|
||||||
|
- Every icon-only button gets `aria-label`. Toasts use `role="status" aria-live="polite"`.
|
||||||
|
- `prefers-reduced-motion: reduce` honored: kills shimmer / pulse / slide-in, state changes become instant.
|
||||||
|
|
||||||
|
**Responsive:**
|
||||||
|
- Desktop-first tool. *Fully supported* minimum viewport: 1280×800. Anything narrower is best-effort only.
|
||||||
|
- ≥1600px: standard layout, content max-width 1440px, centered.
|
||||||
|
- 1280–1599px: standard layout, no max-width cap.
|
||||||
|
- 768–1279px (tablet, best-effort): sidebar collapses to a 56px icon-rail, breadcrumb truncates to last crumb. Functional but not polished.
|
||||||
|
- <768px (phone): explicit "Z-AMPP is desktop-only" splash. No fake mobile experience.
|
||||||
|
|
||||||
|
**Browser support:** Chromium latest 2 versions + Safari latest 2. Firefox best-effort. No IE / legacy Edge.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
Four waves, ordered by blast radius. Each wave is its own commit / deploy / verify cycle on zampp1.
|
||||||
|
|
||||||
|
**Wave 1 — Foundation (zero user-visible change).** Build pipeline, Tailwind + flyon-ui config, theme port, primitive CSS components, shell markup migration. New primitives exist but no page uses them yet. *Validates the build system works end-to-end.*
|
||||||
|
|
||||||
|
**Wave 2 — Shell + low-risk pages.** `login.html`, `home.html`, `settings.html`, `tokens.html`, `users.html`, `containers.html`. New shell, new card / list patterns, new forms. Low risk: no live data, simple flows.
|
||||||
|
|
||||||
|
**Wave 3 — Content-heavy pages.** `index.html` (Library), `projects.html`, `upload.html`, `jobs.html`, `api-tokens.html`. Asset grid, project tree, upload queue, job queue.
|
||||||
|
|
||||||
|
**Wave 4 — Operational pages.** `recorders.html`, `cluster.html`, `capture.html`. Live data, HLS preview, signal polling, BMD picker, codec slide-panel. Done last so the primitives are battle-tested before they meet the most fragile pages.
|
||||||
|
|
||||||
|
**Excluded from rework:** `edit.html`, `editor.html` (in-development construction screen is the right treatment), `player.html` (standalone embed, no shell).
|
||||||
|
|
||||||
|
**Definition of done per page:** new shell + new primitives + AA contrast verified + keyboard-nav check + responsive at 1280/1440/1920 widths + no JS regressions (live-recording flow on recorders.html is the canary).
|
||||||
|
|
||||||
|
## Risks & mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| Tailwind build pipeline introduction breaks docker build | Wave 1 ships the build *without* migrating any page. If build fails, we revert without losing functionality. |
|
||||||
|
| Theme port loses brand hue 266 character | Custom flyon-ui theme explicitly maps existing oklch tokens; QA on wave 1 includes side-by-side color comparison vs. current. |
|
||||||
|
| Recorders rewrite (just stabilized) gets re-touched in wave 4 | Wave 4 is last on purpose — primitives are battle-tested by then. The codec-tab pattern from the recent recorders rewrite is generalized into the `.field-group` primitive in wave 1, so wave 4's recorders rewrite is mostly markup migration, not pattern reinvention. |
|
||||||
|
| Density target is too aggressive — 13px text / 28px rows feel cramped on smaller monitors | Wave 1 ships density + AA verified at 1280×800. If feedback says cramped, bump base text to 14px in a single token change. |
|
||||||
|
| Page-level skeleton loaders are extra implementation work | Acceptable cost. Spinners-only would feel cheaper than the rest of the design. |
|
||||||
|
| Native `<input type="date">` looks inconsistent across Chromium / Safari | Acceptable. Inconsistency is small; bundle weight savings of avoiding a date-picker library is real. |
|
||||||
|
|
||||||
|
## Implementation plan handoff
|
||||||
|
|
||||||
|
Once this spec is approved by the user, the next step is invoking the `superpowers:writing-plans` skill to produce a wave-by-wave implementation plan with concrete commit / deploy steps. The plan will live at `docs/superpowers/plans/2026-05-21-ui-shell-rework-plan.md` and reference this spec.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None. All seven design sections were approved by the user (Zac) during brainstorming on 2026-05-21. No placeholder values remain in this spec.
|
||||||
272
docs/superpowers/specs/2026-05-23-youtube-importer-design.md
Normal file
272
docs/superpowers/specs/2026-05-23-youtube-importer-design.md
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
# YouTube Importer — Design Spec
|
||||||
|
|
||||||
|
> Status: **design approved 2026-05-23**, awaiting user review of the spec before the implementation plan is written.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Ingest group in Dragonflight today covers file Upload, Recorders (SRT/RTMP/SDI), Capture (DeckLink), Monitors, and Schedule. There is no path to bring in media that already lives on the public web. The frequent ask is: "I want to grab a YouTube link and have it become an asset in my project, with the same proxy/thumbnail pipeline as anything else." This spec adds a YouTube importer that mirrors the existing upload flow: paste a URL, pick a project, click Import, and the asset shows up in the Library once the worker is done.
|
||||||
|
|
||||||
|
The importer rides on the existing job pipeline. After the download lands in S3, the asset re-enters the same proxy → thumbnail → ready path as a regular upload, so there is no parallel "imported asset" lifecycle to maintain.
|
||||||
|
|
||||||
|
## Goals & non-goals
|
||||||
|
|
||||||
|
**Goals**
|
||||||
|
- Paste a public YouTube URL, end up with a `ready` asset in the chosen project.
|
||||||
|
- Reuse the existing `assets` table, S3 layout, BullMQ pipeline, and Jobs screen — no parallel state machine.
|
||||||
|
- Progress visible from both the import screen (queue rows) and the Jobs screen.
|
||||||
|
- Clear, actionable errors for the obvious failure modes (private, age-gated, removed, geo-blocked, network).
|
||||||
|
|
||||||
|
**Non-goals**
|
||||||
|
- Playlists, channels, or batch-paste of multiple URLs. Single URL per submission. (Easy to add later.)
|
||||||
|
- Cookies / login. Private, members-only, and age-gated videos are out of scope v1.
|
||||||
|
- Quality picker. Always grabs best MP4 (with M4A audio merge fallback).
|
||||||
|
- Non-YouTube sources (Vimeo, Twitch VODs, Dropbox links, etc.). The route is `/imports/youtube` precisely to leave room for siblings later.
|
||||||
|
- Auto-update of yt-dlp inside the running container. Updates land via image rebuild.
|
||||||
|
- Copyright enforcement. We surface a one-line "only import videos you have rights to use" note and stop there.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The importer threads through four existing layers:
|
||||||
|
|
||||||
|
```
|
||||||
|
[web-ui] YouTube screen ──POST /imports/youtube──▶ [mam-api]
|
||||||
|
│
|
||||||
|
assets row (status='ingesting')
|
||||||
|
jobs row (type='youtube_import')
|
||||||
|
│
|
||||||
|
BullMQ "import" queue
|
||||||
|
▼
|
||||||
|
[worker]
|
||||||
|
yt-dlp download → S3 originals/
|
||||||
|
ffprobe metadata → assets row
|
||||||
|
status='processing'
|
||||||
|
│
|
||||||
|
BullMQ "proxy" queue ◀── existing path
|
||||||
|
▼
|
||||||
|
proxy → thumbnail → ready
|
||||||
|
```
|
||||||
|
|
||||||
|
Once the worker hands off to the `proxy` queue, the asset is indistinguishable from one that came through Upload — same proxy worker, same thumbnail worker, same Library list.
|
||||||
|
|
||||||
|
## 1. UX
|
||||||
|
|
||||||
|
### Nav
|
||||||
|
|
||||||
|
A 6th child is added to the **Ingest** group in `shell.jsx`, between Upload and Recorders:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{ id: "youtube", label: "YouTube", icon: "download" },
|
||||||
|
```
|
||||||
|
|
||||||
|
The `download` glyph already exists in `icons.jsx`. The matching `ingestChildren` array in `shell.jsx` and the crumbs map in `app.jsx` both gain `"youtube"`.
|
||||||
|
|
||||||
|
### Screen
|
||||||
|
|
||||||
|
A new `YouTubeImport` component lives in `screens-ingest.jsx` and is exported on `window` alongside `Upload`, `Recorders`, etc. It is registered as a route in `app.jsx`.
|
||||||
|
|
||||||
|
Layout — visually a sibling of the Upload screen:
|
||||||
|
|
||||||
|
- **Header**: title "YouTube", subtitle "Paste a link — we download and import the best available MP4."
|
||||||
|
- **Project selector**: same `select` element as Upload's, pre-selected to the first project.
|
||||||
|
- **URL input**: a single-line `field-input` with placeholder "Paste a YouTube URL (youtube.com/watch, youtu.be, or shorts)…" and an inline **Import** button. Enter submits. The button is disabled until a URL pattern matches.
|
||||||
|
- **Subtitle line under the input**: "Only import videos you have rights to use. Private, age-gated, and members-only videos are not supported."
|
||||||
|
- **Queue panel**: identical structure to Upload's queue — one row per submitted URL, showing:
|
||||||
|
- Source icon (use `link` glyph) and the URL (truncated middle, full URL in `title` tooltip).
|
||||||
|
- Title once known (filled in by a poll on the asset row).
|
||||||
|
- Progress bar tied to job `progress` (0–100). The worker drives this between 5 and 60 % for download and 60 to 100 % for upload + DB writes.
|
||||||
|
- Status pill: queued → downloading → processing → done / failed.
|
||||||
|
- Error text if the job fails (red, one line).
|
||||||
|
- A "Clear done" button at the top of the queue.
|
||||||
|
|
||||||
|
The queue persists for the session in component state only — no separate UI table. Jobs screen remains the canonical history.
|
||||||
|
|
||||||
|
### URL validation (client-side, before POST)
|
||||||
|
|
||||||
|
Accept (case-insensitive) any of these patterns:
|
||||||
|
- `https?://(www\.|m\.)?youtube\.com/watch\?[^ ]*v=[A-Za-z0-9_-]{11}`
|
||||||
|
- `https?://youtu\.be/[A-Za-z0-9_-]{11}`
|
||||||
|
- `https?://(www\.)?youtube\.com/shorts/[A-Za-z0-9_-]{11}`
|
||||||
|
|
||||||
|
Anything else is rejected inline ("That doesn't look like a YouTube URL") without an API call. The server re-validates as a defense-in-depth check.
|
||||||
|
|
||||||
|
### Out-of-scope v1 (called out, not built)
|
||||||
|
|
||||||
|
- Pasting a playlist URL. Server returns 400 "Playlists aren't supported yet."
|
||||||
|
- Multi-line paste. Single URL only.
|
||||||
|
- Quality picker. yt-dlp format string is hard-coded.
|
||||||
|
- Cookies upload. Private videos fail with a clear message.
|
||||||
|
|
||||||
|
## 2. API
|
||||||
|
|
||||||
|
### Route
|
||||||
|
|
||||||
|
New file `services/mam-api/src/routes/imports.js`, mounted at `/api/v1/imports` in `services/mam-api/src/index.js`.
|
||||||
|
|
||||||
|
**`POST /api/v1/imports/youtube`**
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{ "url": "https://youtu.be/dQw4w9WgXcQ", "projectId": "uuid", "binId": "uuid?" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
1. Validate `url` against the same three regexes as the client. 400 on miss.
|
||||||
|
2. Reject playlist URLs (URL contains `list=`) with 400 "Playlists aren't supported yet."
|
||||||
|
3. Generate `assetId = uuidv4()`.
|
||||||
|
4. Insert into `assets` with:
|
||||||
|
- `status='ingesting'`
|
||||||
|
- `media_type='video'`
|
||||||
|
- `filename = url` (placeholder; worker overwrites with the sanitized title once yt-dlp prints metadata — keeps the row queryable in the meantime)
|
||||||
|
- `display_name = url` (same; worker overwrites)
|
||||||
|
- `original_s3_key = NULL` (worker fills in)
|
||||||
|
- `source_url = url` (new column — see Schema)
|
||||||
|
- `project_id`, `bin_id`, timestamps.
|
||||||
|
5. Insert into `jobs` with `type='youtube_import'`, `asset_id`, `payload={ url }`, `status='queued'`, `progress=0`.
|
||||||
|
6. Enqueue BullMQ job on the `import` queue:
|
||||||
|
```js
|
||||||
|
await importQueue.add('youtube', { assetId, url });
|
||||||
|
```
|
||||||
|
7. Respond `200 { assetId, jobId }`.
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
- Missing fields → 400.
|
||||||
|
- Bad URL → 400 with `error: 'Invalid YouTube URL'`.
|
||||||
|
- Playlist URL → 400 with `error: 'Playlists aren't supported yet'`.
|
||||||
|
- Project not found → 404.
|
||||||
|
- DB / queue failure → 500 (next(err)).
|
||||||
|
|
||||||
|
### Jobs screen integration
|
||||||
|
|
||||||
|
`services/web-ui/public/screens-jobs.jsx` already normalizes job types via a `kindMap`. Add one entry:
|
||||||
|
```js
|
||||||
|
const kindMap = { proxy: 'Proxy', thumbnail: 'Thumbnail', conform: 'Conform', transcode: 'Transcode', youtube_import: 'YouTube' };
|
||||||
|
```
|
||||||
|
Retry, delete, and the SSE event stream all work for the new type with no further changes because they key off `job.id`, not `job.type`.
|
||||||
|
|
||||||
|
## 3. Worker
|
||||||
|
|
||||||
|
### Container changes
|
||||||
|
|
||||||
|
`services/worker/Dockerfile` gains two packages:
|
||||||
|
```dockerfile
|
||||||
|
RUN apk add --no-cache ffmpeg yt-dlp python3
|
||||||
|
```
|
||||||
|
`yt-dlp` is in the Alpine `community` repo and pulls `python3` as a runtime dep — we list it explicitly for clarity. Image grows by ~25 MB.
|
||||||
|
|
||||||
|
### New worker
|
||||||
|
|
||||||
|
`services/worker/src/workers/youtube-import.js`, registered in `services/worker/src/index.js`:
|
||||||
|
```js
|
||||||
|
const workers = [
|
||||||
|
createWorker('proxy', proxyWorker),
|
||||||
|
createWorker('thumbnail', thumbnailWorker),
|
||||||
|
createWorker('conform', conformWorker),
|
||||||
|
createWorker('import', youtubeImportWorker),
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Job handler
|
||||||
|
|
||||||
|
For a job with `{ assetId, url }`:
|
||||||
|
|
||||||
|
1. `job.updateProgress(2)` — accepted.
|
||||||
|
2. Build a temp directory `tmpdir()/yt-${jobId}`.
|
||||||
|
3. Run yt-dlp:
|
||||||
|
```sh
|
||||||
|
yt-dlp \
|
||||||
|
--no-playlist \
|
||||||
|
--no-warnings \
|
||||||
|
--restrict-filenames \
|
||||||
|
-f "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b" \
|
||||||
|
--merge-output-format mp4 \
|
||||||
|
--print-json \
|
||||||
|
--newline \
|
||||||
|
-o "<tmpdir>/<assetId>.%(ext)s" \
|
||||||
|
"<url>"
|
||||||
|
```
|
||||||
|
- `--print-json` writes one JSON line at the end with title, duration, width, height, uploader, etc.
|
||||||
|
- `--newline` makes progress lines newline-terminated so we can parse them.
|
||||||
|
- `--restrict-filenames` prevents shell-special characters in temp paths.
|
||||||
|
4. Stream stdout line-by-line. Lines matching `r'\[download\]\s+(\d+(\.\d+)?)%'` map to `job.updateProgress(5 + Math.floor(pct * 0.55))` so download takes us from 5 to 60 %.
|
||||||
|
5. On yt-dlp non-zero exit: parse stderr for the first line containing `ERROR:` and use it as the job's error message. Mark the asset `status='error'`, mark the job failed, throw so BullMQ records it. Surface a friendly substitution for the common cases:
|
||||||
|
- "Private video" → "Private video — not supported."
|
||||||
|
- "Sign in to confirm your age" → "Age-restricted video — not supported."
|
||||||
|
- "Video unavailable" → "Video unavailable or removed."
|
||||||
|
- "This video is not available in your country" → "Video is geo-blocked from this region."
|
||||||
|
- HTTP 429 → "YouTube rate-limited the importer — try again later."
|
||||||
|
- Anything else → use yt-dlp's stderr line verbatim, truncated to 300 chars.
|
||||||
|
6. Parse the last stdout line as JSON to read metadata. The resulting file is `<tmpdir>/<assetId>.mp4`.
|
||||||
|
7. `getMediaInfo` (existing helper in `services/worker/src/ffmpeg/executor.js`) on that path. Use ffprobe's values for codec/fps/duration when yt-dlp's are missing or wrong.
|
||||||
|
8. Sanitize the title for the S3 filename: keep `[A-Za-z0-9 ._-]`, collapse runs of whitespace, trim, cap at 120 chars, append `.mp4`. If the sanitized title is empty, fall back to `youtube-<videoId>.mp4`.
|
||||||
|
9. Upload to `originals/{assetId}/{sanitized-title}.mp4` via the existing `uploadToS3` helper. Progress 60 → 90 %.
|
||||||
|
10. UPDATE the assets row with:
|
||||||
|
- `filename = <sanitized title>.mp4`
|
||||||
|
- `display_name = <yt-dlp title untouched>`
|
||||||
|
- `original_s3_key = originals/<assetId>/<sanitized-title>.mp4`
|
||||||
|
- `codec`, `resolution`, `fps`, `duration_ms`, `file_size` from ffprobe.
|
||||||
|
- `status = 'processing'`
|
||||||
|
- `updated_at = NOW()`
|
||||||
|
11. Enqueue a `proxy` job on the existing `proxy` queue with the same payload shape `upload.js` uses:
|
||||||
|
```js
|
||||||
|
await proxyQueue.add('generate', {
|
||||||
|
assetId,
|
||||||
|
inputKey: asset.original_s3_key,
|
||||||
|
outputKey: `proxies/${assetId}.mp4`,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
12. `job.updateProgress(100)`. Return — BullMQ marks the import job done. The proxy job picks up the rest exactly like a regular upload.
|
||||||
|
13. Always `rm -rf` the temp directory in a `finally`.
|
||||||
|
|
||||||
|
### Concurrency & retries
|
||||||
|
|
||||||
|
- Default BullMQ concurrency for this queue: **1** per worker process. Two simultaneous yt-dlp invocations risk YouTube rate-limiting more than they help throughput. Configurable later via env if needed.
|
||||||
|
- No automatic BullMQ retry — yt-dlp failures are almost always permanent (private, geo, removed) and a silent retry storm would chew through quota. The Jobs screen's manual Retry button is the right knob for "this should be transient" cases.
|
||||||
|
|
||||||
|
## 4. Schema migration
|
||||||
|
|
||||||
|
New file `services/mam-api/src/db/migrations/011-youtube-import.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1. Add the new job type to the enum.
|
||||||
|
-- Postgres requires ALTER TYPE ... ADD VALUE for enum changes.
|
||||||
|
ALTER TYPE job_type ADD VALUE IF NOT EXISTS 'youtube_import';
|
||||||
|
|
||||||
|
-- 2. Remember where an asset came from. NULL for everything that
|
||||||
|
-- pre-dates the importer; populated for any imported asset.
|
||||||
|
ALTER TABLE assets ADD COLUMN IF NOT EXISTS source_url TEXT;
|
||||||
|
```
|
||||||
|
|
||||||
|
`source_url` is exposed on the asset drawer as a "Source" line ("imported from youtu.be/…") in a follow-up PR — out of scope for this spec, but worth noting that the column exists for it.
|
||||||
|
|
||||||
|
## 5. Files touched
|
||||||
|
|
||||||
|
**New**
|
||||||
|
- `services/mam-api/src/routes/imports.js`
|
||||||
|
- `services/mam-api/src/db/migrations/011-youtube-import.sql`
|
||||||
|
- `services/worker/src/workers/youtube-import.js`
|
||||||
|
|
||||||
|
**Edited**
|
||||||
|
- `services/mam-api/src/index.js` — mount the new route.
|
||||||
|
- `services/web-ui/public/screens-ingest.jsx` — add `YouTubeImport`, export on `window`.
|
||||||
|
- `services/web-ui/public/shell.jsx` — add the nav child, extend `ingestChildren`.
|
||||||
|
- `services/web-ui/public/app.jsx` — register the route and the crumb.
|
||||||
|
- `services/web-ui/public/screens-jobs.jsx` — extend `kindMap` with `youtube_import: 'YouTube'`.
|
||||||
|
- `services/worker/src/index.js` — register the `import` queue worker.
|
||||||
|
- `services/worker/Dockerfile` — add `yt-dlp` and `python3` to the apk install line.
|
||||||
|
|
||||||
|
## 6. Risks & trade-offs
|
||||||
|
|
||||||
|
- **Worker egress**. The worker container needs outbound HTTPS to YouTube. Fine in the current homelab; will fail in a locked-down cluster. Documented in the implementation plan.
|
||||||
|
- **yt-dlp drift**. YouTube changes break old yt-dlp versions every few weeks. The Alpine package lags upstream by days. Fix is to rebuild the worker image. We do not auto-update inside the running container — too risky for an offline / locked-down deploy. If imports start failing en masse, the runbook is `docker compose build worker && docker compose up -d worker`.
|
||||||
|
- **Single-URL UX feels light**. That is deliberate for v1. Adding multi-URL paste and playlist expansion are both small follow-ups once the single-URL path is stable.
|
||||||
|
- **No copyright enforcement**. We rely on the one-line notice in the UI. If misuse becomes a real concern, the next step would be an admin allowlist of domains or a per-user import quota — not in this spec.
|
||||||
|
- **`filename = url` placeholder**. Briefly, the asset row in the Library shows the URL as the name. The worker overwrites it within seconds for a successful import. Acceptable; the Library already handles "ingesting" assets with placeholder names from the upload path.
|
||||||
|
|
||||||
|
## 7. Acceptance
|
||||||
|
|
||||||
|
The feature is done when:
|
||||||
|
- A user can navigate to **Ingest → YouTube**, paste a public YouTube URL, pick a project, click Import, and within a minute or two see the asset appear in the Library with proxy and thumbnail.
|
||||||
|
- A failed import (private video, removed video, bogus URL) shows a clear error message on both the YouTube screen's queue row and the Jobs screen.
|
||||||
|
- The Jobs screen lists "YouTube" jobs alongside Proxy / Thumbnail / Conform, with Retry working.
|
||||||
|
- `source_url` is populated on the imported asset row.
|
||||||
|
- Image rebuild + `docker compose up -d worker` is the documented recovery path if YouTube changes break yt-dlp.
|
||||||
269
docs/superpowers/specs/2026-05-27-auth-system-design.md
Normal file
269
docs/superpowers/specs/2026-05-27-auth-system-design.md
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
# Dragonflight User Authentication — Design
|
||||||
|
|
||||||
|
**Status:** Approved, ready for implementation planning
|
||||||
|
**Date:** 2026-05-27
|
||||||
|
**Brainstormed with:** Zac
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Dragonflight has the skeleton of an auth system spread across the codebase:
|
||||||
|
|
||||||
|
- `users` table (`id`, `username`, `password_hash`, `display_name`, `role`)
|
||||||
|
- `sessions` table (`sid`, `sess`, `expire`) for `connect-pg-simple`
|
||||||
|
- `groups`, `user_groups`, `api_tokens` tables
|
||||||
|
- `SESSION_SECRET` env var
|
||||||
|
- `AUTH_ENABLED` env flag with boot-log toggle
|
||||||
|
- PR #26 frontend handler that bounces to `/login.html` on 401
|
||||||
|
- Issue #94 "session security fixes" deployed 2026-05-26 (commit `3ebe5d6`)
|
||||||
|
|
||||||
|
But the actual `express-session` middleware was never mounted in `services/mam-api/src/index.js`. There is no `/api/v1/auth/*` router. There is no `requireAuth` middleware. As a result, when `AUTH_ENABLED=true` was tried:
|
||||||
|
|
||||||
|
1. User submits login, server returns 200 OK from a stub endpoint.
|
||||||
|
2. No `Set-Cookie` is ever sent (no session middleware mounted).
|
||||||
|
3. The next request to a protected route returns 401.
|
||||||
|
4. Frontend bounces to `/login.html`.
|
||||||
|
5. **Infinite redirect loop.**
|
||||||
|
|
||||||
|
The prior attempts failed because auth was being built reactively in pieces, with no single source of truth for what "logged in" means.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- One coherent, readable auth code path.
|
||||||
|
- Web UI logins survive page reloads and container restarts.
|
||||||
|
- Premiere panel can authenticate via long-lived bearer tokens.
|
||||||
|
- First-run setup works on a fresh install with no env var or CLI gymnastics.
|
||||||
|
- The whole auth flow can be exercised by automated tests, including a regression test for the redirect-loop failure mode.
|
||||||
|
|
||||||
|
## Non-goals (v1)
|
||||||
|
|
||||||
|
- MFA / TOTP.
|
||||||
|
- OAuth / OIDC delegation (Forgejo, Google, etc.).
|
||||||
|
- Per-project or per-recorder permissions. Flat access: logged in = full access.
|
||||||
|
- Email-based "forgot password" (no SMTP assumed; admin-reset only).
|
||||||
|
- Audit log of who-did-what (the `last_login_at` column is the minimum).
|
||||||
|
- Service-to-service auth for `node-agent` — keeps existing `019-node-token-binding` mechanism.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Decision | Choice | Reasoning |
|
||||||
|
|---|---|---|
|
||||||
|
| Client surface | Web UI + Premiere panel | Two transports (cookies + bearer), one identity backend |
|
||||||
|
| Permission model | Flat (logged in = full access) | Small homogeneous operator population. `groups` / `user_groups` schemas stay inert. |
|
||||||
|
| Identity provider | Local username/password | On-prem broadcast operators won't tolerate OIDC roundtrips. Matches existing schema. |
|
||||||
|
| First-user bootstrap | First-run setup page | Hardest to mis-configure. No env vars to leak. No CLI to remember. |
|
||||||
|
| Session lifetime | 8h absolute + 1h sliding idle | Operator security posture, tighter than typical SaaS. |
|
||||||
|
| Auth library | Hand-rolled (`express-session` + `connect-pg-simple`) | Explicit, debuggable. Rejected JWT and Passport for this codebase. |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Single source of truth
|
||||||
|
|
||||||
|
"Logged in" means exactly one of two things:
|
||||||
|
|
||||||
|
1. The request carries a valid `dragonflight.sid` cookie whose row in `sessions` hasn't expired and isn't past its 1h-idle or 8h-absolute window, OR
|
||||||
|
2. The request carries `Authorization: Bearer <token>` whose SHA-256 matches an `api_tokens` row that hasn't been revoked or expired.
|
||||||
|
|
||||||
|
Nothing else counts. No `localStorage` flags, no JWT, no client-side "I think I'm logged in" hints.
|
||||||
|
|
||||||
|
### One middleware, one check
|
||||||
|
|
||||||
|
`services/mam-api/src/middleware/auth.js` exposes a single `requireAuth` function:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export async function requireAuth(req, res, next) {
|
||||||
|
// Dev mode preserved. The 'dev' user is a real row in `users` seeded at
|
||||||
|
// boot when AUTH_ENABLED !== 'true', so FK-bearing routes (api_tokens,
|
||||||
|
// future comments, audit fields) keep working without conditional logic.
|
||||||
|
if (process.env.AUTH_ENABLED !== 'true') {
|
||||||
|
req.user = DEV_USER; // { id: <UUID of seeded 'dev' user>, username: 'dev' }
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Session check
|
||||||
|
if (req.session?.user_id) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - req.session.first_seen_at > 8 * 3600 * 1000) return destroyAnd401(req, res);
|
||||||
|
if (now - req.session.last_seen_at > 1 * 3600 * 1000) return destroyAnd401(req, res);
|
||||||
|
req.session.last_seen_at = now;
|
||||||
|
req.user = await loadUser(req.session.user_id);
|
||||||
|
if (!req.user) return destroyAnd401(req, res);
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Bearer check
|
||||||
|
const bearer = parseBearer(req.headers.authorization);
|
||||||
|
if (bearer) {
|
||||||
|
const hash = sha256hex(bearer);
|
||||||
|
const row = await pool.query(
|
||||||
|
`SELECT t.id, t.user_id, t.expires_at, u.username
|
||||||
|
FROM api_tokens t JOIN users u ON u.id = t.user_id
|
||||||
|
WHERE t.token_hash = $1`, [hash]);
|
||||||
|
if (row.rows.length && (!row.rows[0].expires_at || row.rows[0].expires_at > new Date())) {
|
||||||
|
pool.query(`UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1`, [row.rows[0].id]).catch(() => {});
|
||||||
|
req.user = { id: row.rows[0].user_id, username: row.rows[0].username };
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Otherwise
|
||||||
|
return res.status(401).json({ error: 'unauthorized' });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Mounted at the `/api/v1` level in `services/mam-api/src/index.js`, **before** the individual route mounts, with an allowlist for the three pre-login auth paths:
|
||||||
|
|
||||||
|
```js
|
||||||
|
app.use('/api/v1', (req, res, next) => {
|
||||||
|
const unauth = ['/auth/login', '/auth/setup', '/auth/setup-required'];
|
||||||
|
if (unauth.some(p => req.path === p)) return next();
|
||||||
|
return requireAuth(req, res, next);
|
||||||
|
});
|
||||||
|
// then: app.use('/api/v1/assets', assetsRouter), etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
`/health` lives at the root, outside the `/api/v1` mount, so it's naturally unaffected. `/api/v1/cluster/*` keeps its existing `019-node-token-binding` service-auth path: requireAuth runs first, fails with 401 for an unauthenticated request, **but** the cluster routes themselves do their own token check on request bodies, so node-agent traffic must include a valid user session OR an api_token (which is the change — node-agent will need to be issued an api_token at install time). Alternative: carve `/api/v1/cluster/*` out of the requireAuth gate too, and keep node-agent on its existing binding token alone. Implementer should pick — flagged in the implementation order.
|
||||||
|
|
||||||
|
### Session middleware (actually wired this time)
|
||||||
|
|
||||||
|
In `services/mam-api/src/index.js`, **before any route**:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import session from 'express-session';
|
||||||
|
import connectPgSimple from 'connect-pg-simple';
|
||||||
|
const PgStore = connectPgSimple(session);
|
||||||
|
|
||||||
|
if (process.env.TRUST_PROXY === 'true') app.set('trust proxy', 1);
|
||||||
|
|
||||||
|
app.use(session({
|
||||||
|
store: new PgStore({ pool, tableName: 'sessions', pruneSessionInterval: 60 * 15 }),
|
||||||
|
secret: process.env.SESSION_SECRET,
|
||||||
|
name: 'dragonflight.sid',
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: process.env.TRUST_PROXY === 'true',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 8 * 3600 * 1000,
|
||||||
|
},
|
||||||
|
rolling: false, // sliding renewal handled in requireAuth so we can enforce idle + absolute separately
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auth router
|
||||||
|
|
||||||
|
`services/mam-api/src/routes/auth.js`:
|
||||||
|
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `GET` | `/api/v1/auth/setup-required` | none | `{ required: bool }`. Cheap, no auth. |
|
||||||
|
| `POST` | `/api/v1/auth/setup` | none | Only succeeds if `users` is empty. Creates first user, logs them in. |
|
||||||
|
| `POST` | `/api/v1/auth/login` | none | `{ username, password }` -> 200 + cookie or 401 |
|
||||||
|
| `POST` | `/api/v1/auth/logout` | required | Destroys session row, clears cookie |
|
||||||
|
| `GET` | `/api/v1/auth/me` | required | `{ id, username, display_name }` |
|
||||||
|
| `POST` | `/api/v1/auth/password` | required | Change own password (requires current) |
|
||||||
|
| `GET/POST/DELETE` | `/api/v1/auth/users[/:id]` | required | User CRUD |
|
||||||
|
| `GET/POST/DELETE` | `/api/v1/auth/tokens[/:id]` | required | Current user's API tokens |
|
||||||
|
|
||||||
|
### Data model
|
||||||
|
|
||||||
|
Existing schema is almost right. One small migration:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- services/mam-api/src/db/migrations/023-auth-session-timestamps.sql
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_updated_at TIMESTAMPTZ DEFAULT NOW();
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;
|
||||||
|
-- idle / absolute timestamps live inside session.sess JSONB; no schema change needed
|
||||||
|
```
|
||||||
|
|
||||||
|
`groups` and `user_groups` stay as-is, unused for v1. `api_tokens` is already correctly shaped.
|
||||||
|
|
||||||
|
## Flows
|
||||||
|
|
||||||
|
### Browser login (the one that broke last time)
|
||||||
|
|
||||||
|
1. SPA boots, `<AuthGate>` calls `GET /api/v1/auth/me`.
|
||||||
|
2. `requireAuth` returns 401.
|
||||||
|
3. AuthGate calls `GET /api/v1/auth/setup-required`. If `true`, render Setup screen. Otherwise, render Login screen.
|
||||||
|
4. User submits `POST /api/v1/auth/login`. Server `bcrypt.compare`s, sets `req.session.user_id`, `first_seen_at`, `last_seen_at`. **Critical:** `await new Promise(r => req.session.save(r))` before responding, so the cookie is persisted to Postgres before the next request can arrive.
|
||||||
|
5. AuthGate re-calls `/api/v1/auth/me`, gets 200, renders the app.
|
||||||
|
|
||||||
|
**Why this doesn't loop:** the explicit `req.session.save()` callback before response guarantees the cookie row exists before the SPA can fire its next request. `requireAuth` returns a clean 401 (not a redirect) so the SPA decides what to render. The static `/login.html` is deleted; there is no HTML bounce.
|
||||||
|
|
||||||
|
### Premiere panel bearer
|
||||||
|
|
||||||
|
1. Web UI -> Settings -> API Tokens -> "New token" named "Premiere panel".
|
||||||
|
2. `POST /api/v1/auth/tokens` returns `{ token: 'dfl_<32 hex>', prefix: 'dfl_a3f2', id }` **exactly once**.
|
||||||
|
3. Premiere panel sends `Authorization: Bearer dfl_<...>` on every request. `requireAuth` SHA-256s it, looks up `api_tokens.token_hash`, updates `last_used_at`.
|
||||||
|
|
||||||
|
### Idle + absolute timeout (inside `requireAuth`)
|
||||||
|
|
||||||
|
```
|
||||||
|
if session present:
|
||||||
|
if now - session.first_seen_at > 8h -> destroy session, 401
|
||||||
|
if now - session.last_seen_at > 1h -> destroy session, 401
|
||||||
|
session.last_seen_at = now
|
||||||
|
req.user = lookup(session.user_id)
|
||||||
|
next()
|
||||||
|
```
|
||||||
|
|
||||||
|
Bearer tokens have their own optional `expires_at` (`NULL` = never expires); checked the same way.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
- **`services/web-ui/src/auth-gate.jsx`** — new component that wraps the SPA. On mount: `GET /me`. On 401: check `setup-required`, render either Setup or Login. On 200: render the app shell.
|
||||||
|
- **Login screen** — layout B from brainstorm: 22px wordmark over "WILD DRAGON BROADCAST" tagline above a `--bg-1` card containing username, password, "Sign in" button. Matches DESIGN.md tokens.
|
||||||
|
- **Setup screen** — same chrome; fields = username, password, confirm password; button = "Create admin".
|
||||||
|
- **Settings -> Account section** — change password.
|
||||||
|
- **Settings -> API Tokens section** — list / create / revoke. New token shown exactly once with a copy affordance.
|
||||||
|
- **Fetch wrapper** — the central `ZAMPP_API.fetch` (already exists) gains a 401 handler that re-mounts AuthGate's Login state with the current path saved as `last_path`, restored after re-auth.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- The static `/login.html` page (PR #26's bounce target) is deleted. SPA handles login internally; no full-page reload.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
| Case | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Wrong username or password | `401 { error: 'invalid credentials' }`. Same message either way, no user enumeration. |
|
||||||
|
| Login rate limiting | Per-IP exponential backoff (1s, 2s, 4s, 8s, max 30s). In-memory `Map`. Single-instance limitation documented. |
|
||||||
|
| Idle / absolute expiry | 401 -> AuthGate Login. Last path saved, restored on re-auth. |
|
||||||
|
| Setup after first user exists | `409 { error: 'setup already complete' }`. Permanently disabled. |
|
||||||
|
| Token revoke | `DELETE /api/v1/auth/tokens/:id` — only owner can revoke. Subsequent bearer requests 401. |
|
||||||
|
| Delete-self when only user | `409 { error: 'cannot delete last user' }`. |
|
||||||
|
| Forgot password | No self-serve. Any logged-in user can reset another via `POST /api/v1/auth/users/:id/password`. Documented as the recovery path. |
|
||||||
|
| Password rules | Min 12 chars, no max, no character class requirements (NIST SP 800-63B). `bcrypt` cost 12. |
|
||||||
|
| CSRF | `SameSite=Lax` + same origin + required `X-Requested-With: dragonflight-ui` header on mutating requests (belt-and-suspenders). |
|
||||||
|
| Session table growth | `connect-pg-simple` `pruneSessionInterval: 60 * 15` (every 15 min). |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Unit — `services/mam-api/test/middleware/auth.test.js`**: requireAuth with (a) no creds, (b) valid session, (c) idle-expired session, (d) absolute-expired session, (e) valid bearer, (f) invalid bearer, (g) bearer matching a deleted user.
|
||||||
|
- **Integration — `services/mam-api/test/auth.integration.test.js`**: spin up Express + test Postgres. Walks: setup -> login -> /me -> mutating call -> logout -> /me 401. Second pass: idle timeout simulated by mutating `last_seen_at` in DB. Third pass: bearer issue -> use -> revoke -> 401.
|
||||||
|
- **Regression test for the redirect-loop bug:** explicit test that after `POST /auth/login` returns 200, a subsequent `GET /auth/me` with the returned cookie returns 200 in the same test client. This is the test that would have caught the original failure.
|
||||||
|
- **Manual smoke (documented in PR):** fresh install -> setup -> create admin -> land on dashboard -> reload (stays logged in) -> wait 1h idle -> reload -> bounce to login.
|
||||||
|
|
||||||
|
## Implementation order
|
||||||
|
|
||||||
|
Suggested sequencing for the implementation plan (writing-plans will refine):
|
||||||
|
|
||||||
|
1. Migration `023-auth-session-timestamps.sql`. Add idempotent seed of the dev user (`INSERT ... ON CONFLICT DO NOTHING` with a fixed UUID) so dev mode FK-bearing routes work out of the box.
|
||||||
|
2. `express-session` + `connect-pg-simple` wiring in `index.js`.
|
||||||
|
3. `requireAuth` middleware (with `DEV_USER` constant resolved from the seeded row).
|
||||||
|
4. Auth router (setup, login, logout, me, password).
|
||||||
|
5. Apply `requireAuth` to API router with allowlist. Decide cluster carve-out (see Architecture).
|
||||||
|
6. Auth tests (unit + integration + regression).
|
||||||
|
7. Frontend `<AuthGate>` + Login screen + Setup screen.
|
||||||
|
8. Frontend Settings -> Account + API Tokens.
|
||||||
|
9. Delete `/login.html`.
|
||||||
|
10. User CRUD + token CRUD routes.
|
||||||
|
11. Rate limiting + CSRF header.
|
||||||
|
12. Documentation: README updates, `AUTH_ENABLED` transition notes.
|
||||||
|
|
||||||
|
## Out-of-band notes for the implementer
|
||||||
|
|
||||||
|
- The current `cors({ origin: true, credentials: true })` in `index.js` is too permissive once cookies start carrying authority. Tighten to a specific origin list (driven by an `ALLOWED_ORIGINS` env var) at the same time as wiring the session middleware — otherwise we're undoing the `SameSite=Lax` protection from the other side.
|
||||||
|
- node-agent -> mam-api traffic on `/api/v1/cluster/*` must keep working. Add a route-level carve-out comment that this path uses the existing `019-node-token-binding` token, not the user-auth path.
|
||||||
|
- The boot log currently says `Authentication: ENABLED` / `DISABLED (set AUTH_ENABLED=true for production)`. Once this lands, the recommended default flips: `AUTH_ENABLED=true` becomes the documented default in `.env.example` and the README, and `AUTH_ENABLED=false` is documented as a dev-only escape hatch.
|
||||||
84
docs/superpowers/specs/2026-05-29-hls-vod-playback-design.md
Normal file
84
docs/superpowers/specs/2026-05-29-hls-vod-playback-design.md
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
# HLS VOD Playback for Browser
|
||||||
|
|
||||||
|
Date: 2026-05-29 | Status: design → implementation
|
||||||
|
Authors: Zac + Claude
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Replace the browser playback path for **recorded (VOD) assets** with HLS, retiring
|
||||||
|
the MP4 range-stitching workaround. The MP4 proxy is **kept** (supplements, not
|
||||||
|
replaces) because the Premiere UXP panel and conform pipeline consume it.
|
||||||
|
|
||||||
|
## Background — current state
|
||||||
|
|
||||||
|
- `GET /assets/:id/stream` returns `{ url: /api/v1/assets/:id/video, type: 'mp4' }`
|
||||||
|
for ready assets.
|
||||||
|
- `GET /assets/:id/video` streams `proxies/<id>.mp4` through Node with the
|
||||||
|
**RustFS range-stitching hack** (`stitchedS3Stream`): RustFS mis-serves ranged
|
||||||
|
GETs whose start offset is past ~5.8 MB, so the endpoint streams from byte 0 and
|
||||||
|
drops bytes. Works, but wastes bandwidth/CPU per seek and is fragile.
|
||||||
|
- **Live** assets already use HLS (`type: 'hls'`, `/live/<id>/index.m3u8`), and
|
||||||
|
`hls.js` is already loaded and wired in `screens-asset.jsx` for `type === 'hls'`.
|
||||||
|
- The proxy worker (`services/worker/src/workers/proxy.js`) produces a single
|
||||||
|
H.264/AAC/yuv420p MP4 — already HLS-compatible.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- **Supplement, not replace.** Keep `proxies/<id>.mp4`; add an HLS rendition.
|
||||||
|
- **Generate in the proxy worker** via fast remux (`-c copy`) — no re-encode.
|
||||||
|
- **Serve segments through mam-api** as whole-file GETs (no Range) — sidesteps the
|
||||||
|
RustFS range bug entirely and reuses session auth.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### 1. Generation (worker/proxy.js)
|
||||||
|
After uploading `proxies/<id>.mp4`, remux it to HLS into a temp dir:
|
||||||
|
```
|
||||||
|
ffmpeg -i <proxy.mp4> -c copy -f hls \
|
||||||
|
-hls_time 6 -hls_playlist_type vod -hls_flags independent_segments \
|
||||||
|
-hls_segment_filename <tmp>/seg_%03d.ts <tmp>/index.m3u8
|
||||||
|
```
|
||||||
|
Upload every file in the temp dir to `hls/<assetId>/` (playlist + `.ts`). Set
|
||||||
|
`assets.hls_s3_key = 'hls/<assetId>/index.m3u8'`. Remux is seconds; failure is
|
||||||
|
non-fatal (MP4 path still works as fallback).
|
||||||
|
|
||||||
|
### 2. Storage / schema
|
||||||
|
Migration adds `assets.hls_s3_key TEXT` (nullable). Presence = HLS available.
|
||||||
|
Segment objects live under `hls/<assetId>/seg_NNN.ts`; playlist references
|
||||||
|
**relative** segment names so the serving endpoint is path-agnostic.
|
||||||
|
|
||||||
|
### 3. Serving (mam-api)
|
||||||
|
New `GET /assets/:id/hls/:file` (file = `index.m3u8` or `seg_NNN.ts`):
|
||||||
|
- Validate `:file` against `^(index\.m3u8|seg_\d+\.ts)$` (no traversal).
|
||||||
|
- Whole-object GET of `hls/<id>/<file>` from S3 — **no Range handling**.
|
||||||
|
- Content-Type: `application/vnd.apple.mpegurl` (m3u8) / `video/mp2t` (ts).
|
||||||
|
- `Cache-Control: private, max-age=3600` for segments; `no-cache` for the playlist.
|
||||||
|
- Covered by the existing `requireAuth` gate; `hls.js` carries the same-origin
|
||||||
|
session cookie (same mechanism the live HLS path already relies on).
|
||||||
|
|
||||||
|
### 4. Stream selection (mam-api `/stream`)
|
||||||
|
For non-live assets: if `hls_s3_key` is set →
|
||||||
|
`{ url: '/api/v1/assets/:id/hls/index.m3u8', type: 'hls' }`. Else fall back to the
|
||||||
|
existing MP4 `/video` response. Live unchanged.
|
||||||
|
|
||||||
|
### 5. Backfill (existing assets)
|
||||||
|
Add an `hls` BullMQ job + `POST /assets/:id/reprocess?type=hls`: downloads the
|
||||||
|
existing `proxy_s3_key`, remuxes to HLS, uploads, sets `hls_s3_key`. No re-encode.
|
||||||
|
|
||||||
|
### 6. Frontend
|
||||||
|
No change required — `screens-asset.jsx` already plays `type: 'hls'` via `hls.js`.
|
||||||
|
Verify `hls.js` xhr carries credentials (same-origin cookie) for the proxied
|
||||||
|
segments; add `xhrSetup` withCredentials only if needed.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
- Multi-bitrate/ABR ladders (single rendition for now).
|
||||||
|
- Replacing the MP4 proxy or the `/video` endpoint (kept as fallback + for panel).
|
||||||
|
- Live-asset playback changes (already HLS).
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
1. Upload/capture an asset → proxy job produces MP4 **and** `hls/<id>/index.m3u8`.
|
||||||
|
2. `/stream` returns `type: 'hls'`; `/assets/:id/hls/index.m3u8` → 200 m3u8;
|
||||||
|
`/assets/:id/hls/seg_000.ts` → 200 `video/mp2t`, whole-file (no 206/Range).
|
||||||
|
3. Browser: asset plays + seeks via hls.js (no range-stitching path hit).
|
||||||
|
4. `reprocess?type=hls` backfills an older asset; it then plays via HLS.
|
||||||
|
5. MP4 proxy + `/hires` download still work (panel workflow intact).
|
||||||
235
docs/superpowers/specs/2026-05-30-playout-mcr-design.md
Normal file
235
docs/superpowers/specs/2026-05-30-playout-mcr-design.md
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
# Wild Dragon MAM — Playout / Master Control (MCR)
|
||||||
|
|
||||||
|
**Date:** 2026-05-30 (revised 2026-05-30 — §7 closed)
|
||||||
|
**Status:** APPROVED — implementation in progress (code drafted but uncommitted; see WORK_LOG_PLAYOUT.md)
|
||||||
|
**Author:** Zac + Claude
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resolved Decisions
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
|----------|----------|
|
||||||
|
| Playout engine | **CasparCG Server** (orchestrated via AMCP), not ffmpeg-native |
|
||||||
|
| Channel count | **Multi-channel from start** — N independent channels, placed across cluster nodes by capability (mirrors recorders) |
|
||||||
|
| Scheduling model | **Phased** — Phase A: on-demand playlist player; Phase B: 24/7 continuous channel |
|
||||||
|
| Output targets | SDI (DeckLink), NDI, SRT, RTMP — all via CasparCG consumers |
|
||||||
|
| Media source | Assets live in **S3**; must be staged to a CasparCG-local media volume before play (see §4) |
|
||||||
|
| CasparCG packaging | **Build our own image** (like `capture/build-with-decklink.sh`) — GL context via GPU passthrough or Xvfb; NDI + DeckLink SDKs fetched at build time (not redistributable) |
|
||||||
|
| Master codec playability | Zac confirms current masters **play fine in CasparCG** — no transcode-for-playout step; staging is a plain S3→/media copy. Validate on hardware but do not gate on it |
|
||||||
|
| Management UI | **Single Dragonflight `playout.html` GUI** drives everything via AMCP; operator never touches CasparCG directly |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Playout adds **master-control-room** capability to Dragonflight: take library assets, arrange them on a timeline / playlist, and play them out continuously to a broadcast output — SDI via DeckLink, or stream via SRT / RTMP / NDI. Drag-and-drop scheduling, a program/preview monitor, and as-run logging.
|
||||||
|
|
||||||
|
This is the **mirror image** of the existing capture path. Capture is `input → ffmpeg encode → S3`. Playout is `S3 asset → engine → output`. We reuse three things wholesale:
|
||||||
|
|
||||||
|
1. **Cluster node + capability model** — nodes already advertise DeckLink/Deltacast/GPU in `cluster_nodes.capabilities`; channels are placed on nodes that have a free output port, exactly as recorders claim input ports.
|
||||||
|
2. **Sidecar orchestration** — mam-api spawns containers via the local Docker socket or the remote `node-agent /sidecar/start`. A CasparCG channel is just a different sidecar image.
|
||||||
|
3. **Scheduler tick + PG advisory lock** — `src/scheduler.js` already runs a single-leader tick over a schedule table. Phase B's wall-clock channel reuses this pattern.
|
||||||
|
|
||||||
|
### Why CasparCG over ffmpeg-native
|
||||||
|
|
||||||
|
The capture stack proves we can drive ffmpeg + DeckLink. But playout's hard part is **gapless, frame-accurate, clean transitions between clips** — every clip boundary in an ffmpeg-per-clip model is a black flash unless we engineer a concat-feeder. CasparCG solves this natively: a channel is a persistent output with a playlist, hard/mix/wipe transitions, layered graphics/logo (CG), and DeckLink/NDI/SRT/RTMP consumers built in. We orchestrate it over **AMCP** (its TCP control protocol) instead of reinventing a feeder. Trade: a new dependency + container image, and media must be on a CasparCG-visible disk (§4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Data Model
|
||||||
|
|
||||||
|
New migration `029-playout.sql`. Five tables.
|
||||||
|
|
||||||
|
### 1.1 `playout_channels`
|
||||||
|
A logical output. One channel → one engine instance → one output target.
|
||||||
|
|
||||||
|
```
|
||||||
|
id uuid pk
|
||||||
|
name text -- "Channel 1", "Pop-up SDI"
|
||||||
|
node_id uuid -> cluster_nodes(id) -- where the engine runs (null = primary)
|
||||||
|
output_type text -- 'decklink' | 'ndi' | 'srt' | 'rtmp'
|
||||||
|
output_config jsonb -- { device_index } | { ndi_name } | { url, latency } | { url, key }
|
||||||
|
video_format text -- '1080i5994' | '1080p5994' | '720p5994' ...
|
||||||
|
status text -- 'stopped' | 'starting' | 'running' | 'error'
|
||||||
|
container_id text -- running CasparCG sidecar
|
||||||
|
project_id uuid -> projects(id) -- RBAC scoping (nullable = admin-only)
|
||||||
|
created_at / updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
`output_type` + `output_config` map straight to a CasparCG consumer:
|
||||||
|
- `decklink` → `ADD <ch> DECKLINK <device> ...`
|
||||||
|
- `ndi` → `ADD <ch> NDI ...`
|
||||||
|
- `srt`/`rtmp` → `ADD <ch> FFMPEG <url> -f mpegts ...` (CasparCG 2.3+ ffmpeg consumer)
|
||||||
|
|
||||||
|
### 1.2 `playout_playlists`
|
||||||
|
An ordered list of items bound to a channel. Phase A's primary object.
|
||||||
|
|
||||||
|
```
|
||||||
|
id, channel_id -> playout_channels(id)
|
||||||
|
name, loop boolean, created_at / updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 `playout_items`
|
||||||
|
One entry on a playlist OR one entry on the 24/7 timeline.
|
||||||
|
|
||||||
|
```
|
||||||
|
id
|
||||||
|
playlist_id uuid -> playout_playlists(id) -- Phase A
|
||||||
|
asset_id uuid -> assets(id)
|
||||||
|
sort_order int -- position in playlist (Phase A)
|
||||||
|
scheduled_at timestamptz -- wall-clock start (Phase B, null in A)
|
||||||
|
in_point numeric -- seconds, trim head (reuse subclip in/out from editor)
|
||||||
|
out_point numeric -- seconds, trim tail
|
||||||
|
transition text -- 'cut' | 'mix' | 'wipe'
|
||||||
|
transition_ms int
|
||||||
|
graphics jsonb -- optional CG/template overlay (Phase B+)
|
||||||
|
media_status text -- 'pending' | 'staging' | 'ready' | 'error' (see §4)
|
||||||
|
media_path text -- resolved path inside the CasparCG media volume
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 `playout_schedule` (Phase B)
|
||||||
|
Day-ahead, wall-clock-bound timeline rows. Same shape as `playout_items` but `scheduled_at` is authoritative and the scheduler tick (§5) drives transitions. Phase A can ignore this table.
|
||||||
|
|
||||||
|
### 1.5 `playout_as_run`
|
||||||
|
Append-only log: what actually played, when, for how long. Compliance / billing.
|
||||||
|
|
||||||
|
```
|
||||||
|
id, channel_id, asset_id, item_id
|
||||||
|
started_at, ended_at, duration_s, result -- 'played' | 'skipped' | 'error'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Services & Components
|
||||||
|
|
||||||
|
### 2.1 New sidecar: `services/playout/` (CasparCG wrapper)
|
||||||
|
A thin container: **CasparCG Server** + a small Node control shim exposing HTTP, the same way `capture` wraps ffmpeg.
|
||||||
|
|
||||||
|
- Base image: official/community `casparcg/server` (Linux build with DeckLink + NDI + FFmpeg producers/consumers).
|
||||||
|
- Node shim (`src/index.js`): opens an AMCP TCP socket to local CasparCG, exposes:
|
||||||
|
- `POST /channel/start` → `ADD <ch> <consumer>` for the channel's output target
|
||||||
|
- `POST /play` → `PLAY <ch>-<layer> <media> [transition]`
|
||||||
|
- `POST /loadbg` + `/play` → preview/cue then take (preview monitor)
|
||||||
|
- `POST /stop`, `GET /status` → `INFO <ch>` (current clip, position, fps)
|
||||||
|
- playlist load → translate `playout_items` rows into a sequence of AMCP `LOADBG`/`PLAY` calls, advancing on `OnTransition` / end-of-clip events.
|
||||||
|
- Mirrors capture's status-polling contract so the UI monitor reuses existing plumbing.
|
||||||
|
|
||||||
|
### 2.2 mam-api: `src/routes/playout.js`
|
||||||
|
CRUD + control, RBAC-scoped via the existing `assertProjectAccess` helper (channels carry `project_id`).
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /playout/channels list (project-filtered)
|
||||||
|
POST /playout/channels create (edit on project)
|
||||||
|
POST /playout/channels/:id/start|stop spawn/kill CasparCG sidecar
|
||||||
|
GET /playout/channels/:id/status proxy engine INFO
|
||||||
|
POST /playout/channels/:id/play|pause|skip transport control
|
||||||
|
GET/POST/PUT/DELETE /playout/playlists... playlist + item CRUD, reorder
|
||||||
|
POST /playout/items/:id/stage kick S3→media-volume staging (§4)
|
||||||
|
GET /playout/channels/:id/asrun as-run log
|
||||||
|
```
|
||||||
|
|
||||||
|
Channel start/stop reuses `resolveNodeTarget()` + the Docker-socket / `node-agent /sidecar/start` split already in `recorders.js`. **Refactor opportunity:** lift that sidecar-spawn logic out of `recorders.js` into `src/orchestration/sidecar.js` so both recorders and playout share it (keep this small — only what both need).
|
||||||
|
|
||||||
|
### 2.3 web-ui: `playout.html` + `public/playout.jsx`
|
||||||
|
New MCR page. Layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ PREVIEW ───────────┬─ PROGRAM (on air) ──────┐
|
||||||
|
│ [cued clip] │ [live output] ● ON AIR │
|
||||||
|
│ TC / duration │ TC / remaining │
|
||||||
|
│ [CUE] [TAKE] │ [PLAY][PAUSE][SKIP][STOP]│
|
||||||
|
├─ MEDIA BIN ─────────┴──────────────────────────┤
|
||||||
|
│ (draggable asset list, reuse asset browser) │
|
||||||
|
├─ PLAYLIST / TIMELINE ──────────────────────────┤
|
||||||
|
│ ▸ clip A ──▸ clip B ──▸ clip C (drag-drop) │ Phase A: ordered list
|
||||||
|
│ └ 24h time grid w/ now-bar │ Phase B: time-of-day grid
|
||||||
|
└────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Drag-drop: reuse whatever the NLE editor timeline uses (check `editor.jsx`); assets drag from the bin into the playlist/grid.
|
||||||
|
- API via existing `ZAMPP_API.fetch` wrapper.
|
||||||
|
- Program monitor: HLS preview of the output — CasparCG can emit a second low-bitrate FFmpeg consumer to HLS, reusing the `/live/<id>` HLS plumbing capture already uses.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Channel placement & ports
|
||||||
|
|
||||||
|
A DeckLink port is exclusive — same constraint capture already handles. A node's DeckLink port can be an **input (recorder)** or an **output (playout channel)**, never both at once. So:
|
||||||
|
|
||||||
|
- Extend the capability/port-claim check: when starting a channel on `output_type=decklink`, verify the target node has that device index free (no active recorder, no active channel).
|
||||||
|
- NDI / SRT / RTMP outputs have no hardware contention → can stack many per node (GPU/CPU-bound only).
|
||||||
|
- Surface a unified "device map" (extend the existing cluster DeckLink-status endpoint) showing each port's role: idle / recording / playing-out.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Media staging (the S3 ⇄ CasparCG gap)
|
||||||
|
|
||||||
|
**The crux.** Assets live in S3 (`original_s3_key` / `proxy_s3_key`). CasparCG plays from a **local media folder**. Options:
|
||||||
|
|
||||||
|
- **A — Pre-stage to a shared media volume (recommended).** Before a clip can go on air, download/symlink it from S3 to a CasparCG-visible volume (`/media`), set `playout_items.media_status='ready'` + `media_path`. A new BullMQ `playout-stage` job (reuses the worker pattern) does the pull. UI shows per-item readiness; TAKE is blocked until `ready`. Mirrors the growing-file SMB share already mounted for capture.
|
||||||
|
- **B — Stream from S3 via presigned URL.** CasparCG FFmpeg producer plays an HTTPS presigned URL directly. Zero staging, but seeking/trim and gapless transitions over network are fragile for broadcast. Acceptable as a fallback for SRT/RTMP, risky for SDI.
|
||||||
|
|
||||||
|
**Decision:** Phase A uses **A** (stage proxies for preview, masters for air) with **B** available as a per-channel "low-latency / no-stage" toggle. Zac confirms the current masters play fine in CasparCG, so staging is a **plain S3→/media copy — no transcode-for-playout step**. (Validate on hardware during implementation, but the model does not assume a transcode stage.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Scheduling
|
||||||
|
|
||||||
|
### Phase A — playlist player
|
||||||
|
No wall clock. Operator builds a `playout_playlists` row, drags items in, hits PLAY. The playout sidecar walks `playout_items` by `sort_order`, cueing the next clip during the current one (`LOADBG`) and taking it at end-of-clip with the configured transition. `loop` repeats. As-run logged per item.
|
||||||
|
|
||||||
|
### Phase B — 24/7 continuous channel
|
||||||
|
Wall-clock timeline in `playout_schedule`. Reuse `src/scheduler.js`:
|
||||||
|
- Add a second tick (or extend the existing one) under the **same PG advisory lock pattern** — exactly-one-leader, so a multi-replica deploy doesn't double-fire.
|
||||||
|
- Tick responsibilities: stage upcoming items (look-ahead window), verify the on-air item matches the schedule, **fill gaps** (loop a filler/slate asset when the timeline has a hole — a channel must never go black), roll the day forward.
|
||||||
|
- As-run becomes the compliance record.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Phasing / Milestones
|
||||||
|
|
||||||
|
**Phase A — Playlist playout MVP**
|
||||||
|
1. Migration `029-playout.sql` (channels, playlists, items, as-run).
|
||||||
|
2. `services/playout/` sidecar: CasparCG image + AMCP control shim, single output target (start with **SRT or NDI** — no hardware needed for dev; DeckLink behind hardware check).
|
||||||
|
3. mam-api `routes/playout.js` — channel + playlist CRUD, start/stop, transport, RBAC.
|
||||||
|
4. `playout-stage` BullMQ job (S3 → /media).
|
||||||
|
5. web-ui `playout.html` — bin + drag-drop ordered playlist + program/preview monitors + transport.
|
||||||
|
6. DeckLink output on real hardware; port-contention check vs recorders.
|
||||||
|
|
||||||
|
**Phase B — 24/7 continuous channel**
|
||||||
|
7. `playout_schedule` + time-of-day grid UI.
|
||||||
|
8. Scheduler tick (advisory-locked) — look-ahead staging, gap-fill/slate, day-roll.
|
||||||
|
9. As-run reporting view.
|
||||||
|
10. Graphics/CG overlay (logo bug, lower-thirds) via CasparCG templates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Open Questions (for review)
|
||||||
|
|
||||||
|
**Resolved (2026-05-30):**
|
||||||
|
- ~~CasparCG packaging~~ → **build our own image.** Fetch DeckLink + NDI SDKs at build time (not redistributable — same as capture's DeckLink build). GL context for the mixer comes from GPU passthrough on a real node, or **Xvfb** (virtual framebuffer) where there's no display — community images run `--privileged` + X11 socket. Pin the NDI SDK version to what the server expects (`.so` version mismatch is the common docker failure).
|
||||||
|
- ~~Master codec playability~~ → Zac confirms masters **play fine**; no transcode-for-playout. Staging = plain S3→/media copy.
|
||||||
|
- ~~Management GUI~~ → **single Dragonflight `playout.html`** drives everything via AMCP; operator never touches CasparCG.
|
||||||
|
- ~~Audio loudness~~ → **pre-normalize at stage time** (Zac, 2026-05-30). `playout-stage` job runs ffmpeg `loudnorm` (EBU R128, target −23 LUFS, true-peak −1 dBTP) once, on the S3→/media copy. Output is the cached version CasparCG plays. Staging is no longer a pure copy — staging cost ≈ realtime CPU per clip on first stage; results are reusable across channels. Override (`media_status='ready'` + raw copy) available for clips already mastered to spec.
|
||||||
|
- ~~Frame rate~~ → **`1080p5994`** default for new channels (Zac, 2026-05-30). Progressive 1080 @ 59.94 fps. Per-channel override allowed (`video_format` column). Streaming-friendly (SRT/RTMP/NDI) and current SDI gear accepts it; matches capture's 59.94 cadence.
|
||||||
|
- ~~Preview latency~~ → **HLS v1** (Zac, 2026-05-30). Reuse capture's `/live/<id>` HLS plumbing. CasparCG emits a second low-bitrate FFmpeg consumer to HLS. ~4–6s lag, fine for confidence monitor. Operator desk gets a real downstream monitor off the SDI/NDI output anyway. Revisit WebRTC if MCR operators complain.
|
||||||
|
- ~~Failover~~ → **auto-restart on healthy node** (Zac, 2026-05-30). Scheduler tick (§5) monitors `playout_sidecars` health (AMCP ping + container alive); on N missed checks marks the channel `error`, re-places it on another capability-matching node with a free output port, resumes the playlist from the next item after the as-run-logged on-air item. Gap = black/slate for ~5–30 s during respawn (operator sees a flag in the UI). **DeckLink channels are not auto-failed-over in v1** — device-index pinning makes the destination port non-trivial; v1 alerts and lets the operator move the channel. NDI/SRT/RTMP channels (no hardware contention) failover automatically. Tracked via `restart_count` + `last_restart_at` on `playout_channels`.
|
||||||
|
|
||||||
|
**Still open:**
|
||||||
|
- (none — all §7 questions resolved 2026-05-30)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Reused building blocks (already in the repo)
|
||||||
|
|
||||||
|
| Need | Existing piece |
|
||||||
|
|------|----------------|
|
||||||
|
| Spawn engine container local/remote | `recorders.js` Docker-socket + `node-agent /sidecar/start` |
|
||||||
|
| Node capability / port model | `cluster_nodes.capabilities`, cluster DeckLink-status endpoint |
|
||||||
|
| Single-leader scheduled transitions | `src/scheduler.js` + PG advisory lock |
|
||||||
|
| Background media jobs | BullMQ worker (`services/worker`) |
|
||||||
|
| RBAC scoping | `src/auth/authz.js` `assertProjectAccess` (channel/project_id) |
|
||||||
|
| HLS preview plumbing | capture's `/live/<id>` HLS output |
|
||||||
|
| Subclip in/out points | NLE editor in/out marking |
|
||||||
|
| API wrapper / SPA shell | `ZAMPP_API.fetch`, esbuild JSX pages |
|
||||||
|
|
@ -1,8 +1,97 @@
|
||||||
|
# ── Stage 1: Build FFmpeg with DeckLink + NVENC (HEVC/H264) support ─────────
|
||||||
|
# All-Intra HEVC NVENC is the master codec for growing-file ingest (see
|
||||||
|
# docs/design/2026-05-29-all-intra-hevc-ingest.md). This stage gets the
|
||||||
|
# nv-codec-headers (header-only, no driver / no full CUDA toolkit needed)
|
||||||
|
# so ffmpeg's configure can light up hevc_nvenc / h264_nvenc / cuvid.
|
||||||
|
# At runtime, /dev/nvidia* + the host driver libs (via the NVIDIA Container
|
||||||
|
# Toolkit) supply the actual encoder.
|
||||||
|
FROM debian:bookworm AS ffmpeg-builder
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential nasm yasm pkg-config git ca-certificates python3 \
|
||||||
|
libssl-dev libx264-dev libx265-dev libvpx-dev libopus-dev \
|
||||||
|
libmp3lame-dev libsrt-openssl-dev \
|
||||||
|
libzmq3-dev zlib1g-dev libstdc++-12-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy in BMD DeckLink SDK headers and patch script
|
||||||
|
COPY sdk/ /decklink-sdk/
|
||||||
|
COPY patch_decklink.py /patch_decklink.py
|
||||||
|
COPY decklink-sdk16.patch /decklink-sdk16.patch
|
||||||
|
|
||||||
|
# nv-codec-headers — just the ffnvcodec public headers + a pkg-config file.
|
||||||
|
# Pin to a tag known to work with FFmpeg 7.1 (n12.x series).
|
||||||
|
RUN git clone --depth=1 --branch n12.1.14.0 https://github.com/FFmpeg/nv-codec-headers.git /nv-codec-headers \
|
||||||
|
&& make -C /nv-codec-headers PREFIX=/usr/local install
|
||||||
|
|
||||||
|
# Pull FFmpeg 7.1 source
|
||||||
|
RUN git clone --depth=1 --branch release/7.1 https://git.ffmpeg.org/ffmpeg.git /ffmpeg
|
||||||
|
|
||||||
|
# Patch FFmpeg DeckLink code for SDK 16.x API changes
|
||||||
|
RUN python3 /patch_decklink.py
|
||||||
|
|
||||||
|
WORKDIR /ffmpeg
|
||||||
|
# NVENC adds: --enable-nvenc (encoder), --enable-cuvid (decoder), --enable-ffnvcodec.
|
||||||
|
# We deliberately do NOT enable --enable-cuda-nvcc / --enable-libnpp here — those
|
||||||
|
# require the full ~3GB CUDA toolkit and are only needed for GPU filters like
|
||||||
|
# yadif_cuda / scale_cuda. If §5's GPU deinterlace stretch goal goes ahead,
|
||||||
|
# rebuild this image off nvidia/cuda:12.x-devel and flip those flags on.
|
||||||
|
RUN ./configure \
|
||||||
|
--prefix=/usr/local \
|
||||||
|
--extra-cflags="-I/decklink-sdk -I/usr/local/include" \
|
||||||
|
--extra-ldflags="-L/usr/local/lib" \
|
||||||
|
--enable-gpl \
|
||||||
|
--enable-nonfree \
|
||||||
|
--enable-libx264 \
|
||||||
|
--enable-libx265 \
|
||||||
|
--enable-libvpx \
|
||||||
|
--enable-libopus \
|
||||||
|
--enable-libmp3lame \
|
||||||
|
--enable-libsrt \
|
||||||
|
--enable-libzmq \
|
||||||
|
--enable-decklink \
|
||||||
|
--enable-ffnvcodec \
|
||||||
|
--enable-nvenc \
|
||||||
|
--enable-cuvid \
|
||||||
|
--disable-doc \
|
||||||
|
--disable-debug \
|
||||||
|
--disable-ffplay \
|
||||||
|
&& make -j$(nproc) \
|
||||||
|
&& make install
|
||||||
|
|
||||||
|
# Sanity-check: hevc_nvenc and h264_nvenc must be present in the encoder list,
|
||||||
|
# otherwise the resulting image is useless for the All-Intra HEVC pipeline.
|
||||||
|
RUN /usr/local/bin/ffmpeg -hide_banner -encoders 2>&1 | grep -E 'nvenc' \
|
||||||
|
|| (echo 'FATAL: nvenc encoders missing from ffmpeg build' && exit 1)
|
||||||
|
|
||||||
|
# ── Stage 2: Runtime image ───────────────────────────────────────────────────
|
||||||
FROM node:20-bookworm
|
FROM node:20-bookworm
|
||||||
RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
# Runtime deps for compiled ffmpeg libs
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libx264-164 libx265-199 libvpx7 libopus0 libmp3lame0 \
|
||||||
|
libsrt1.5-openssl libzmq5 libstdc++6 libc++1 libc++abi1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy compiled ffmpeg/ffprobe
|
||||||
|
COPY --from=ffmpeg-builder /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg
|
||||||
|
COPY --from=ffmpeg-builder /usr/local/bin/ffprobe /usr/local/bin/ffprobe
|
||||||
|
COPY --from=ffmpeg-builder /usr/local/lib/ /usr/local/lib/
|
||||||
|
|
||||||
|
# DeckLink runtime .so
|
||||||
|
COPY lib/libDeckLinkAPI.so /usr/lib/libDeckLinkAPI.so
|
||||||
|
COPY lib/libDeckLinkPreviewAPI.so /usr/lib/libDeckLinkPreviewAPI.so
|
||||||
|
RUN ldconfig
|
||||||
|
|
||||||
|
# Mount points the recorder lifecycle expects to exist.
|
||||||
|
# /live — HLS preview output (bound from host LIVE_DIR by node-agent)
|
||||||
|
# /growing — growing-file master output (bound from host /mnt/NVME/MAM/growing)
|
||||||
|
RUN mkdir -p /live /growing
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
CMD ["node", "src/index.js"]
|
CMD ["node", "src/index.js"]
|
||||||
|
|
|
||||||
30
services/capture/build-with-decklink.sh
Executable file
30
services/capture/build-with-decklink.sh
Executable file
|
|
@ -0,0 +1,30 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "=== Checking prerequisites ==="
|
||||||
|
|
||||||
|
if [ ! -f sdk/DeckLinkAPI.h ]; then
|
||||||
|
echo "ERROR: sdk/DeckLinkAPI.h not found."
|
||||||
|
echo ""
|
||||||
|
echo "Please download the Blackmagic DeckLink SDK 16.x from:"
|
||||||
|
echo " https://www.blackmagicdesign.com/developer/product/capture"
|
||||||
|
echo ""
|
||||||
|
echo "Then extract the Linux/include/ folder contents into:"
|
||||||
|
echo " $(pwd)/sdk/"
|
||||||
|
echo ""
|
||||||
|
echo "Required files: DeckLinkAPI.h DeckLinkAPIVersion.h DeckLinkAPIDispatch.cpp"
|
||||||
|
echo " LinuxCOM.h DeckLinkAPIModes.h DeckLinkAPITypes.h"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "SDK headers found:"
|
||||||
|
ls sdk/*.h sdk/*.cpp 2>/dev/null
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Building capture container with DeckLink FFmpeg ==="
|
||||||
|
docker compose -f ../../docker-compose.yml build capture
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Verifying DeckLink support in built image ==="
|
||||||
|
docker run --rm wild-dragon-capture ffmpeg -f decklink -list_devices true -i dummy 2>&1 | head -20
|
||||||
346
services/capture/decklink-sdk16.patch
Normal file
346
services/capture/decklink-sdk16.patch
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
diff --git a/libavdevice/decklink_common.cpp b/libavdevice/decklink_common.cpp
|
||||||
|
index fe187cd..47de7ef 100644
|
||||||
|
--- a/libavdevice/decklink_common.cpp
|
||||||
|
+++ b/libavdevice/decklink_common.cpp
|
||||||
|
@@ -25,12 +25,7 @@ extern "C" {
|
||||||
|
#include "libavformat/internal.h"
|
||||||
|
}
|
||||||
|
|
||||||
|
-#include <DeckLinkAPIVersion.h>
|
||||||
|
#include <DeckLinkAPI.h>
|
||||||
|
-#if BLACKMAGIC_DECKLINK_API_VERSION >= 0x0e030000
|
||||||
|
-#include <DeckLinkAPI_v14_2_1.h>
|
||||||
|
-#endif
|
||||||
|
-
|
||||||
|
#ifdef _WIN32
|
||||||
|
#include <DeckLinkAPI_i.c>
|
||||||
|
#else
|
||||||
|
@@ -517,8 +512,8 @@ int ff_decklink_list_devices(AVFormatContext *avctx,
|
||||||
|
return AVERROR(EIO);
|
||||||
|
|
||||||
|
while (ret == 0 && iter->Next(&dl) == S_OK) {
|
||||||
|
- IDeckLinkOutput_v14_2_1 *output_config;
|
||||||
|
- IDeckLinkInput_v14_2_1 *input_config;
|
||||||
|
+ IDeckLinkOutput *output_config;
|
||||||
|
+ IDeckLinkInput *input_config;
|
||||||
|
const char *display_name = NULL;
|
||||||
|
const char *unique_name = NULL;
|
||||||
|
AVDeviceInfo *new_device = NULL;
|
||||||
|
@@ -532,14 +527,14 @@ int ff_decklink_list_devices(AVFormatContext *avctx,
|
||||||
|
goto next;
|
||||||
|
|
||||||
|
if (show_outputs) {
|
||||||
|
- if (dl->QueryInterface(IID_IDeckLinkOutput_v14_2_1, (void **)&output_config) == S_OK) {
|
||||||
|
+ if (dl->QueryInterface(IID_IDeckLinkOutput, (void **)&output_config) == S_OK) {
|
||||||
|
output_config->Release();
|
||||||
|
add = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (show_inputs) {
|
||||||
|
- if (dl->QueryInterface(IID_IDeckLinkInput_v14_2_1, (void **)&input_config) == S_OK) {
|
||||||
|
+ if (dl->QueryInterface(IID_IDeckLinkInput, (void **)&input_config) == S_OK) {
|
||||||
|
input_config->Release();
|
||||||
|
add = 1;
|
||||||
|
}
|
||||||
|
diff --git a/libavdevice/decklink_common.h b/libavdevice/decklink_common.h
|
||||||
|
index 095b438..6b32dc2 100644
|
||||||
|
--- a/libavdevice/decklink_common.h
|
||||||
|
+++ b/libavdevice/decklink_common.h
|
||||||
|
@@ -29,23 +29,6 @@
|
||||||
|
#define IDeckLinkProfileAttributes IDeckLinkAttributes
|
||||||
|
#endif
|
||||||
|
|
||||||
|
-#if BLACKMAGIC_DECKLINK_API_VERSION < 0x0e030000
|
||||||
|
-#define IDeckLinkInput_v14_2_1 IDeckLinkInput
|
||||||
|
-#define IDeckLinkInputCallback_v14_2_1 IDeckLinkInputCallback
|
||||||
|
-#define IDeckLinkMemoryAllocator_v14_2_1 IDeckLinkMemoryAllocator
|
||||||
|
-#define IDeckLinkOutput_v14_2_1 IDeckLinkOutput
|
||||||
|
-#define IDeckLinkVideoFrame_v14_2_1 IDeckLinkVideoFrame
|
||||||
|
-#define IDeckLinkVideoInputFrame_v14_2_1 IDeckLinkVideoInputFrame
|
||||||
|
-#define IDeckLinkVideoOutputCallback_v14_2_1 IDeckLinkVideoOutputCallback
|
||||||
|
-#define IID_IDeckLinkInput_v14_2_1 IID_IDeckLinkInput
|
||||||
|
-#define IID_IDeckLinkInputCallback_v14_2_1 IID_IDeckLinkInputCallback
|
||||||
|
-#define IID_IDeckLinkMemoryAllocator_v14_2_1 IID_IDeckLinkMemoryAllocator
|
||||||
|
-#define IID_IDeckLinkOutput_v14_2_1 IID_IDeckLinkOutput
|
||||||
|
-#define IID_IDeckLinkVideoFrame_v14_2_1 IID_IDeckLinkVideoFrame
|
||||||
|
-#define IID_IDeckLinkVideoInputFrame_v14_2_1 IID_IDeckLinkVideoInputFrame
|
||||||
|
-#define IID_IDeckLinkVideoOutputCallback_v14_2_1 IID_IDeckLinkVideoOutputCallback
|
||||||
|
-#endif
|
||||||
|
-
|
||||||
|
extern "C" {
|
||||||
|
#include "libavutil/mem.h"
|
||||||
|
#include "libavcodec/packet_internal.h"
|
||||||
|
@@ -93,16 +76,6 @@ static char *dup_cfstring_to_utf8(CFStringRef w)
|
||||||
|
#define DECKLINK_FREE(s) free((void *) s)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
-#ifdef _WIN32
|
||||||
|
-#include <guiddef.h> // REFIID, IsEqualIID()
|
||||||
|
-#define DECKLINK_IsEqualIID IsEqualIID
|
||||||
|
-#else
|
||||||
|
-static inline bool DECKLINK_IsEqualIID(const REFIID& riid1, const REFIID& riid2)
|
||||||
|
-{
|
||||||
|
- return memcmp(&riid1, &riid2, sizeof(REFIID)) == 0;
|
||||||
|
-}
|
||||||
|
-#endif
|
||||||
|
-
|
||||||
|
class decklink_output_callback;
|
||||||
|
class decklink_input_callback;
|
||||||
|
|
||||||
|
@@ -120,8 +93,8 @@ typedef struct DecklinkPacketQueue {
|
||||||
|
struct decklink_ctx {
|
||||||
|
/* DeckLink SDK interfaces */
|
||||||
|
IDeckLink *dl;
|
||||||
|
- IDeckLinkOutput_v14_2_1 *dlo;
|
||||||
|
- IDeckLinkInput_v14_2_1 *dli;
|
||||||
|
+ IDeckLinkOutput *dlo;
|
||||||
|
+ IDeckLinkInput *dli;
|
||||||
|
IDeckLinkConfiguration *cfg;
|
||||||
|
IDeckLinkProfileAttributes *attr;
|
||||||
|
decklink_output_callback *output_callback;
|
||||||
|
diff --git a/libavdevice/decklink_dec.cpp b/libavdevice/decklink_dec.cpp
|
||||||
|
index 8830779..418701e 100644
|
||||||
|
--- a/libavdevice/decklink_dec.cpp
|
||||||
|
+++ b/libavdevice/decklink_dec.cpp
|
||||||
|
@@ -31,11 +31,7 @@ extern "C" {
|
||||||
|
#include "libavformat/internal.h"
|
||||||
|
}
|
||||||
|
|
||||||
|
-#include <DeckLinkAPIVersion.h>
|
||||||
|
#include <DeckLinkAPI.h>
|
||||||
|
-#if BLACKMAGIC_DECKLINK_API_VERSION >= 0x0e030000
|
||||||
|
-#include <DeckLinkAPI_v14_2_1.h>
|
||||||
|
-#endif
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
#include "config.h"
|
||||||
|
@@ -109,7 +105,7 @@ static VANCLineNumber vanc_line_numbers[] = {
|
||||||
|
{bmdModeUnknown, 0, -1, -1, -1}
|
||||||
|
};
|
||||||
|
|
||||||
|
-class decklink_allocator : public IDeckLinkMemoryAllocator_v14_2_1
|
||||||
|
+class decklink_allocator : public IDeckLinkMemoryAllocator
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
decklink_allocator(): _refs(1) { }
|
||||||
|
@@ -133,21 +129,7 @@ public:
|
||||||
|
virtual HRESULT STDMETHODCALLTYPE Decommit() { return S_OK; }
|
||||||
|
|
||||||
|
// IUnknown methods
|
||||||
|
- virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, LPVOID *ppv)
|
||||||
|
- {
|
||||||
|
- if (DECKLINK_IsEqualIID(riid, IID_IUnknown)) {
|
||||||
|
- *ppv = static_cast<IUnknown*>(this);
|
||||||
|
- } else if (DECKLINK_IsEqualIID(riid, IID_IDeckLinkMemoryAllocator_v14_2_1)) {
|
||||||
|
- *ppv = static_cast<IDeckLinkMemoryAllocator_v14_2_1*>(this);
|
||||||
|
- } else {
|
||||||
|
- *ppv = NULL;
|
||||||
|
- return E_NOINTERFACE;
|
||||||
|
- }
|
||||||
|
-
|
||||||
|
- AddRef();
|
||||||
|
- return S_OK;
|
||||||
|
- }
|
||||||
|
-
|
||||||
|
+ virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, LPVOID *ppv) { return E_NOINTERFACE; }
|
||||||
|
virtual ULONG STDMETHODCALLTYPE AddRef(void) { return ++_refs; }
|
||||||
|
virtual ULONG STDMETHODCALLTYPE Release(void)
|
||||||
|
{
|
||||||
|
@@ -490,7 +472,7 @@ skip_packet:
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
-static void handle_klv(AVFormatContext *avctx, decklink_ctx *ctx, IDeckLinkVideoInputFrame_v14_2_1 *videoFrame, int64_t pts)
|
||||||
|
+static void handle_klv(AVFormatContext *avctx, decklink_ctx *ctx, IDeckLinkVideoInputFrame *videoFrame, int64_t pts)
|
||||||
|
{
|
||||||
|
const uint8_t KLV_DID = 0x44;
|
||||||
|
const uint8_t KLV_IN_VANC_SDID = 0x04;
|
||||||
|
@@ -592,30 +574,17 @@ static void handle_klv(AVFormatContext *avctx, decklink_ctx *ctx, IDeckLinkVideo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
-class decklink_input_callback : public IDeckLinkInputCallback_v14_2_1
|
||||||
|
+class decklink_input_callback : public IDeckLinkInputCallback
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit decklink_input_callback(AVFormatContext *_avctx);
|
||||||
|
~decklink_input_callback();
|
||||||
|
|
||||||
|
- virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, LPVOID *ppv)
|
||||||
|
- {
|
||||||
|
- if (DECKLINK_IsEqualIID(riid, IID_IUnknown)) {
|
||||||
|
- *ppv = static_cast<IUnknown*>(this);
|
||||||
|
- } else if (DECKLINK_IsEqualIID(riid, IID_IDeckLinkInputCallback_v14_2_1)) {
|
||||||
|
- *ppv = static_cast<IDeckLinkInputCallback_v14_2_1*>(this);
|
||||||
|
- } else {
|
||||||
|
- *ppv = NULL;
|
||||||
|
- return E_NOINTERFACE;
|
||||||
|
- }
|
||||||
|
-
|
||||||
|
- AddRef();
|
||||||
|
- return S_OK;
|
||||||
|
- }
|
||||||
|
+ virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, LPVOID *ppv) { return E_NOINTERFACE; }
|
||||||
|
virtual ULONG STDMETHODCALLTYPE AddRef(void);
|
||||||
|
virtual ULONG STDMETHODCALLTYPE Release(void);
|
||||||
|
virtual HRESULT STDMETHODCALLTYPE VideoInputFormatChanged(BMDVideoInputFormatChangedEvents, IDeckLinkDisplayMode*, BMDDetectedVideoInputFormatFlags);
|
||||||
|
- virtual HRESULT STDMETHODCALLTYPE VideoInputFrameArrived(IDeckLinkVideoInputFrame_v14_2_1*, IDeckLinkAudioInputPacket*);
|
||||||
|
+ virtual HRESULT STDMETHODCALLTYPE VideoInputFrameArrived(IDeckLinkVideoInputFrame*, IDeckLinkAudioInputPacket*);
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::atomic<int> _refs;
|
||||||
|
@@ -624,7 +593,7 @@ private:
|
||||||
|
int no_video;
|
||||||
|
int64_t initial_video_pts;
|
||||||
|
int64_t initial_audio_pts;
|
||||||
|
- IDeckLinkVideoInputFrame_v14_2_1* last_video_frame;
|
||||||
|
+ IDeckLinkVideoInputFrame* last_video_frame;
|
||||||
|
};
|
||||||
|
|
||||||
|
decklink_input_callback::decklink_input_callback(AVFormatContext *_avctx) : _refs(1)
|
||||||
|
@@ -656,7 +625,7 @@ ULONG decklink_input_callback::Release(void)
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
-static int64_t get_pkt_pts(IDeckLinkVideoInputFrame_v14_2_1 *videoFrame,
|
||||||
|
+static int64_t get_pkt_pts(IDeckLinkVideoInputFrame *videoFrame,
|
||||||
|
IDeckLinkAudioInputPacket *audioFrame,
|
||||||
|
int64_t wallclock,
|
||||||
|
int64_t abs_wallclock,
|
||||||
|
@@ -710,7 +679,7 @@ static int64_t get_pkt_pts(IDeckLinkVideoInputFrame_v14_2_1 *videoFrame,
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
-static int get_bmd_timecode(AVFormatContext *avctx, AVTimecode *tc, AVRational frame_rate, BMDTimecodeFormat tc_format, IDeckLinkVideoInputFrame_v14_2_1 *videoFrame)
|
||||||
|
+static int get_bmd_timecode(AVFormatContext *avctx, AVTimecode *tc, AVRational frame_rate, BMDTimecodeFormat tc_format, IDeckLinkVideoInputFrame *videoFrame)
|
||||||
|
{
|
||||||
|
IDeckLinkTimecode *timecode;
|
||||||
|
int ret = AVERROR(ENOENT);
|
||||||
|
@@ -732,7 +701,7 @@ static int get_bmd_timecode(AVFormatContext *avctx, AVTimecode *tc, AVRational f
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
-static int get_frame_timecode(AVFormatContext *avctx, decklink_ctx *ctx, AVTimecode *tc, IDeckLinkVideoInputFrame_v14_2_1 *videoFrame)
|
||||||
|
+static int get_frame_timecode(AVFormatContext *avctx, decklink_ctx *ctx, AVTimecode *tc, IDeckLinkVideoInputFrame *videoFrame)
|
||||||
|
{
|
||||||
|
AVRational frame_rate = ctx->video_st->r_frame_rate;
|
||||||
|
int ret;
|
||||||
|
@@ -757,7 +726,7 @@ static int get_frame_timecode(AVFormatContext *avctx, decklink_ctx *ctx, AVTimec
|
||||||
|
}
|
||||||
|
|
||||||
|
HRESULT decklink_input_callback::VideoInputFrameArrived(
|
||||||
|
- IDeckLinkVideoInputFrame_v14_2_1 *videoFrame, IDeckLinkAudioInputPacket *audioFrame)
|
||||||
|
+ IDeckLinkVideoInputFrame *videoFrame, IDeckLinkAudioInputPacket *audioFrame)
|
||||||
|
{
|
||||||
|
void *frameBytes;
|
||||||
|
void *audioFrameBytes;
|
||||||
|
@@ -1172,7 +1141,7 @@ av_cold int ff_decklink_read_header(AVFormatContext *avctx)
|
||||||
|
goto error;
|
||||||
|
|
||||||
|
/* Get input device. */
|
||||||
|
- if (ctx->dl->QueryInterface(IID_IDeckLinkInput_v14_2_1, (void **) &ctx->dli) != S_OK) {
|
||||||
|
+ if (ctx->dl->QueryInterface(IID_IDeckLinkInput, (void **) &ctx->dli) != S_OK) {
|
||||||
|
av_log(avctx, AV_LOG_ERROR, "Could not open input device from '%s'\n",
|
||||||
|
avctx->url);
|
||||||
|
ret = AVERROR(EIO);
|
||||||
|
diff --git a/libavdevice/decklink_enc.cpp b/libavdevice/decklink_enc.cpp
|
||||||
|
index d2e246c..cb8f917 100644
|
||||||
|
--- a/libavdevice/decklink_enc.cpp
|
||||||
|
+++ b/libavdevice/decklink_enc.cpp
|
||||||
|
@@ -28,11 +28,7 @@ extern "C" {
|
||||||
|
#include "libavformat/internal.h"
|
||||||
|
}
|
||||||
|
|
||||||
|
-#include <DeckLinkAPIVersion.h>
|
||||||
|
#include <DeckLinkAPI.h>
|
||||||
|
-#if BLACKMAGIC_DECKLINK_API_VERSION >= 0x0e030000
|
||||||
|
-#include <DeckLinkAPI_v14_2_1.h>
|
||||||
|
-#endif
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
#include "libavformat/avformat.h"
|
||||||
|
@@ -52,7 +48,7 @@ extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* DeckLink callback class declaration */
|
||||||
|
-class decklink_frame : public IDeckLinkVideoFrame_v14_2_1
|
||||||
|
+class decklink_frame : public IDeckLinkVideoFrame
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
decklink_frame(struct decklink_ctx *ctx, AVFrame *avframe, AVCodecID codec_id, int height, int width) :
|
||||||
|
@@ -115,20 +111,7 @@ public:
|
||||||
|
_ancillary->AddRef();
|
||||||
|
return S_OK;
|
||||||
|
}
|
||||||
|
- virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, LPVOID *ppv)
|
||||||
|
- {
|
||||||
|
- if (DECKLINK_IsEqualIID(riid, IID_IUnknown)) {
|
||||||
|
- *ppv = static_cast<IUnknown*>(this);
|
||||||
|
- } else if (DECKLINK_IsEqualIID(riid, IID_IDeckLinkVideoFrame_v14_2_1)) {
|
||||||
|
- *ppv = static_cast<IDeckLinkVideoFrame_v14_2_1*>(this);
|
||||||
|
- } else {
|
||||||
|
- *ppv = NULL;
|
||||||
|
- return E_NOINTERFACE;
|
||||||
|
- }
|
||||||
|
-
|
||||||
|
- AddRef();
|
||||||
|
- return S_OK;
|
||||||
|
- }
|
||||||
|
+ virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, LPVOID *ppv) { return E_NOINTERFACE; }
|
||||||
|
virtual ULONG STDMETHODCALLTYPE AddRef(void) { return ++_refs; }
|
||||||
|
virtual ULONG STDMETHODCALLTYPE Release(void)
|
||||||
|
{
|
||||||
|
@@ -155,10 +138,10 @@ private:
|
||||||
|
std::atomic<int> _refs;
|
||||||
|
};
|
||||||
|
|
||||||
|
-class decklink_output_callback : public IDeckLinkVideoOutputCallback_v14_2_1
|
||||||
|
+class decklink_output_callback : public IDeckLinkVideoOutputCallback
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
- virtual HRESULT STDMETHODCALLTYPE ScheduledFrameCompleted(IDeckLinkVideoFrame_v14_2_1 *_frame, BMDOutputFrameCompletionResult result)
|
||||||
|
+ virtual HRESULT STDMETHODCALLTYPE ScheduledFrameCompleted(IDeckLinkVideoFrame *_frame, BMDOutputFrameCompletionResult result)
|
||||||
|
{
|
||||||
|
decklink_frame *frame = static_cast<decklink_frame *>(_frame);
|
||||||
|
struct decklink_ctx *ctx = frame->_ctx;
|
||||||
|
@@ -176,20 +159,7 @@ public:
|
||||||
|
return S_OK;
|
||||||
|
}
|
||||||
|
virtual HRESULT STDMETHODCALLTYPE ScheduledPlaybackHasStopped(void) { return S_OK; }
|
||||||
|
- virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, LPVOID *ppv)
|
||||||
|
- {
|
||||||
|
- if (DECKLINK_IsEqualIID(riid, IID_IUnknown)) {
|
||||||
|
- *ppv = static_cast<IUnknown*>(this);
|
||||||
|
- } else if (DECKLINK_IsEqualIID(riid, IID_IDeckLinkVideoOutputCallback_v14_2_1)) {
|
||||||
|
- *ppv = static_cast<IDeckLinkVideoOutputCallback_v14_2_1*>(this);
|
||||||
|
- } else {
|
||||||
|
- *ppv = NULL;
|
||||||
|
- return E_NOINTERFACE;
|
||||||
|
- }
|
||||||
|
-
|
||||||
|
- AddRef();
|
||||||
|
- return S_OK;
|
||||||
|
- }
|
||||||
|
+ virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, LPVOID *ppv) { return E_NOINTERFACE; }
|
||||||
|
virtual ULONG STDMETHODCALLTYPE AddRef(void) { return 1; }
|
||||||
|
virtual ULONG STDMETHODCALLTYPE Release(void) { return 1; }
|
||||||
|
};
|
||||||
|
@@ -769,7 +739,7 @@ static int decklink_write_video_packet(AVFormatContext *avctx, AVPacket *pkt)
|
||||||
|
ctx->first_pts = pkt->pts;
|
||||||
|
|
||||||
|
/* Schedule frame for playback. */
|
||||||
|
- hr = ctx->dlo->ScheduleVideoFrame(frame,
|
||||||
|
+ hr = ctx->dlo->ScheduleVideoFrame((class IDeckLinkVideoFrame *) frame,
|
||||||
|
pkt->pts * ctx->bmd_tb_num,
|
||||||
|
ctx->bmd_tb_num, ctx->bmd_tb_den);
|
||||||
|
/* Pass ownership to DeckLink, or release on failure */
|
||||||
|
@@ -904,7 +874,7 @@ av_cold int ff_decklink_write_header(AVFormatContext *avctx)
|
||||||
|
return ret;
|
||||||
|
|
||||||
|
/* Get output device. */
|
||||||
|
- if (ctx->dl->QueryInterface(IID_IDeckLinkOutput_v14_2_1, (void **) &ctx->dlo) != S_OK) {
|
||||||
|
+ if (ctx->dl->QueryInterface(IID_IDeckLinkOutput, (void **) &ctx->dlo) != S_OK) {
|
||||||
|
av_log(avctx, AV_LOG_ERROR, "Could not open output device from '%s'\n",
|
||||||
|
avctx->url);
|
||||||
|
ret = AVERROR(EIO);
|
||||||
21
services/capture/patch_decklink.py
Normal file
21
services/capture/patch_decklink.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# Apply the upstream FFmpeg master decklink SDK-16 compatibility patch on top
|
||||||
|
# of the release/7.1 source. The patch renames every IDeckLink* interface and
|
||||||
|
# helper to its _v14_2_1 versioned form so the call sites keep working against
|
||||||
|
# SDK 16's headers (which only retain the versioned aliases). Cherry-picking
|
||||||
|
# individual replacements like the previous regex patch produced inconsistent
|
||||||
|
# code that compiled but silently dropped every video frame.
|
||||||
|
import subprocess, sys, pathlib
|
||||||
|
patch = pathlib.Path('/decklink-sdk16.patch')
|
||||||
|
if not patch.exists():
|
||||||
|
print('FATAL: /decklink-sdk16.patch not found in build context', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
# Patch was produced as `git diff HEAD FETCH_HEAD` where HEAD=release/7.1 and
|
||||||
|
# FETCH_HEAD=master, so we apply it in REVERSE to move 7.1 → master.
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', 'apply', '-R', '--verbose', str(patch)],
|
||||||
|
cwd='/ffmpeg', capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
print(result.stdout)
|
||||||
|
print(result.stderr, file=sys.stderr)
|
||||||
|
sys.exit(result.returncode)
|
||||||
|
|
@ -1,9 +1,112 @@
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { createUploadStream } from './s3/client.js';
|
import { createUploadStream } from './s3/client.js';
|
||||||
|
|
||||||
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
|
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
|
||||||
|
|
||||||
|
// Growing-files mode: writes the master to a local SMB-backed share that the
|
||||||
|
// editor can mount, instead of streaming to S3 in real time. The promotion
|
||||||
|
// worker uploads the finalized file to S3 after the recording stops.
|
||||||
|
// Toggled per-process by `GROWING_ENABLED=true` on the capture container
|
||||||
|
// (see routes/recorders.js where the env is composed).
|
||||||
|
const GROWING_ENABLED = process.env.GROWING_ENABLED === 'true';
|
||||||
|
const GROWING_PATH = process.env.GROWING_PATH || '/growing';
|
||||||
|
|
||||||
|
// ── Codec catalogue ──────────────────────────────────────────────────────
|
||||||
|
// Each entry maps the UI value to a base ffmpeg flag set. Bitrate / framerate
|
||||||
|
// / pix_fmt are layered on top from the per-recorder configuration.
|
||||||
|
const VIDEO_CODECS = {
|
||||||
|
prores_hq: { args: ['-c:v', 'prores_ks', '-profile:v', '3'], bitrateControl: false, pixFmt: 'yuv422p10le' },
|
||||||
|
prores_422: { args: ['-c:v', 'prores_ks', '-profile:v', '2'], bitrateControl: false, pixFmt: 'yuv422p10le' },
|
||||||
|
prores_lt: { args: ['-c:v', 'prores_ks', '-profile:v', '1'], bitrateControl: false, pixFmt: 'yuv422p10le' },
|
||||||
|
prores_proxy: { args: ['-c:v', 'prores_ks', '-profile:v', '0'], bitrateControl: false, pixFmt: 'yuv422p10le' },
|
||||||
|
dnxhd: { args: ['-c:v', 'dnxhd'], bitrateControl: true, pixFmt: 'yuv422p' },
|
||||||
|
dnxhr_hq: { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_hq'], bitrateControl: false, pixFmt: 'yuv422p' },
|
||||||
|
dnxhr_sq: { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_sq'], bitrateControl: false, pixFmt: 'yuv422p' },
|
||||||
|
h264: { args: ['-c:v', 'libx264', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' },
|
||||||
|
h264_nvenc: { args: ['-c:v', 'h264_nvenc', '-preset', 'p5'], bitrateControl: true, pixFmt: 'yuv420p' },
|
||||||
|
h265: { args: ['-c:v', 'libx265', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' },
|
||||||
|
// All-Intra HEVC on NVENC — the growing-file master codec.
|
||||||
|
// Goal: every frame an IDR (all-intra), so a still-growing file is decodable
|
||||||
|
// to its last complete frame — the prerequisite for edit-while-record.
|
||||||
|
//
|
||||||
|
// NVENC will NOT accept `-g 1`: InitializeEncoder enforces
|
||||||
|
// "GopLength > numBFrames + 1", so with -bf 0 the minimum GOP is 2 and -g 1
|
||||||
|
// is rejected with EINVAL (validated on the L4, driver 595). The working
|
||||||
|
// recipe for true all-intra is therefore:
|
||||||
|
// -bf 0 no B-frames
|
||||||
|
// -g 600 large GOP just to satisfy the init check
|
||||||
|
// -forced-idr 1 forced keyframes are emitted as IDR
|
||||||
|
// -force_key_frames expr:1 force a keyframe on EVERY frame
|
||||||
|
// → ffprobe confirms pict_type = I for all frames.
|
||||||
|
//
|
||||||
|
// Container: fragmented MOV (+frag_keyframe+empty_moov+default_base_moof),
|
||||||
|
// NOT MXF — this ffmpeg's MXF muxer rejects HEVC ("Operation not permitted").
|
||||||
|
// The frag-MOV index is not deferred to EOF, so the file stays readable while
|
||||||
|
// growing. (See docs/design/2026-05-29-all-intra-hevc-ingest.md §8.)
|
||||||
|
//
|
||||||
|
// -profile:v main10 / p010le: 10-bit 4:2:0 — the closest NVENC HEVC can get
|
||||||
|
// to ProRes 4:2:2 10-bit. If strict 4:2:2 is required, use prores_hq (CPU).
|
||||||
|
hevc_nvenc: {
|
||||||
|
args: ['-c:v', 'hevc_nvenc', '-preset', 'p4', '-rc', 'vbr', '-bf', '0', '-forced-idr', '1', '-g', '600', '-force_key_frames', 'expr:1', '-profile:v', 'main10'],
|
||||||
|
bitrateControl: true,
|
||||||
|
pixFmt: 'p010le',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const AUDIO_CODECS = {
|
||||||
|
pcm_s16le: { args: ['-c:a', 'pcm_s16le'], bitrateControl: false },
|
||||||
|
pcm_s24le: { args: ['-c:a', 'pcm_s24le'], bitrateControl: false },
|
||||||
|
pcm_s32le: { args: ['-c:a', 'pcm_s32le'], bitrateControl: false },
|
||||||
|
aac: { args: ['-c:a', 'aac'], bitrateControl: true },
|
||||||
|
ac3: { args: ['-c:a', 'ac3'], bitrateControl: true },
|
||||||
|
opus: { args: ['-c:a', 'libopus'], bitrateControl: true },
|
||||||
|
flac: { args: ['-c:a', 'flac'], bitrateControl: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONTAINER_FMT = {
|
||||||
|
mov: 'mov',
|
||||||
|
mp4: 'mp4',
|
||||||
|
mkv: 'matroska',
|
||||||
|
mxf: 'mxf',
|
||||||
|
ts: 'mpegts',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONTAINER_EXT = {
|
||||||
|
mov: 'mov', mp4: 'mp4', mkv: 'mkv', mxf: 'mxf', ts: 'ts',
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildEncodeArgs({
|
||||||
|
codec, videoBitrate, framerate,
|
||||||
|
audioCodec, audioBitrate, audioChannels,
|
||||||
|
container, isNetwork, isProxy = false,
|
||||||
|
}) {
|
||||||
|
const v = VIDEO_CODECS[codec] || (isProxy ? VIDEO_CODECS.h264 : VIDEO_CODECS.prores_hq);
|
||||||
|
const a = AUDIO_CODECS[audioCodec] || (isProxy ? AUDIO_CODECS.aac : AUDIO_CODECS.pcm_s24le);
|
||||||
|
const fmt = CONTAINER_FMT[container] || (isProxy ? 'mp4' : 'mov');
|
||||||
|
|
||||||
|
const args = [];
|
||||||
|
if (isNetwork) args.push('-map', '0:v:0?', '-map', '0:a:0?');
|
||||||
|
|
||||||
|
args.push(...v.args);
|
||||||
|
if (v.pixFmt) args.push('-pix_fmt', v.pixFmt);
|
||||||
|
if (v.bitrateControl && videoBitrate) args.push('-b:v', videoBitrate);
|
||||||
|
if (framerate && framerate !== 'native') args.push('-r', framerate);
|
||||||
|
|
||||||
|
args.push(...a.args);
|
||||||
|
if (a.bitrateControl && audioBitrate) args.push('-b:a', audioBitrate);
|
||||||
|
if (audioChannels) args.push('-ac', String(audioChannels));
|
||||||
|
|
||||||
|
if (fmt === 'mov' || fmt === 'mp4') {
|
||||||
|
args.push('-movflags', '+frag_keyframe+empty_moov');
|
||||||
|
}
|
||||||
|
args.push('-f', fmt);
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
class CaptureManager {
|
class CaptureManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.state = {
|
this.state = {
|
||||||
|
|
@ -11,6 +114,10 @@ class CaptureManager {
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
processes: {},
|
processes: {},
|
||||||
currentSession: {},
|
currentSession: {},
|
||||||
|
framesReceived: 0,
|
||||||
|
currentFps: 0,
|
||||||
|
lastFrameAt: null,
|
||||||
|
lastError: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -19,20 +126,19 @@ class CaptureManager {
|
||||||
* Returns { inputArgs, isNetwork }
|
* Returns { inputArgs, isNetwork }
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_buildInputArgs({ sourceType, device, sourceUrl, listen, listenPort, streamKey }) {
|
async _buildInputArgs({ sourceType, device, sourceUrl, listen, listenPort, streamKey }) {
|
||||||
if (sourceType === 'srt') {
|
if (sourceType === 'srt') {
|
||||||
let url;
|
let url;
|
||||||
if (listen) {
|
if (listen) {
|
||||||
const port = listenPort || 9000;
|
const port = listenPort || 9000;
|
||||||
url = `srt://0.0.0.0:${port}?mode=listener`;
|
url = `srt://0.0.0.0:${port}?mode=listener`;
|
||||||
} else {
|
} else {
|
||||||
// Caller mode — ensure mode=caller is appended if not already present
|
|
||||||
url = sourceUrl;
|
url = sourceUrl;
|
||||||
if (!url.includes('mode=')) {
|
if (!url.includes('mode=')) {
|
||||||
url += (url.includes('?') ? '&' : '?') + 'mode=caller';
|
url += (url.includes('?') ? '&' : '?') + 'mode=caller';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { inputArgs: ['-i', url], isNetwork: true };
|
return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', url], isNetwork: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sourceType === 'rtmp') {
|
if (sourceType === 'rtmp') {
|
||||||
|
|
@ -40,33 +146,104 @@ class CaptureManager {
|
||||||
const port = listenPort || 1935;
|
const port = listenPort || 1935;
|
||||||
const key = streamKey || 'stream';
|
const key = streamKey || 'stream';
|
||||||
return {
|
return {
|
||||||
inputArgs: ['-listen', '1', '-i', `rtmp://0.0.0.0:${port}/live/${key}`],
|
inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-listen', '1', '-i', `rtmp://0.0.0.0:${port}/live/${key}`],
|
||||||
isNetwork: true,
|
isNetwork: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { inputArgs: ['-i', sourceUrl], isNetwork: true };
|
return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', sourceUrl], isNetwork: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deltacast SDI via VideoMaster SDK FFmpeg plugin.
|
||||||
|
// FFmpeg input format is 'deltacast', device address is 'deltacast://<index>'.
|
||||||
|
// When the physical device is absent (/dev/deltacast<N> missing), fall back
|
||||||
|
// to a lavfi test card so development and integration testing work without hardware.
|
||||||
|
if (sourceType === 'deltacast') {
|
||||||
|
const idx = (typeof device === 'number' || /^\d+$/.test(String(device)))
|
||||||
|
? parseInt(device, 10)
|
||||||
|
: 0;
|
||||||
|
const { existsSync } = await import('node:fs');
|
||||||
|
const deviceNode = `/dev/deltacast${idx}`;
|
||||||
|
if (existsSync(deviceNode)) {
|
||||||
|
console.log(`[capture] Deltacast index ${idx} → ${deviceNode} (hardware)`);
|
||||||
|
return {
|
||||||
|
inputArgs: ['-f', 'deltacast', '-i', `deltacast://${idx}`],
|
||||||
|
isNetwork: false,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// No hardware — lavfi test card with port label + timecode burn-in.
|
||||||
|
// Matches the deltacast-sdi-recorder standalone app fallback exactly so
|
||||||
|
// recorded files look right in the MAM library during dev.
|
||||||
|
console.warn(`[capture] Deltacast device ${deviceNode} not found — using lavfi test card for port ${idx}`);
|
||||||
|
const testSrc = [
|
||||||
|
`testsrc2=size=1920x1080:rate=30`,
|
||||||
|
`drawtext=text='DELTACAST PORT ${idx} — TEST MODE':fontsize=48:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2`,
|
||||||
|
`drawtext=text='%{localtime\\:%H\\:%M\\:%S}':fontsize=32:fontcolor=yellow:x=10:y=10`,
|
||||||
|
].join(',');
|
||||||
|
return {
|
||||||
|
inputArgs: [
|
||||||
|
'-f', 'lavfi', '-i', testSrc,
|
||||||
|
'-f', 'lavfi', '-i', 'sine=frequency=1000:sample_rate=48000',
|
||||||
|
'-map', '0:v:0', '-map', '1:a:0',
|
||||||
|
],
|
||||||
|
isNetwork: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: SDI via DeckLink
|
// Default: SDI via DeckLink
|
||||||
|
// device may be an integer index (0-based) or a full device name string.
|
||||||
|
// FFmpeg 7.x DeckLink requires the full name (e.g. 'DeckLink Duo 2 (2)').
|
||||||
|
// Map integer index -> name using ffmpeg -sources decklink at runtime.
|
||||||
|
//
|
||||||
|
// ffmpeg -sources decklink output format:
|
||||||
|
// Auto-detected sources for decklink:
|
||||||
|
// DeckLink Duo 2
|
||||||
|
// DeckLink Duo 2 (2)
|
||||||
|
// Lines containing device names start with whitespace; the header line
|
||||||
|
// starts with a non-space character. Previous code used a v4l2-style
|
||||||
|
// hex-address regex that never matched DeckLink output → index 1+ always
|
||||||
|
// fell through to a wrong fallback, producing black output from port 2+.
|
||||||
|
let deckLinkName = String(device);
|
||||||
|
if (typeof device === 'number' || /^\d+$/.test(String(device))) {
|
||||||
|
const idx = parseInt(device, 10);
|
||||||
|
try {
|
||||||
|
const { execSync } = await import('child_process');
|
||||||
|
const out = execSync('ffmpeg -hide_banner -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 });
|
||||||
|
const names = [];
|
||||||
|
for (const line of out.split('\n')) {
|
||||||
|
// DeckLink source lines: " 81:76669a80:00000000 [DeckLink Duo (1)] (none)"
|
||||||
|
const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
|
||||||
|
if (m) names.push(m[1]);
|
||||||
|
}
|
||||||
|
if (names[idx]) {
|
||||||
|
deckLinkName = names[idx];
|
||||||
|
console.log(`[capture] DeckLink index ${idx} → "${deckLinkName}" (from ${names.length} detected: ${names.join(', ')})`);
|
||||||
|
} else {
|
||||||
|
// Fallback: cannot determine model name without enumeration.
|
||||||
|
// Log a warning — operator should check the detected device list.
|
||||||
|
console.warn(`[capture] DeckLink index ${idx} out of range (detected ${names.length} devices: ${names.join(', ')}). Falling back to index-only input — capture may fail.`);
|
||||||
|
deckLinkName = `DeckLink (${idx})`;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[capture] ffmpeg -sources decklink failed: ${err.message}. Using index ${device} directly.`);
|
||||||
|
// Pass the numeric index directly; some ffmpeg builds accept it.
|
||||||
|
deckLinkName = String(device);
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
inputArgs: ['-f', 'decklink', '-i', String(device)],
|
inputArgs: ['-f', 'decklink', '-i', deckLinkName],
|
||||||
isNetwork: false,
|
isNetwork: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start a new capture session
|
* Start a new capture session.
|
||||||
* @param {Object} params
|
*
|
||||||
* - projectId, binId, clipName — always required
|
* Codec parameters all have sensible defaults so legacy callers (no codec
|
||||||
* - device — DeckLink device index (SDI only)
|
* args) still produce ProRes HQ master + H.264 proxy.
|
||||||
* - sourceType — 'sdi' | 'srt' | 'rtmp' (default: 'sdi')
|
|
||||||
* - sourceUrl — URL for caller mode (SRT/RTMP caller)
|
|
||||||
* - listen — true for listener/server mode
|
|
||||||
* - listenPort — port to bind in listener mode
|
|
||||||
* - streamKey — RTMP stream key for listener mode
|
|
||||||
* @returns {Object} Session info
|
|
||||||
*/
|
*/
|
||||||
async start({
|
async start({
|
||||||
|
assetId,
|
||||||
projectId,
|
projectId,
|
||||||
binId,
|
binId,
|
||||||
clipName,
|
clipName,
|
||||||
|
|
@ -76,96 +253,172 @@ class CaptureManager {
|
||||||
listen = false,
|
listen = false,
|
||||||
listenPort,
|
listenPort,
|
||||||
streamKey,
|
streamKey,
|
||||||
|
// ── Recording codec ─────────────────────────────────────────────
|
||||||
|
videoCodec = 'prores_hq',
|
||||||
|
videoBitrate = null,
|
||||||
|
framerate = null,
|
||||||
|
audioCodec = 'pcm_s24le',
|
||||||
|
audioBitrate = null,
|
||||||
|
audioChannels = 2,
|
||||||
|
container = 'mov',
|
||||||
|
// ── Proxy codec ─────────────────────────────────────────────────
|
||||||
|
proxyEnabled = true,
|
||||||
|
proxyVideoCodec = 'h264',
|
||||||
|
proxyVideoBitrate = '8M',
|
||||||
|
proxyFramerate = null,
|
||||||
|
proxyAudioCodec = 'aac',
|
||||||
|
proxyAudioBitrate = '192k',
|
||||||
|
proxyAudioChannels = 2,
|
||||||
|
proxyContainer = 'mp4',
|
||||||
}) {
|
}) {
|
||||||
|
this._assetIdForHls = assetId || null;
|
||||||
if (this.state.recording) {
|
if (this.state.recording) {
|
||||||
throw new Error('Capture already in progress');
|
throw new Error('Capture already in progress');
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = uuidv4();
|
const sessionId = uuidv4();
|
||||||
const hiresKey = `projects/${projectId}/masters/${clipName}.mov`;
|
const hiresExt = CONTAINER_EXT[container] || 'mov';
|
||||||
|
const proxyExt = CONTAINER_EXT[proxyContainer] || 'mp4';
|
||||||
|
const hiresKey = `projects/${projectId}/masters/${clipName}.${hiresExt}`;
|
||||||
|
|
||||||
// Network sources cannot be opened by two FFmpeg processes simultaneously.
|
// Growing-files: write master to the local SMB share instead of streaming
|
||||||
// proxyKey is null for SRT/RTMP — the BullMQ worker generates the proxy
|
// to S3. Path is relative to the container's GROWING_PATH mount.
|
||||||
// after the recording stops (same pipeline used for uploaded files).
|
const growingPath = GROWING_ENABLED
|
||||||
const proxyKey = sourceType === 'sdi'
|
? `${GROWING_PATH}/${projectId}/${clipName}.${hiresExt}`
|
||||||
? `projects/${projectId}/proxies/${clipName}.mp4`
|
|
||||||
: null;
|
: null;
|
||||||
|
if (growingPath) {
|
||||||
|
try { mkdirSync(dirname(growingPath), { recursive: true }); }
|
||||||
|
catch (err) { console.error('[capture] could not create growing dir:', err.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeckLink hardware does NOT support concurrent capture from the same port.
|
||||||
|
// Opening a second ffmpeg process on the same DeckLink input while the first
|
||||||
|
// is already capturing causes "Cannot Autodetect input stream or No signal"
|
||||||
|
// on the second process — making the proxy empty and potentially crashing the
|
||||||
|
// container before the hires upload completes.
|
||||||
|
//
|
||||||
|
// Treat SDI the same as SRT/RTMP: set proxyKey=null here and let the BullMQ
|
||||||
|
// worker generate the proxy from the hires master after the recording stops.
|
||||||
|
// The stop handler sets needsProxy=true so the worker picks it up.
|
||||||
|
const proxyKey = null;
|
||||||
|
|
||||||
const startedAt = new Date().toISOString();
|
const startedAt = new Date().toISOString();
|
||||||
|
|
||||||
const { inputArgs, isNetwork } = this._buildInputArgs({
|
const { inputArgs, isNetwork } = await this._buildInputArgs({
|
||||||
sourceType,
|
sourceType, device, sourceUrl, listen, listenPort, streamKey,
|
||||||
device,
|
|
||||||
sourceUrl,
|
|
||||||
listen,
|
|
||||||
listenPort,
|
|
||||||
streamKey,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ProRes hires — fragmented moov for pipe-safe output on network sources
|
const hiresCodecArgs = buildEncodeArgs({
|
||||||
const hiresCodecArgs = isNetwork
|
codec: videoCodec, videoBitrate, framerate,
|
||||||
? [
|
audioCodec, audioBitrate, audioChannels,
|
||||||
'-c:v', 'prores_ks',
|
container,
|
||||||
'-profile:v', '3',
|
isNetwork,
|
||||||
'-c:a', 'pcm_s24le',
|
isProxy: false,
|
||||||
'-movflags', '+frag_keyframe+empty_moov',
|
|
||||||
'-f', 'mov',
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
'-c:v', 'prores_ks',
|
|
||||||
'-profile:v', '3',
|
|
||||||
'-c:a', 'pcm_s24le',
|
|
||||||
'-f', 'mov',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Spawn hires FFmpeg process
|
|
||||||
const hiresProcess = spawn('ffmpeg', [
|
|
||||||
...inputArgs,
|
|
||||||
...hiresCodecArgs,
|
|
||||||
'pipe:1',
|
|
||||||
], {
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const hiresUpload = createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout);
|
console.log('[capture] hires ffmpeg args:', hiresCodecArgs.join(' '));
|
||||||
|
|
||||||
|
const sdiFilterArgs = (sourceType === 'sdi') ? ['-vf', 'yadif=mode=1:deint=1'] : [];
|
||||||
|
|
||||||
|
// When growing-files is on, write directly to the SMB share so Premier
|
||||||
|
// can mount and edit the live file. Promotion worker uploads to S3 on EOF.
|
||||||
|
// Otherwise, stream the master to S3 via stdout pipe (legacy behavior).
|
||||||
|
const hiresOutput = growingPath ? growingPath : 'pipe:1';
|
||||||
|
const hiresStdio = growingPath ? ['ignore', 'ignore', 'pipe'] : ['ignore', 'pipe', 'pipe'];
|
||||||
|
|
||||||
|
// For SDI we cannot open the DeckLink device a second time for a preview
|
||||||
|
// tee, so the live HLS preview is produced as a SECOND OUTPUT of the hires
|
||||||
|
// ffmpeg: one decklink read -> yadif -> split -> [ProRes/S3] + [H.264/HLS].
|
||||||
|
let sdiHlsDir = null;
|
||||||
|
let hiresArgs;
|
||||||
|
if (sourceType === 'sdi' && this._assetIdForHls) {
|
||||||
|
const fsMod = await import('node:fs');
|
||||||
|
sdiHlsDir = '/live/' + this._assetIdForHls;
|
||||||
|
try { fsMod.mkdirSync(sdiHlsDir, { recursive: true }); } catch (_) {}
|
||||||
|
hiresArgs = [
|
||||||
|
...inputArgs,
|
||||||
|
'-filter_complex', '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]',
|
||||||
|
// Output 0 — ProRes master (S3 pipe or growing file)
|
||||||
|
'-map', '[vhi]', '-map', '0:a:0?',
|
||||||
|
...hiresCodecArgs,
|
||||||
|
hiresOutput,
|
||||||
|
// Output 1 — low-latency H.264 HLS preview for the UI monitor
|
||||||
|
'-map', '[vlo]', '-map', '0:a:0?',
|
||||||
|
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
|
||||||
|
'-pix_fmt', 'yuv420p', '-b:v', '2M', '-g', '60', '-sc_threshold', '0',
|
||||||
|
'-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
|
||||||
|
'-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
|
||||||
|
'-hls_flags', 'delete_segments+append_list+omit_endlist',
|
||||||
|
'-hls_segment_filename', sdiHlsDir + '/seg-%05d.ts',
|
||||||
|
sdiHlsDir + '/index.m3u8',
|
||||||
|
];
|
||||||
|
console.log('[HLS] SDI preview as 2nd output -> ' + sdiHlsDir);
|
||||||
|
} else {
|
||||||
|
hiresArgs = [ ...inputArgs, ...sdiFilterArgs, ...hiresCodecArgs, hiresOutput ];
|
||||||
|
}
|
||||||
|
|
||||||
|
const hiresProcess = spawn('ffmpeg', hiresArgs, { stdio: hiresStdio });
|
||||||
|
|
||||||
|
const hiresUpload = growingPath
|
||||||
|
? Promise.resolve({ growingPath })
|
||||||
|
: createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout);
|
||||||
|
|
||||||
const processes = { hires: hiresProcess };
|
const processes = { hires: hiresProcess };
|
||||||
const uploads = { hires: hiresUpload };
|
const uploads = { hires: hiresUpload };
|
||||||
|
|
||||||
|
// ── HLS tee for network sources (live preview in the UI) ──────────
|
||||||
|
let hlsProcess = null;
|
||||||
|
let hlsDir = null;
|
||||||
|
if (isNetwork && this._assetIdForHls) {
|
||||||
|
try {
|
||||||
|
const fs = await import('node:fs');
|
||||||
|
hlsDir = '/live/' + this._assetIdForHls;
|
||||||
|
fs.mkdirSync(hlsDir, { recursive: true });
|
||||||
|
const hlsArgs = [
|
||||||
|
...inputArgs,
|
||||||
|
'-map', '0:v:0?', '-map', '0:a:0?',
|
||||||
|
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
|
||||||
|
'-pix_fmt', 'yuv420p', '-b:v', '2M', '-g', '60', '-sc_threshold', '0',
|
||||||
|
'-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
|
||||||
|
'-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
|
||||||
|
'-hls_flags', 'delete_segments+append_list+omit_endlist',
|
||||||
|
'-hls_segment_filename', hlsDir + '/seg-%05d.ts',
|
||||||
|
hlsDir + '/index.m3u8',
|
||||||
|
];
|
||||||
|
hlsProcess = spawn('ffmpeg', hlsArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
hlsProcess.stderr.on('data', (d) => { console.error('[HLS] ' + d); });
|
||||||
|
hlsProcess.on('exit', (c) => console.log('[HLS] exited ' + c));
|
||||||
|
processes.hls = hlsProcess;
|
||||||
|
console.log('[HLS] tee started -> ' + hlsDir);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[HLS] tee failed:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
hiresProcess.stderr.on('data', (data) => {
|
hiresProcess.stderr.on('data', (data) => {
|
||||||
console.error(`[HIRES] ${data}`);
|
const text = data.toString();
|
||||||
|
console.error(`[HIRES] ${text}`);
|
||||||
|
const m = text.match(/frame=\s*(\d+)\s+fps=\s*([\d.]+)/);
|
||||||
|
if (m) {
|
||||||
|
this.state.framesReceived = parseInt(m[1], 10);
|
||||||
|
this.state.currentFps = parseFloat(m[2]);
|
||||||
|
this.state.lastFrameAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
if (/Connection refused|No route to host|Connection failed|Input\/output error|Server returned|404 Not Found|Connection timed out/i.test(text)) {
|
||||||
|
this.state.lastError = text.trim().slice(0, 240);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// SDI only: spawn a second FFmpeg process for the proxy.
|
// Proxy is generated after stop by the BullMQ worker (same as SRT/RTMP).
|
||||||
// DeckLink cards can be opened simultaneously by multiple processes;
|
// DeckLink hardware does not support two concurrent readers on the same port.
|
||||||
// network streams cannot.
|
|
||||||
if (!isNetwork) {
|
|
||||||
const proxyProcess = spawn('ffmpeg', [
|
|
||||||
...inputArgs,
|
|
||||||
'-c:v', 'libx264',
|
|
||||||
'-preset', 'fast',
|
|
||||||
'-b:v', '10M',
|
|
||||||
'-c:a', 'aac',
|
|
||||||
'-b:a', '192k',
|
|
||||||
'-movflags', '+frag_keyframe+empty_moov',
|
|
||||||
'-f', 'mp4',
|
|
||||||
'pipe:1',
|
|
||||||
], {
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const proxyUpload = createUploadStream(S3_BUCKET, proxyKey, proxyProcess.stdout);
|
|
||||||
processes.proxy = proxyProcess;
|
|
||||||
uploads.proxy = proxyUpload;
|
|
||||||
|
|
||||||
proxyProcess.stderr.on('data', (data) => {
|
|
||||||
console.error(`[PROXY] ${data}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state.recording = true;
|
this.state.recording = true;
|
||||||
this.state.sessionId = sessionId;
|
this.state.sessionId = sessionId;
|
||||||
this.state.processes = processes;
|
this.state.processes = processes;
|
||||||
|
this.state.framesReceived = 0;
|
||||||
|
this.state.currentFps = 0;
|
||||||
|
this.state.lastFrameAt = null;
|
||||||
|
this.state.lastError = null;
|
||||||
this.state.currentSession = {
|
this.state.currentSession = {
|
||||||
sessionId,
|
sessionId,
|
||||||
projectId,
|
projectId,
|
||||||
|
|
@ -176,19 +429,21 @@ class CaptureManager {
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
hiresKey,
|
hiresKey,
|
||||||
proxyKey,
|
proxyKey,
|
||||||
|
growingPath,
|
||||||
startedAt,
|
startedAt,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
uploads,
|
uploads,
|
||||||
|
codecs: {
|
||||||
|
videoCodec, videoBitrate, framerate,
|
||||||
|
audioCodec, audioBitrate, audioChannels, container,
|
||||||
|
proxyEnabled, proxyVideoCodec, proxyVideoBitrate,
|
||||||
|
proxyAudioCodec, proxyAudioBitrate, proxyAudioChannels, proxyContainer,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return this._formatSessionResponse();
|
return this._formatSessionResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the current capture session
|
|
||||||
* @param {string} sessionId - Session ID to stop
|
|
||||||
* @returns {Object} Completed session info
|
|
||||||
*/
|
|
||||||
async stop(sessionId) {
|
async stop(sessionId) {
|
||||||
if (!this.state.recording || this.state.sessionId !== sessionId) {
|
if (!this.state.recording || this.state.sessionId !== sessionId) {
|
||||||
throw new Error('No active capture session or session ID mismatch');
|
throw new Error('No active capture session or session ID mismatch');
|
||||||
|
|
@ -196,20 +451,13 @@ class CaptureManager {
|
||||||
|
|
||||||
const { processes, currentSession } = this.state;
|
const { processes, currentSession } = this.state;
|
||||||
|
|
||||||
// Gracefully terminate all FFmpeg processes
|
if (processes.hires) processes.hires.kill('SIGINT');
|
||||||
if (processes.hires) {
|
if (processes.proxy) processes.proxy.kill('SIGINT');
|
||||||
processes.hires.kill('SIGINT');
|
if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} }
|
||||||
}
|
|
||||||
if (processes.proxy) {
|
|
||||||
processes.proxy.kill('SIGINT');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Wait for all in-flight S3 uploads to complete
|
|
||||||
const uploadPromises = [currentSession.uploads.hires];
|
const uploadPromises = [currentSession.uploads.hires];
|
||||||
if (currentSession.uploads.proxy) {
|
if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy);
|
||||||
uploadPromises.push(currentSession.uploads.proxy);
|
|
||||||
}
|
|
||||||
await Promise.all(uploadPromises);
|
await Promise.all(uploadPromises);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during upload completion:', error);
|
console.error('Error during upload completion:', error);
|
||||||
|
|
@ -220,11 +468,15 @@ class CaptureManager {
|
||||||
const stopTime = new Date(stoppedAt);
|
const stopTime = new Date(stoppedAt);
|
||||||
const duration = Math.round((stopTime - startTime) / 1000);
|
const duration = Math.round((stopTime - startTime) / 1000);
|
||||||
|
|
||||||
// Reset state
|
|
||||||
this.state.recording = false;
|
this.state.recording = false;
|
||||||
this.state.sessionId = null;
|
this.state.sessionId = null;
|
||||||
this.state.processes = {};
|
this.state.processes = {};
|
||||||
|
|
||||||
|
// No frames received → the upload (if any) produced a 0-byte object.
|
||||||
|
// Surface that so the shutdown handler can mark the asset as 'error'
|
||||||
|
// instead of posting a broken hi-res key downstream.
|
||||||
|
const framesReceived = this.state.framesReceived;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
projectId: currentSession.projectId,
|
projectId: currentSession.projectId,
|
||||||
|
|
@ -232,28 +484,31 @@ class CaptureManager {
|
||||||
clipName: currentSession.clipName,
|
clipName: currentSession.clipName,
|
||||||
sourceType: currentSession.sourceType,
|
sourceType: currentSession.sourceType,
|
||||||
hiresKey: currentSession.hiresKey,
|
hiresKey: currentSession.hiresKey,
|
||||||
proxyKey: currentSession.proxyKey, // null for SRT/RTMP
|
proxyKey: currentSession.proxyKey,
|
||||||
|
growingPath: currentSession.growingPath || null,
|
||||||
startedAt: currentSession.startedAt,
|
startedAt: currentSession.startedAt,
|
||||||
stoppedAt,
|
stoppedAt,
|
||||||
duration,
|
duration,
|
||||||
|
framesReceived,
|
||||||
|
empty: framesReceived === 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current capture status
|
|
||||||
* @returns {Object} Current state
|
|
||||||
*/
|
|
||||||
getStatus() {
|
getStatus() {
|
||||||
if (!this.state.recording) {
|
if (!this.state.recording) return { recording: false };
|
||||||
return {
|
|
||||||
recording: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const startTime = new Date(this.state.currentSession.startedAt);
|
const startTime = new Date(this.state.currentSession.startedAt);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const duration = Math.round((now - startTime) / 1000);
|
const duration = Math.round((now - startTime) / 1000);
|
||||||
|
|
||||||
|
const lastFrameAt = this.state.lastFrameAt;
|
||||||
|
const msSinceFrame = lastFrameAt ? (Date.now() - new Date(lastFrameAt).getTime()) : null;
|
||||||
|
let signal = 'connecting';
|
||||||
|
if (this.state.framesReceived > 0) {
|
||||||
|
signal = (msSinceFrame !== null && msSinceFrame < 5000) ? 'receiving' : 'lost';
|
||||||
|
} else if (this.state.lastError) {
|
||||||
|
signal = 'error';
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
recording: true,
|
recording: true,
|
||||||
sessionId: this.state.sessionId,
|
sessionId: this.state.sessionId,
|
||||||
|
|
@ -264,13 +519,16 @@ class CaptureManager {
|
||||||
binId: this.state.currentSession.binId,
|
binId: this.state.currentSession.binId,
|
||||||
duration,
|
duration,
|
||||||
startedAt: this.state.currentSession.startedAt,
|
startedAt: this.state.currentSession.startedAt,
|
||||||
|
signal,
|
||||||
|
framesReceived: this.state.framesReceived,
|
||||||
|
currentFps: this.state.currentFps,
|
||||||
|
lastFrameAt,
|
||||||
|
msSinceFrame,
|
||||||
|
lastError: this.state.lastError,
|
||||||
|
codecs: this.state.currentSession.codecs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Format session response
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_formatSessionResponse() {
|
_formatSessionResponse() {
|
||||||
const { currentSession, sessionId } = this.state;
|
const { currentSession, sessionId } = this.state;
|
||||||
return {
|
return {
|
||||||
|
|
@ -283,8 +541,10 @@ class CaptureManager {
|
||||||
hiresKey: currentSession.hiresKey,
|
hiresKey: currentSession.hiresKey,
|
||||||
proxyKey: currentSession.proxyKey,
|
proxyKey: currentSession.proxyKey,
|
||||||
startedAt: currentSession.startedAt,
|
startedAt: currentSession.startedAt,
|
||||||
|
codecs: currentSession.codecs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new CaptureManager();
|
export default new CaptureManager();
|
||||||
|
export { VIDEO_CODECS, AUDIO_CODECS, CONTAINER_FMT, CONTAINER_EXT };
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ dotenv.config();
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';
|
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';
|
||||||
|
const MAM_API_TOKEN = process.env.MAM_API_TOKEN || '';
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
@ -21,11 +22,27 @@ app.use('/capture', captureRoutes);
|
||||||
|
|
||||||
const server = app.listen(PORT, () => {
|
const server = app.listen(PORT, () => {
|
||||||
console.log(`Wild Dragon Capture Service listening on port ${PORT}`);
|
console.log(`Wild Dragon Capture Service listening on port ${PORT}`);
|
||||||
bootstrapAutoStart().catch((err) => {
|
bootstrapAutoStart();
|
||||||
console.error('[bootstrap] auto-start failed:', err);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mapped from the env vars routes/recorders.js writes into the container.
|
||||||
|
// Empty strings collapse to undefined so capture-manager's defaults win.
|
||||||
|
function envOpt(name) {
|
||||||
|
const v = process.env[name];
|
||||||
|
return v === undefined || v === '' ? undefined : v;
|
||||||
|
}
|
||||||
|
function envInt(name) {
|
||||||
|
const v = envOpt(name);
|
||||||
|
if (v === undefined) return undefined;
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
return Number.isFinite(n) ? n : undefined;
|
||||||
|
}
|
||||||
|
function envBool(name) {
|
||||||
|
const v = envOpt(name);
|
||||||
|
if (v === undefined) return undefined;
|
||||||
|
return v === 'true' || v === '1' || v === 'yes';
|
||||||
|
}
|
||||||
|
|
||||||
async function bootstrapAutoStart() {
|
async function bootstrapAutoStart() {
|
||||||
const recorderId = process.env.RECORDER_ID;
|
const recorderId = process.env.RECORDER_ID;
|
||||||
const sourceType = process.env.SOURCE_TYPE;
|
const sourceType = process.env.SOURCE_TYPE;
|
||||||
|
|
@ -42,28 +59,42 @@ async function bootstrapAutoStart() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const listen = process.env.LISTEN === '1' || process.env.LISTEN === 'true';
|
const listen = process.env.LISTEN === '1' || process.env.LISTEN === 'true';
|
||||||
const listenPort = process.env.LISTEN_PORT
|
const listenPort = envInt('LISTEN_PORT');
|
||||||
? parseInt(process.env.LISTEN_PORT, 10)
|
const streamKey = envOpt('STREAM_KEY');
|
||||||
: undefined;
|
const sourceUrl = envOpt('SOURCE_URL');
|
||||||
const streamKey = process.env.STREAM_KEY || undefined;
|
const device = envInt('DEVICE_INDEX');
|
||||||
const sourceUrl = process.env.SOURCE_URL || undefined;
|
|
||||||
|
|
||||||
if (sourceType === 'sdi') {
|
|
||||||
console.warn('[bootstrap] SDI auto-start not supported');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[bootstrap] starting ${sourceType} ingest (listen=${listen} port=${listenPort || 'n/a'})...`);
|
console.log(`[bootstrap] starting ${sourceType} ingest (listen=${listen} port=${listenPort || 'n/a'})...`);
|
||||||
try {
|
try {
|
||||||
const session = await captureManager.start({
|
const session = await captureManager.start({
|
||||||
|
assetId: envOpt('ASSET_ID') || null,
|
||||||
projectId,
|
projectId,
|
||||||
binId: process.env.BIN_ID || null,
|
binId: envOpt('BIN_ID') || null,
|
||||||
clipName,
|
clipName,
|
||||||
|
device,
|
||||||
sourceType,
|
sourceType,
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
listen,
|
listen,
|
||||||
listenPort,
|
listenPort,
|
||||||
streamKey,
|
streamKey,
|
||||||
|
|
||||||
|
// Recording codec — recorders.js passes these straight through
|
||||||
|
videoCodec: envOpt('RECORDING_CODEC') || 'prores_hq',
|
||||||
|
videoBitrate: envOpt('RECORDING_VIDEO_BITRATE'),
|
||||||
|
framerate: envOpt('RECORDING_FRAMERATE'),
|
||||||
|
audioCodec: envOpt('RECORDING_AUDIO_CODEC') || 'pcm_s24le',
|
||||||
|
audioBitrate: envOpt('RECORDING_AUDIO_BITRATE'),
|
||||||
|
audioChannels: envInt('RECORDING_AUDIO_CHANNELS') ?? 2,
|
||||||
|
container: envOpt('RECORDING_CONTAINER') || 'mov',
|
||||||
|
|
||||||
|
proxyEnabled: envBool('PROXY_ENABLED') ?? true,
|
||||||
|
proxyVideoCodec: envOpt('PROXY_CODEC') || 'h264',
|
||||||
|
proxyVideoBitrate: envOpt('PROXY_VIDEO_BITRATE') || '8M',
|
||||||
|
proxyFramerate: envOpt('PROXY_FRAMERATE'),
|
||||||
|
proxyAudioCodec: envOpt('PROXY_AUDIO_CODEC') || 'aac',
|
||||||
|
proxyAudioBitrate: envOpt('PROXY_AUDIO_BITRATE') || '192k',
|
||||||
|
proxyAudioChannels: envInt('PROXY_AUDIO_CHANNELS') ?? 2,
|
||||||
|
proxyContainer: envOpt('PROXY_CONTAINER') || 'mp4',
|
||||||
});
|
});
|
||||||
console.log(`[bootstrap] session ${session.sessionId} started for clip ${clipName}`);
|
console.log(`[bootstrap] session ${session.sessionId} started for clip ${clipName}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -83,31 +114,68 @@ async function gracefulShutdown(signal) {
|
||||||
console.log(`[shutdown] stopping active session ${status.sessionId}...`);
|
console.log(`[shutdown] stopping active session ${status.sessionId}...`);
|
||||||
try {
|
try {
|
||||||
const completed = await captureManager.stop(status.sessionId);
|
const completed = await captureManager.stop(status.sessionId);
|
||||||
console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s`);
|
console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s frames=${completed.framesReceived}`);
|
||||||
|
|
||||||
try {
|
const liveAssetId = process.env.ASSET_ID || null;
|
||||||
const res = await fetch(`${MAM_API_URL}/api/v1/assets`, {
|
|
||||||
method: 'POST',
|
// No frames received → the source never connected (bad SRT URL, dead
|
||||||
headers: { 'Content-Type': 'application/json' },
|
// SDI signal, RTMP stream key mismatch, etc.). The S3 upload at this
|
||||||
body: JSON.stringify({
|
// point is 0 bytes and would just clog the proxy queue with "moov
|
||||||
projectId: completed.projectId,
|
// atom not found" failures. Mark the pre-created live asset as
|
||||||
binId: completed.binId,
|
// 'error' and skip the POST /assets registration entirely.
|
||||||
clipName: completed.clipName,
|
if (completed.empty) {
|
||||||
sourceType: completed.sourceType,
|
console.warn('[shutdown] no frames received — marking asset as error and skipping registration');
|
||||||
hiresKey: completed.hiresKey,
|
if (liveAssetId) {
|
||||||
proxyKey: completed.proxyKey,
|
try {
|
||||||
needsProxy: completed.proxyKey === null,
|
await fetch(`${MAM_API_URL}/api/v1/assets/${liveAssetId}/mark-empty`, {
|
||||||
duration: completed.duration,
|
method: 'POST',
|
||||||
capturedAt: completed.startedAt,
|
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
|
||||||
}),
|
});
|
||||||
});
|
} catch (e) {
|
||||||
if (!res.ok) {
|
console.error('[shutdown] failed to flag empty asset:', e.message);
|
||||||
console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`);
|
}
|
||||||
} else {
|
}
|
||||||
console.log('[shutdown] asset registered with mam-api');
|
} else if (liveAssetId) {
|
||||||
|
// Finalise the pre-created live asset by id (avoids POST / 409 collision).
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${MAM_API_URL}/api/v1/assets/${liveAssetId}/finalize`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
|
||||||
|
body: JSON.stringify({ hiresKey: completed.hiresKey, proxyKey: completed.proxyKey, duration: completed.duration }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
console.warn(`[shutdown] mam-api finalize returned ${res.status}: ${await res.text()}`);
|
||||||
|
} else {
|
||||||
|
console.log('[shutdown] live asset finalised with mam-api');
|
||||||
|
}
|
||||||
|
} catch (mamErr) {
|
||||||
|
console.error('[shutdown] failed to finalise asset:', mamErr.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${MAM_API_URL}/api/v1/assets`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
|
||||||
|
body: JSON.stringify({
|
||||||
|
projectId: completed.projectId,
|
||||||
|
binId: completed.binId,
|
||||||
|
clipName: completed.clipName,
|
||||||
|
sourceType: completed.sourceType,
|
||||||
|
hiresKey: completed.hiresKey,
|
||||||
|
proxyKey: completed.proxyKey,
|
||||||
|
needsProxy: completed.proxyKey === null,
|
||||||
|
duration: completed.duration,
|
||||||
|
capturedAt: completed.startedAt,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`);
|
||||||
|
} else {
|
||||||
|
console.log('[shutdown] asset registered with mam-api');
|
||||||
|
}
|
||||||
|
} catch (mamErr) {
|
||||||
|
console.error('[shutdown] failed to register asset:', mamErr.message);
|
||||||
}
|
}
|
||||||
} catch (mamErr) {
|
|
||||||
console.error('[shutdown] failed to register asset:', mamErr.message);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[shutdown] error during stop:', err);
|
console.error('[shutdown] error during stop:', err);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,79 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { execSync } from 'child_process';
|
import { execSync, spawn } from 'child_process';
|
||||||
|
import { existsSync, readdirSync } from 'node:fs';
|
||||||
import captureManager from '../capture-manager.js';
|
import captureManager from '../capture-manager.js';
|
||||||
|
|
||||||
|
import dgram from 'dgram';
|
||||||
|
import net from 'net';
|
||||||
|
|
||||||
|
function parseUrl(u) {
|
||||||
|
try {
|
||||||
|
const m = String(u).match(/^[a-z]+:\/\/([^:\/?#]+)(?::(\d+))?/i);
|
||||||
|
if (!m) return null;
|
||||||
|
return { host: m[1], port: parseInt(m[2] || '0', 10) };
|
||||||
|
} catch (_) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkReachable(host, port, sourceType) {
|
||||||
|
if (!port) return { ok: true };
|
||||||
|
if (sourceType === 'srt') return await udpSendProbe(host, port);
|
||||||
|
if (sourceType === 'rtmp') return await tcpConnectProbe(host, port);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function udpSendProbe(host, port) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const sock = dgram.createSocket('udp4');
|
||||||
|
let done = false;
|
||||||
|
const finish = (result) => { if (done) return; done = true; try { sock.close(); } catch (_) {} resolve(result); };
|
||||||
|
sock.on('error', (err) => {
|
||||||
|
const msg = String(err && err.message || err);
|
||||||
|
if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) {
|
||||||
|
finish({ ok: false, error: 'Host ' + host + ' is unreachable from the capture container (no route). Confirm the IP is correct and the machine is online.', diagnostic: msg });
|
||||||
|
} else if (/ECONNREFUSED|EPORTUNREACH/i.test(msg)) {
|
||||||
|
finish({ ok: false, error: 'Nothing is listening on UDP ' + host + ':' + port + '. In vMix, confirm the SRT output is Started and the port matches.', diagnostic: msg });
|
||||||
|
} else {
|
||||||
|
finish({ ok: false, error: 'UDP probe failed: ' + msg, diagnostic: msg });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sock.send(Buffer.from('Z-AMPP-PROBE'), port, host, () => {});
|
||||||
|
setTimeout(() => finish({ ok: true }), 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tcpConnectProbe(host, port) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const sock = new net.Socket();
|
||||||
|
let done = false;
|
||||||
|
const finish = (r) => { if (done) return; done = true; try { sock.destroy(); } catch (_) {} resolve(r); };
|
||||||
|
sock.setTimeout(2500);
|
||||||
|
sock.once('connect', () => finish({ ok: true }));
|
||||||
|
sock.once('timeout', () => finish({ ok: false, error: 'TCP connect to ' + host + ':' + port + ' timed out. Confirm the host is reachable and a TCP listener is running.' }));
|
||||||
|
sock.once('error', (err) => {
|
||||||
|
const msg = String(err && err.message || err);
|
||||||
|
if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) finish({ ok: false, error: 'Host ' + host + ' unreachable (no route).', diagnostic: msg });
|
||||||
|
else if (/ECONNREFUSED/i.test(msg)) finish({ ok: false, error: 'Nothing is listening on TCP ' + host + ':' + port + '.', diagnostic: msg });
|
||||||
|
else finish({ ok: false, error: 'TCP probe failed: ' + msg, diagnostic: msg });
|
||||||
|
});
|
||||||
|
sock.connect(port, host);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyProbeError(raw, sourceType) {
|
||||||
|
const r = (raw || '').toLowerCase();
|
||||||
|
if (sourceType === 'srt') {
|
||||||
|
if (/connection .* failed: (input\/output|timer expired|connection setup failure)/i.test(raw)) {
|
||||||
|
return 'SRT handshake failed. In vMix: confirm the External Output is Started, Type=SRT, Mode=Listener, port matches, and any passphrase / stream ID is empty (or copied exactly).';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sourceType === 'rtmp') {
|
||||||
|
if (/connection refused/i.test(r)) return 'Nothing is listening on RTMP at this address. Start your RTMP source.';
|
||||||
|
if (/end-of-file|invalid data found/i.test(r)) return 'Got a TCP connection but no RTMP stream. Confirm the source is publishing and the path / stream-key match.';
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';
|
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';
|
||||||
|
|
@ -16,7 +88,7 @@ router.get('/devices', (req, res) => {
|
||||||
let output = '';
|
let output = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
output = execSync('ffmpeg -f decklink -list_devices 1 -i dummy 2>&1', {
|
output = execSync('ffmpeg -sources decklink 2>&1', {
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -24,13 +96,13 @@ router.get('/devices', (req, res) => {
|
||||||
output = error.stderr ? error.stderr.toString() : error.toString();
|
output = error.stderr ? error.stderr.toString() : error.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse ffmpeg output for DeckLink device names
|
// Parse ffmpeg output for DeckLink device names.
|
||||||
// Format: [decklink @ ...] "DeckLink Quad 2" (input #0)
|
// DeckLink source lines: " 81:76669a80:00000000 [DeckLink Duo (1)] (none)"
|
||||||
const lines = output.split('\n');
|
const lines = output.split('\n');
|
||||||
let deviceIndex = 0;
|
let deviceIndex = 0;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const match = line.match(/^\s*\[decklink[^\]]*\]\s+"([^"]+)"/);
|
const match = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
|
||||||
if (match) {
|
if (match) {
|
||||||
devices.push({
|
devices.push({
|
||||||
index: deviceIndex,
|
index: deviceIndex,
|
||||||
|
|
@ -47,6 +119,57 @@ router.get('/devices', (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /devices/deltacast
|
||||||
|
* List available Deltacast ports.
|
||||||
|
* Reads /dev/deltacast<N> nodes; falls back to env DELTACAST_PORT_COUNT
|
||||||
|
* so nodes without hardware still report their configured port count
|
||||||
|
* (test-card mode).
|
||||||
|
*/
|
||||||
|
router.get('/devices/deltacast', (req, res) => {
|
||||||
|
try {
|
||||||
|
const devices = [];
|
||||||
|
|
||||||
|
// First: enumerate actual /dev/deltacast* device nodes.
|
||||||
|
try {
|
||||||
|
const devEntries = readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n));
|
||||||
|
devEntries.sort();
|
||||||
|
for (const entry of devEntries) {
|
||||||
|
const m = entry.match(/^deltacast(\d+)$/);
|
||||||
|
if (m) {
|
||||||
|
devices.push({
|
||||||
|
index: parseInt(m[1], 10),
|
||||||
|
name: `Deltacast Port ${m[1]}`,
|
||||||
|
device: `/dev/${entry}`,
|
||||||
|
present: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) { /* /dev always exists; ignore */ }
|
||||||
|
|
||||||
|
// Second: if DELTACAST_PORT_COUNT env is set and larger than what we found,
|
||||||
|
// fill in the remaining slots as test-card entries (no physical device).
|
||||||
|
const envCount = parseInt(process.env.DELTACAST_PORT_COUNT || '0', 10);
|
||||||
|
const found = new Set(devices.map(d => d.index));
|
||||||
|
for (let i = 0; i < envCount; i++) {
|
||||||
|
if (!found.has(i)) {
|
||||||
|
devices.push({
|
||||||
|
index: i,
|
||||||
|
name: `Deltacast Port ${i} (test card)`,
|
||||||
|
device: `/dev/deltacast${i}`,
|
||||||
|
present: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
devices.sort((a, b) => a.index - b.index);
|
||||||
|
res.json({ devices });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error listing Deltacast devices:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to list Deltacast devices' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /status
|
* GET /status
|
||||||
* Get current capture status
|
* Get current capture status
|
||||||
|
|
@ -60,6 +183,103 @@ router.get('/status', (req, res) => {
|
||||||
res.status(500).json({ error: 'Failed to get status' });
|
res.status(500).json({ error: 'Failed to get status' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
router.post('/probe', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { source_type = 'sdi', source_url, listen = false } = req.body || {};
|
||||||
|
|
||||||
|
if (source_type === 'sdi') {
|
||||||
|
try {
|
||||||
|
const raw = execSync('ffmpeg -hide_banner -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 });
|
||||||
|
const devices = [];
|
||||||
|
for (const line of raw.split('\n')) {
|
||||||
|
const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
|
||||||
|
if (m) devices.push(m[1]);
|
||||||
|
}
|
||||||
|
return res.json({ ok: true, source_type, devices });
|
||||||
|
} catch (err) {
|
||||||
|
const out = (err.stderr || err.stdout || err.toString()).toString();
|
||||||
|
return res.json({ ok: false, source_type, error: out.slice(0, 800) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source_type === 'deltacast') {
|
||||||
|
// Enumerate /dev/deltacast* nodes; report present/absent per index.
|
||||||
|
try {
|
||||||
|
const envCount = parseInt(process.env.DELTACAST_PORT_COUNT || '0', 10);
|
||||||
|
const devEntries = readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n)).sort();
|
||||||
|
const found = devEntries.map(n => {
|
||||||
|
const m = n.match(/^deltacast(\d+)$/);
|
||||||
|
return { index: parseInt(m[1], 10), device: `/dev/${n}`, present: true };
|
||||||
|
});
|
||||||
|
const foundIdx = new Set(found.map(d => d.index));
|
||||||
|
for (let i = 0; i < envCount; i++) {
|
||||||
|
if (!foundIdx.has(i)) {
|
||||||
|
found.push({ index: i, device: `/dev/deltacast${i}`, present: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found.sort((a, b) => a.index - b.index);
|
||||||
|
return res.json({ ok: true, source_type, devices: found });
|
||||||
|
} catch (err) {
|
||||||
|
return res.json({ ok: false, source_type, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listen) {
|
||||||
|
return res.json({ ok: false, source_type, error: 'Listener-mode sources cannot be probed standalone. Start the recorder and watch the signal indicator.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!source_url) return res.status(400).json({ error: 'source_url is required' });
|
||||||
|
|
||||||
|
// Pre-flight: parse host:port and check L3/L4 reachability so we can give
|
||||||
|
// an actionable error instead of the opaque libsrt "Input/output error".
|
||||||
|
const parsed = parseUrl(source_url);
|
||||||
|
if (!parsed) {
|
||||||
|
return res.json({ ok: false, source_type, source_url, error: 'Could not parse host:port from URL.' });
|
||||||
|
}
|
||||||
|
const reach = await checkReachable(parsed.host, parsed.port, source_type);
|
||||||
|
if (!reach.ok) {
|
||||||
|
return res.json({ ok: false, source_type, source_url, error: reach.error, diagnostic: reach.diagnostic });
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = source_url;
|
||||||
|
if (source_type === 'srt' && !/mode=/.test(url)) {
|
||||||
|
url += (url.includes('?') ? '&' : '?') + 'mode=caller';
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = ['-hide_banner','-v','error','-probesize','32M','-analyzeduration','8M','-rw_timeout','7000000','-i', url, '-show_streams','-show_format','-of','json'];
|
||||||
|
const ff = spawn('ffprobe', args);
|
||||||
|
let stdout = '', stderr = '';
|
||||||
|
ff.stdout.on('data', (c) => { stdout += c; });
|
||||||
|
ff.stderr.on('data', (c) => { stderr += c; });
|
||||||
|
const killer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch (_) {} }, 10000);
|
||||||
|
ff.on('close', (code) => {
|
||||||
|
clearTimeout(killer);
|
||||||
|
if (code !== 0) {
|
||||||
|
const rawErr = (stderr || 'ffprobe failed').slice(0, 800);
|
||||||
|
const friendly = classifyProbeError(rawErr, source_type);
|
||||||
|
return res.json({ ok: false, source_type, source_url, error: friendly, diagnostic: rawErr });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(stdout);
|
||||||
|
const streams = (parsed.streams || []).map(s => ({
|
||||||
|
index: s.index, codec_type: s.codec_type, codec_name: s.codec_name,
|
||||||
|
width: s.width, height: s.height, pix_fmt: s.pix_fmt,
|
||||||
|
r_frame_rate: s.r_frame_rate, avg_frame_rate: s.avg_frame_rate,
|
||||||
|
sample_rate: s.sample_rate, channels: s.channels,
|
||||||
|
channel_layout: s.channel_layout, bit_rate: s.bit_rate,
|
||||||
|
}));
|
||||||
|
return res.json({ ok: true, source_type, source_url,
|
||||||
|
format: { format_name: parsed.format && parsed.format.format_name, duration: parsed.format && parsed.format.duration, bit_rate: parsed.format && parsed.format.bit_rate },
|
||||||
|
streams });
|
||||||
|
} catch (err) {
|
||||||
|
return res.json({ ok: false, source_type, source_url, error: 'Could not parse ffprobe output: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Probe error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /start
|
* POST /start
|
||||||
|
|
|
||||||
330
services/capture/src/routes/capture.js.bak
Normal file
330
services/capture/src/routes/capture.js.bak
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
import express from 'express';
|
||||||
|
import { execSync, spawn } from 'child_process';
|
||||||
|
import captureManager from '../capture-manager.js';
|
||||||
|
|
||||||
|
import dgram from 'dgram';
|
||||||
|
import net from 'net';
|
||||||
|
|
||||||
|
function parseUrl(u) {
|
||||||
|
try {
|
||||||
|
const m = String(u).match(/^[a-z]+:\/\/([^:\/?#]+)(?::(\d+))?/i);
|
||||||
|
if (!m) return null;
|
||||||
|
return { host: m[1], port: parseInt(m[2] || '0', 10) };
|
||||||
|
} catch (_) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkReachable(host, port, sourceType) {
|
||||||
|
if (!port) return { ok: true };
|
||||||
|
if (sourceType === 'srt') return await udpSendProbe(host, port);
|
||||||
|
if (sourceType === 'rtmp') return await tcpConnectProbe(host, port);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function udpSendProbe(host, port) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const sock = dgram.createSocket('udp4');
|
||||||
|
let done = false;
|
||||||
|
const finish = (result) => { if (done) return; done = true; try { sock.close(); } catch (_) {} resolve(result); };
|
||||||
|
sock.on('error', (err) => {
|
||||||
|
const msg = String(err && err.message || err);
|
||||||
|
if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) {
|
||||||
|
finish({ ok: false, error: 'Host ' + host + ' is unreachable from the capture container (no route). Confirm the IP is correct and the machine is online.', diagnostic: msg });
|
||||||
|
} else if (/ECONNREFUSED|EPORTUNREACH/i.test(msg)) {
|
||||||
|
finish({ ok: false, error: 'Nothing is listening on UDP ' + host + ':' + port + '. In vMix, confirm the SRT output is Started and the port matches.', diagnostic: msg });
|
||||||
|
} else {
|
||||||
|
finish({ ok: false, error: 'UDP probe failed: ' + msg, diagnostic: msg });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sock.send(Buffer.from('Z-AMPP-PROBE'), port, host, () => {});
|
||||||
|
setTimeout(() => finish({ ok: true }), 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tcpConnectProbe(host, port) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const sock = new net.Socket();
|
||||||
|
let done = false;
|
||||||
|
const finish = (r) => { if (done) return; done = true; try { sock.destroy(); } catch (_) {} resolve(r); };
|
||||||
|
sock.setTimeout(2500);
|
||||||
|
sock.once('connect', () => finish({ ok: true }));
|
||||||
|
sock.once('timeout', () => finish({ ok: false, error: 'TCP connect to ' + host + ':' + port + ' timed out. Confirm the host is reachable and a TCP listener is running.' }));
|
||||||
|
sock.once('error', (err) => {
|
||||||
|
const msg = String(err && err.message || err);
|
||||||
|
if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) finish({ ok: false, error: 'Host ' + host + ' unreachable (no route).', diagnostic: msg });
|
||||||
|
else if (/ECONNREFUSED/i.test(msg)) finish({ ok: false, error: 'Nothing is listening on TCP ' + host + ':' + port + '.', diagnostic: msg });
|
||||||
|
else finish({ ok: false, error: 'TCP probe failed: ' + msg, diagnostic: msg });
|
||||||
|
});
|
||||||
|
sock.connect(port, host);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyProbeError(raw, sourceType) {
|
||||||
|
const r = (raw || '').toLowerCase();
|
||||||
|
if (sourceType === 'srt') {
|
||||||
|
if (/connection .* failed: (input\/output|timer expired|connection setup failure)/i.test(raw)) {
|
||||||
|
return 'SRT handshake failed. In vMix: confirm the External Output is Started, Type=SRT, Mode=Listener, port matches, and any passphrase / stream ID is empty (or copied exactly).';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sourceType === 'rtmp') {
|
||||||
|
if (/connection refused/i.test(r)) return 'Nothing is listening on RTMP at this address. Start your RTMP source.';
|
||||||
|
if (/end-of-file|invalid data found/i.test(r)) return 'Got a TCP connection but no RTMP stream. Confirm the source is publishing and the path / stream-key match.';
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /devices
|
||||||
|
* List available DeckLink devices
|
||||||
|
*/
|
||||||
|
router.get('/devices', (req, res) => {
|
||||||
|
try {
|
||||||
|
const devices = [];
|
||||||
|
let output = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
output = execSync('ffmpeg -f decklink -list_devices 1 -i dummy 2>&1', {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// ffmpeg returns non-zero, but stderr is still captured
|
||||||
|
output = error.stderr ? error.stderr.toString() : error.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse ffmpeg output for DeckLink device names
|
||||||
|
// Format: [decklink @ ...] "DeckLink Quad 2" (input #0)
|
||||||
|
const lines = output.split('\n');
|
||||||
|
let deviceIndex = 0;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = line.match(/^\s*\[decklink[^\]]*\]\s+"([^"]+)"/);
|
||||||
|
if (match) {
|
||||||
|
devices.push({
|
||||||
|
index: deviceIndex,
|
||||||
|
name: match[1],
|
||||||
|
});
|
||||||
|
deviceIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ devices });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error listing devices:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to list devices' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /status
|
||||||
|
* Get current capture status
|
||||||
|
*/
|
||||||
|
router.get('/status', (req, res) => {
|
||||||
|
try {
|
||||||
|
const status = captureManager.getStatus();
|
||||||
|
res.json(status);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting status:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get status' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.post('/probe', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { source_type = 'sdi', source_url, listen = false } = req.body || {};
|
||||||
|
|
||||||
|
if (source_type === 'sdi') {
|
||||||
|
try {
|
||||||
|
const raw = execSync('ffmpeg -hide_banner -f decklink -list_devices 1 -i dummy 2>&1', { encoding: 'utf-8', timeout: 5000 });
|
||||||
|
const devices = [];
|
||||||
|
for (const line of raw.split('\n')) {
|
||||||
|
const m = line.match(/\[decklink[^\]]*\]\s+"([^"]+)"/);
|
||||||
|
if (m) devices.push(m[1]);
|
||||||
|
}
|
||||||
|
return res.json({ ok: true, source_type, devices });
|
||||||
|
} catch (err) {
|
||||||
|
const out = (err.stderr || err.stdout || err.toString()).toString();
|
||||||
|
return res.json({ ok: false, source_type, error: out.slice(0, 800) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listen) {
|
||||||
|
return res.json({ ok: false, source_type, error: 'Listener-mode sources cannot be probed standalone. Start the recorder and watch the signal indicator.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!source_url) return res.status(400).json({ error: 'source_url is required' });
|
||||||
|
|
||||||
|
// Pre-flight: parse host:port and check L3/L4 reachability so we can give
|
||||||
|
// an actionable error instead of the opaque libsrt "Input/output error".
|
||||||
|
const parsed = parseUrl(source_url);
|
||||||
|
if (!parsed) {
|
||||||
|
return res.json({ ok: false, source_type, source_url, error: 'Could not parse host:port from URL.' });
|
||||||
|
}
|
||||||
|
const reach = await checkReachable(parsed.host, parsed.port, source_type);
|
||||||
|
if (!reach.ok) {
|
||||||
|
return res.json({ ok: false, source_type, source_url, error: reach.error, diagnostic: reach.diagnostic });
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = source_url;
|
||||||
|
if (source_type === 'srt' && !/mode=/.test(url)) {
|
||||||
|
url += (url.includes('?') ? '&' : '?') + 'mode=caller';
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = ['-hide_banner','-v','error','-probesize','32M','-analyzeduration','8M','-rw_timeout','7000000','-i', url, '-show_streams','-show_format','-of','json'];
|
||||||
|
const ff = spawn('ffprobe', args);
|
||||||
|
let stdout = '', stderr = '';
|
||||||
|
ff.stdout.on('data', (c) => { stdout += c; });
|
||||||
|
ff.stderr.on('data', (c) => { stderr += c; });
|
||||||
|
const killer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch (_) {} }, 10000);
|
||||||
|
ff.on('close', (code) => {
|
||||||
|
clearTimeout(killer);
|
||||||
|
if (code !== 0) {
|
||||||
|
const rawErr = (stderr || 'ffprobe failed').slice(0, 800);
|
||||||
|
const friendly = classifyProbeError(rawErr, source_type);
|
||||||
|
return res.json({ ok: false, source_type, source_url, error: friendly, diagnostic: rawErr });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(stdout);
|
||||||
|
const streams = (parsed.streams || []).map(s => ({
|
||||||
|
index: s.index, codec_type: s.codec_type, codec_name: s.codec_name,
|
||||||
|
width: s.width, height: s.height, pix_fmt: s.pix_fmt,
|
||||||
|
r_frame_rate: s.r_frame_rate, avg_frame_rate: s.avg_frame_rate,
|
||||||
|
sample_rate: s.sample_rate, channels: s.channels,
|
||||||
|
channel_layout: s.channel_layout, bit_rate: s.bit_rate,
|
||||||
|
}));
|
||||||
|
return res.json({ ok: true, source_type, source_url,
|
||||||
|
format: { format_name: parsed.format && parsed.format.format_name, duration: parsed.format && parsed.format.duration, bit_rate: parsed.format && parsed.format.bit_rate },
|
||||||
|
streams });
|
||||||
|
} catch (err) {
|
||||||
|
return res.json({ ok: false, source_type, source_url, error: 'Could not parse ffprobe output: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Probe error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /start
|
||||||
|
* Start a new capture session
|
||||||
|
*
|
||||||
|
* Body (SDI):
|
||||||
|
* { project_id, clip_name, device, bin_id?, source_type? }
|
||||||
|
*
|
||||||
|
* Body (SRT/RTMP caller):
|
||||||
|
* { project_id, clip_name, source_type, source_url, bin_id? }
|
||||||
|
*
|
||||||
|
* Body (SRT/RTMP listener):
|
||||||
|
* { project_id, clip_name, source_type, listen: true, listen_port?, stream_key?, bin_id? }
|
||||||
|
*/
|
||||||
|
router.post('/start', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
project_id,
|
||||||
|
bin_id,
|
||||||
|
clip_name,
|
||||||
|
device,
|
||||||
|
source_type = 'sdi',
|
||||||
|
source_url,
|
||||||
|
listen = false,
|
||||||
|
listen_port,
|
||||||
|
stream_key,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!project_id || !clip_name) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Missing required fields: project_id, clip_name',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source-specific validation
|
||||||
|
if (source_type === 'sdi') {
|
||||||
|
if (device === undefined || device === null) {
|
||||||
|
return res.status(400).json({ error: 'SDI source requires: device' });
|
||||||
|
}
|
||||||
|
} else if (source_type === 'srt' || source_type === 'rtmp') {
|
||||||
|
if (!listen && !source_url) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `${source_type.toUpperCase()} caller mode requires: source_url`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `Unknown source_type: ${source_type}. Must be sdi, srt, or rtmp`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await captureManager.start({
|
||||||
|
projectId: project_id,
|
||||||
|
binId: bin_id || null,
|
||||||
|
clipName: clip_name,
|
||||||
|
device,
|
||||||
|
sourceType: source_type,
|
||||||
|
sourceUrl: source_url,
|
||||||
|
listen,
|
||||||
|
listenPort: listen_port,
|
||||||
|
streamKey: stream_key,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(session);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error starting capture:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /stop
|
||||||
|
* Stop the current capture session
|
||||||
|
* Body: { session_id }
|
||||||
|
*/
|
||||||
|
router.post('/stop', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { session_id } = req.body;
|
||||||
|
|
||||||
|
if (!session_id) {
|
||||||
|
return res.status(400).json({ error: 'Missing required field: session_id' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedSession = await captureManager.stop(session_id);
|
||||||
|
|
||||||
|
// Register asset with mam-api.
|
||||||
|
// If proxyKey is null (SRT/RTMP source), set needsProxy=true so the
|
||||||
|
// worker generates a proxy from the hires file asynchronously.
|
||||||
|
try {
|
||||||
|
const mamResponse = await fetch(`${MAM_API_URL}/api/v1/assets`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
projectId: completedSession.projectId,
|
||||||
|
binId: completedSession.binId,
|
||||||
|
clipName: completedSession.clipName,
|
||||||
|
sourceType: completedSession.sourceType,
|
||||||
|
hiresKey: completedSession.hiresKey,
|
||||||
|
proxyKey: completedSession.proxyKey,
|
||||||
|
needsProxy: completedSession.proxyKey === null,
|
||||||
|
duration: completedSession.duration,
|
||||||
|
capturedAt: completedSession.startedAt,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mamResponse.ok) {
|
||||||
|
console.warn(
|
||||||
|
`MAM API registration returned ${mamResponse.status}: ${await mamResponse.text()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (mamError) {
|
||||||
|
console.warn('Failed to register asset with MAM API:', mamError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(completedSession);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error stopping capture:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
FROM node:22-alpine
|
FROM node:22-slim
|
||||||
|
# unzip/tar needed for SDK upload extraction (see routes/sdk.js)
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends unzip tar ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
|
|
|
||||||
2992
services/mam-api/package-lock.json
generated
Normal file
2992
services/mam-api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,10 +2,12 @@
|
||||||
"name": "wild-dragon-mam-api",
|
"name": "wild-dragon-mam-api",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Media Asset Management API for Wild Dragon",
|
"description": "Media Asset Management API for Wild Dragon",
|
||||||
|
"type": "module",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
"dev": "node --watch src/index.js"
|
"dev": "node --watch src/index.js",
|
||||||
|
"test": "node --test $(find test -name '*.test.js' | sort)"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
|
@ -20,7 +22,9 @@
|
||||||
"bullmq": "^5.5.0",
|
"bullmq": "^5.5.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"dotenv": "^16.4.5"
|
"dotenv": "^16.4.5",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"google-auth-library": "^9.14.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.0.0"
|
"node": ">=22.0.0"
|
||||||
|
|
|
||||||
90
services/mam-api/src/auth/authz.js
Normal file
90
services/mam-api/src/auth/authz.js
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
// Per-project authorization — the single source of truth for "can this user
|
||||||
|
// touch this project?". v1 auth answers "are you logged in?"; this answers
|
||||||
|
// "which projects, and at what level?".
|
||||||
|
//
|
||||||
|
// Model (locked with Zac):
|
||||||
|
// - role 'admin' → global bypass; every project at 'edit'.
|
||||||
|
// - role 'editor'/'viewer' → scoped to projects granted to them directly
|
||||||
|
// (project_access subject_type='user') or via a
|
||||||
|
// group they belong to (subject_type='group').
|
||||||
|
// - grant level 'view' → read-only; 'edit' → read-write.
|
||||||
|
//
|
||||||
|
// A user's effective level on a project is the MAX of every matching grant
|
||||||
|
// (direct + each group). 'edit' outranks 'view'.
|
||||||
|
//
|
||||||
|
// All functions take an optional `db` (defaults to the shared pool) so tests
|
||||||
|
// can inject an isolated test pool.
|
||||||
|
|
||||||
|
import defaultPool from '../db/pool.js';
|
||||||
|
|
||||||
|
const LEVEL_RANK = { view: 1, edit: 2 };
|
||||||
|
|
||||||
|
export function isAdmin(user) {
|
||||||
|
return user?.role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the higher of two levels (either may be null/undefined).
|
||||||
|
function maxLevel(a, b) {
|
||||||
|
const ra = LEVEL_RANK[a] || 0;
|
||||||
|
const rb = LEVEL_RANK[b] || 0;
|
||||||
|
if (ra === 0 && rb === 0) return null;
|
||||||
|
return ra >= rb ? a : b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve every project the user can see, with their effective level.
|
||||||
|
// admin → { all: true, ids: null, levelByProject: null }
|
||||||
|
// else → { all: false, ids: Set<projectId>, levelByProject: Map<projectId, 'view'|'edit'> }
|
||||||
|
export async function accessibleProjectIds(user, db = defaultPool) {
|
||||||
|
if (isAdmin(user)) return { all: true, ids: null, levelByProject: null };
|
||||||
|
|
||||||
|
const levelByProject = new Map();
|
||||||
|
if (!user?.id) return { all: false, ids: new Set(), levelByProject };
|
||||||
|
|
||||||
|
const { rows } = await db.query(
|
||||||
|
`SELECT pa.project_id, pa.level
|
||||||
|
FROM project_access pa
|
||||||
|
WHERE (pa.subject_type = 'user' AND pa.subject_id = $1)
|
||||||
|
OR (pa.subject_type = 'group' AND pa.subject_id IN (
|
||||||
|
SELECT group_id FROM user_groups WHERE user_id = $1
|
||||||
|
))`,
|
||||||
|
[user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
levelByProject.set(r.project_id, maxLevel(levelByProject.get(r.project_id), r.level));
|
||||||
|
}
|
||||||
|
return { all: false, ids: new Set(levelByProject.keys()), levelByProject };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effective level on a single project: 'edit' | 'view' | null.
|
||||||
|
export async function projectLevel(user, projectId, db = defaultPool) {
|
||||||
|
if (isAdmin(user)) return 'edit';
|
||||||
|
if (!user?.id || !projectId) return null;
|
||||||
|
|
||||||
|
const { rows } = await db.query(
|
||||||
|
`SELECT pa.level
|
||||||
|
FROM project_access pa
|
||||||
|
WHERE pa.project_id = $1
|
||||||
|
AND ( (pa.subject_type = 'user' AND pa.subject_id = $2)
|
||||||
|
OR (pa.subject_type = 'group' AND pa.subject_id IN (
|
||||||
|
SELECT group_id FROM user_groups WHERE user_id = $2
|
||||||
|
)) )`,
|
||||||
|
[projectId, user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
let level = null;
|
||||||
|
for (const r of rows) level = maxLevel(level, r.level);
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throw a 403-shaped error (caught by errorHandler) unless the user has at
|
||||||
|
// least `need` access on the project. `need` ∈ 'view' | 'edit'.
|
||||||
|
export async function assertProjectAccess(user, projectId, need = 'view', db = defaultPool) {
|
||||||
|
if (isAdmin(user)) return;
|
||||||
|
const have = await projectLevel(user, projectId, db);
|
||||||
|
if (!have || (LEVEL_RANK[have] || 0) < (LEVEL_RANK[need] || 0)) {
|
||||||
|
const err = new Error('forbidden');
|
||||||
|
err.status = 403;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
90
services/mam-api/src/auth/google-oauth.js
Normal file
90
services/mam-api/src/auth/google-oauth.js
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
// Google OAuth (OIDC) sign-in helpers.
|
||||||
|
//
|
||||||
|
// Entirely config-gated: if GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET /
|
||||||
|
// OAUTH_REDIRECT_URL aren't set, isConfigured() is false and the routes 404, so
|
||||||
|
// a deployment without Google SSO behaves exactly as before. google-auth-library
|
||||||
|
// is imported lazily so the dependency is only required when the feature is on.
|
||||||
|
//
|
||||||
|
// Flow: /auth/google redirects to Google's consent screen with a signed `state`;
|
||||||
|
// /auth/google/callback exchanges the code, verifies the ID token, enforces the
|
||||||
|
// allowed Workspace domain, and auto-provisions a viewer account on first login.
|
||||||
|
|
||||||
|
const SCOPES = ['openid', 'email', 'profile'];
|
||||||
|
|
||||||
|
export function isConfigured() {
|
||||||
|
return !!(process.env.GOOGLE_CLIENT_ID
|
||||||
|
&& process.env.GOOGLE_CLIENT_SECRET
|
||||||
|
&& process.env.OAUTH_REDIRECT_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allowedDomain() {
|
||||||
|
return (process.env.GOOGLE_ALLOWED_DOMAIN || '').trim().toLowerCase() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazily build an OAuth2 client (throws a clear error if the dep is missing).
|
||||||
|
async function makeClient() {
|
||||||
|
let OAuth2Client;
|
||||||
|
try {
|
||||||
|
({ OAuth2Client } = await import('google-auth-library'));
|
||||||
|
} catch {
|
||||||
|
const err = new Error('google-auth-library is not installed');
|
||||||
|
err.status = 500;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return new OAuth2Client({
|
||||||
|
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||||
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||||
|
redirectUri: process.env.OAUTH_REDIRECT_URL,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL of Google's consent screen. `state` is an opaque anti-CSRF token we also
|
||||||
|
// stash in the session and re-check on callback.
|
||||||
|
export async function buildAuthUrl(state) {
|
||||||
|
const client = await makeClient();
|
||||||
|
return client.generateAuthUrl({
|
||||||
|
access_type: 'online',
|
||||||
|
scope: SCOPES,
|
||||||
|
state,
|
||||||
|
prompt: 'select_account',
|
||||||
|
// If a Workspace domain is configured, hint Google to scope the picker to it.
|
||||||
|
...(allowedDomain() ? { hd: allowedDomain() } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange the authorization code and verify the returned ID token. Returns the
|
||||||
|
// verified { sub, email, name, hd } payload. Throws { status } on any failure.
|
||||||
|
export async function exchangeAndVerify(code) {
|
||||||
|
const client = await makeClient();
|
||||||
|
const { tokens } = await client.getToken(code);
|
||||||
|
if (!tokens.id_token) {
|
||||||
|
const err = new Error('no id_token from Google'); err.status = 401; throw err;
|
||||||
|
}
|
||||||
|
const ticket = await client.verifyIdToken({
|
||||||
|
idToken: tokens.id_token,
|
||||||
|
audience: process.env.GOOGLE_CLIENT_ID,
|
||||||
|
});
|
||||||
|
const p = ticket.getPayload();
|
||||||
|
if (!p || !p.sub) {
|
||||||
|
const err = new Error('invalid id_token'); err.status = 401; throw err;
|
||||||
|
}
|
||||||
|
// Require an explicitly verified email — a missing/undefined claim is NOT
|
||||||
|
// treated as verified, since the email drives account linking/provisioning.
|
||||||
|
if (!p.email || p.email_verified !== true) {
|
||||||
|
const err = new Error('email not verified'); err.status = 403; throw err;
|
||||||
|
}
|
||||||
|
const domain = allowedDomain();
|
||||||
|
if (domain) {
|
||||||
|
// ONLY trust Google's `hd` (hosted-domain) claim — it's present iff the
|
||||||
|
// account is a member of a Google Workspace domain that Google itself
|
||||||
|
// has verified. The email-suffix fallback we used to allow let any
|
||||||
|
// non-Workspace account with a spoof-friendly email through; if a
|
||||||
|
// GOOGLE_ALLOWED_DOMAIN is set, the operator means "only this Workspace,"
|
||||||
|
// and consumer accounts (no hd) must be rejected.
|
||||||
|
const hd = (p.hd || '').toLowerCase();
|
||||||
|
if (hd !== domain) {
|
||||||
|
const err = new Error('domain not allowed'); err.status = 403; throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { sub: p.sub, email: p.email, name: p.name || p.email, hd: p.hd || null };
|
||||||
|
}
|
||||||
58
services/mam-api/src/auth/mfa-tickets.js
Normal file
58
services/mam-api/src/auth/mfa-tickets.js
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
// Short-lived MFA tickets bridging the two login steps.
|
||||||
|
//
|
||||||
|
// When a user with TOTP enabled passes password auth, we don't create a session
|
||||||
|
// yet — we hand back an opaque ticket. The second request (code or recovery
|
||||||
|
// code) redeems the ticket to finish login. Tickets are single-use and expire
|
||||||
|
// fast so a stolen ticket is near-useless.
|
||||||
|
//
|
||||||
|
// Tickets are bound to the issuing request's IP and User-Agent (hashed). A
|
||||||
|
// stolen ticket replayed from a different origin redeems to null. This is
|
||||||
|
// defense in depth against ticket exfiltration via a logged proxy, browser
|
||||||
|
// extension, or shoulder-surf; it does not stop an attacker who is on the same
|
||||||
|
// IP and UA.
|
||||||
|
//
|
||||||
|
// In-memory + single-instance, matching the existing login rate-limiter
|
||||||
|
// (auth/rate-limit.js). Documented limitation: in a multi-instance deployment
|
||||||
|
// the second step must hit the same node. Acceptable for Dragonflight's
|
||||||
|
// one-mam-api-per-node shape; revisit if that changes.
|
||||||
|
import { randomBytes, createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
const TTL_MS = 5 * 60 * 1000; // 5 minutes to enter a code
|
||||||
|
const tickets = new Map(); // id -> { userId, ipHash, uaHash, expiresAt }
|
||||||
|
|
||||||
|
function sweep() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [id, t] of tickets) if (t.expiresAt <= now) tickets.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashBinding(value) {
|
||||||
|
return createHash('sha256').update(String(value || '')).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function issueTicket(userId, { ip, userAgent } = {}) {
|
||||||
|
sweep();
|
||||||
|
const id = randomBytes(32).toString('hex');
|
||||||
|
tickets.set(id, {
|
||||||
|
userId,
|
||||||
|
ipHash: hashBinding(ip),
|
||||||
|
uaHash: hashBinding(userAgent),
|
||||||
|
expiresAt: Date.now() + TTL_MS,
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redeem (and consume) a ticket. Returns the userId, or null if missing,
|
||||||
|
// expired, or the binding doesn't match the redeeming request.
|
||||||
|
export function redeemTicket(id, { ip, userAgent } = {}) {
|
||||||
|
if (!id) return null;
|
||||||
|
const t = tickets.get(id);
|
||||||
|
if (!t) return null;
|
||||||
|
tickets.delete(id); // single-use — burn even on binding mismatch so a
|
||||||
|
// wrong-binding probe can't be retried.
|
||||||
|
if (t.expiresAt <= Date.now()) return null;
|
||||||
|
// If a caller doesn't supply bindings (e.g. tests), accept — the issue side
|
||||||
|
// controls whether bindings get recorded.
|
||||||
|
if (ip !== undefined && t.ipHash !== hashBinding(ip)) return null;
|
||||||
|
if (userAgent !== undefined && t.uaHash !== hashBinding(userAgent)) return null;
|
||||||
|
return t.userId;
|
||||||
|
}
|
||||||
19
services/mam-api/src/auth/passwords.js
Normal file
19
services/mam-api/src/auth/passwords.js
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
// Thin bcrypt wrapper. Cost 12 per the spec (NIST SP 800-63B-friendly).
|
||||||
|
// comparePassword must never throw on a malformed hash — that path is hit
|
||||||
|
// by the seeded dev user's placeholder hash and by any partially-imported
|
||||||
|
// row. Throwing here would 500 on a wrong-password attempt.
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
const COST = 12;
|
||||||
|
|
||||||
|
export async function hashPassword(plain) {
|
||||||
|
return bcrypt.hash(plain, COST);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function comparePassword(plain, hash) {
|
||||||
|
try {
|
||||||
|
return await bcrypt.compare(plain, hash);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
services/mam-api/src/auth/rate-limit.js
Normal file
24
services/mam-api/src/auth/rate-limit.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// Per-IP exponential backoff for /auth/login. Single-instance — fine for
|
||||||
|
// Dragonflight's deployment shape (one mam-api per node). Documented limitation.
|
||||||
|
const failures = new Map(); // ip -> count
|
||||||
|
|
||||||
|
const STEPS = [1000, 2000, 4000, 8000, 16000, 30000];
|
||||||
|
const MAX_ENTRIES = 10000; // bounded to prevent unbounded growth under spray attacks
|
||||||
|
|
||||||
|
export const ipBackoff = {
|
||||||
|
delayMs(ip) {
|
||||||
|
const n = failures.get(ip) || 0;
|
||||||
|
if (n === 0) return 0;
|
||||||
|
return STEPS[Math.min(n - 1, STEPS.length - 1)];
|
||||||
|
},
|
||||||
|
recordFailure(ip) {
|
||||||
|
// Evict the oldest entry if we're at the cap. Map preserves insertion order,
|
||||||
|
// so .keys().next().value is the oldest.
|
||||||
|
if (failures.size >= MAX_ENTRIES && !failures.has(ip)) {
|
||||||
|
failures.delete(failures.keys().next().value);
|
||||||
|
}
|
||||||
|
failures.set(ip, (failures.get(ip) || 0) + 1);
|
||||||
|
},
|
||||||
|
recordSuccess(ip) { failures.delete(ip); },
|
||||||
|
reset(ip) { failures.delete(ip); },
|
||||||
|
};
|
||||||
22
services/mam-api/src/auth/tokens.js
Normal file
22
services/mam-api/src/auth/tokens.js
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { randomBytes, createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
const PREFIX = 'dfl_';
|
||||||
|
|
||||||
|
export function generateToken() {
|
||||||
|
return PREFIX + randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashToken(token) {
|
||||||
|
return createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBearer(authorizationHeader) {
|
||||||
|
if (!authorizationHeader || typeof authorizationHeader !== 'string') return null;
|
||||||
|
const m = authorizationHeader.match(/^Bearer\s+(\S+)$/i);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TOKEN_PREFIX_DISPLAY_LEN = 8; // for api_tokens.token_prefix
|
||||||
|
export function tokenDisplayPrefix(token) {
|
||||||
|
return token.slice(0, TOKEN_PREFIX_DISPLAY_LEN);
|
||||||
|
}
|
||||||
118
services/mam-api/src/auth/totp.js
Normal file
118
services/mam-api/src/auth/totp.js
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
// TOTP (RFC 6238) implemented on node:crypto — no runtime dependency.
|
||||||
|
//
|
||||||
|
// Why hand-rolled: the algorithm is small and stable, and avoiding a dep keeps
|
||||||
|
// the auth core auditable. Verified against the RFC 6238 Appendix B test vectors
|
||||||
|
// in test/auth/totp.test.js.
|
||||||
|
//
|
||||||
|
// Defaults match every mainstream authenticator app (Google Authenticator,
|
||||||
|
// Authy, 1Password): SHA-1, 6 digits, 30-second step.
|
||||||
|
|
||||||
|
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
const DIGITS = 6;
|
||||||
|
const STEP_SECONDS = 30;
|
||||||
|
const RFC4648_B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
|
|
||||||
|
// ── base32 (RFC 4648, no padding) ──────────────────────────────────────────
|
||||||
|
export function base32Encode(buf) {
|
||||||
|
let bits = 0, value = 0, out = '';
|
||||||
|
for (const byte of buf) {
|
||||||
|
value = (value << 8) | byte;
|
||||||
|
bits += 8;
|
||||||
|
while (bits >= 5) {
|
||||||
|
out += RFC4648_B32[(value >>> (bits - 5)) & 31];
|
||||||
|
bits -= 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bits > 0) out += RFC4648_B32[(value << (5 - bits)) & 31];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base32Decode(str) {
|
||||||
|
const clean = str.replace(/=+$/,'').toUpperCase().replace(/\s+/g, '');
|
||||||
|
let bits = 0, value = 0;
|
||||||
|
const out = [];
|
||||||
|
for (const ch of clean) {
|
||||||
|
const idx = RFC4648_B32.indexOf(ch);
|
||||||
|
if (idx === -1) continue; // skip stray chars
|
||||||
|
value = (value << 5) | idx;
|
||||||
|
bits += 5;
|
||||||
|
if (bits >= 8) {
|
||||||
|
out.push((value >>> (bits - 8)) & 0xff);
|
||||||
|
bits -= 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Buffer.from(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a new base32 secret (20 random bytes = 160 bits, the RFC-recommended
|
||||||
|
// SHA-1 key length).
|
||||||
|
export function generateSecret() {
|
||||||
|
return base32Encode(randomBytes(20));
|
||||||
|
}
|
||||||
|
|
||||||
|
// HOTP for a specific counter (RFC 4226).
|
||||||
|
function hotp(secretBuf, counter) {
|
||||||
|
const buf = Buffer.alloc(8);
|
||||||
|
// 64-bit big-endian counter.
|
||||||
|
buf.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
|
||||||
|
buf.writeUInt32BE(counter >>> 0, 4);
|
||||||
|
const hmac = createHmac('sha1', secretBuf).update(buf).digest();
|
||||||
|
const offset = hmac[hmac.length - 1] & 0x0f;
|
||||||
|
const code = ((hmac[offset] & 0x7f) << 24)
|
||||||
|
| ((hmac[offset + 1] & 0xff) << 16)
|
||||||
|
| ((hmac[offset + 2] & 0xff) << 8)
|
||||||
|
| (hmac[offset + 3] & 0xff);
|
||||||
|
return String(code % (10 ** DIGITS)).padStart(DIGITS, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The TOTP code for a given time (defaults to now).
|
||||||
|
export function generateToken(base32Secret, atMs = Date.now()) {
|
||||||
|
const counter = Math.floor(atMs / 1000 / STEP_SECONDS);
|
||||||
|
return hotp(base32Decode(base32Secret), counter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify a user-supplied code, allowing ±`window` steps of clock drift
|
||||||
|
// (default ±1 = 90s total tolerance). Constant-time compare per candidate.
|
||||||
|
//
|
||||||
|
// Returns the matched counter on success (so callers can persist it for
|
||||||
|
// replay protection — RFC 6238 §5.2), or null on failure. Boolean truthiness
|
||||||
|
// still works for the common case (`if (verifyToken(...))`).
|
||||||
|
export function verifyToken(base32Secret, token, atMs = Date.now(), window = 1) {
|
||||||
|
if (!base32Secret || !token) return null;
|
||||||
|
const cleaned = String(token).replace(/\s+/g, '');
|
||||||
|
if (!/^\d{6}$/.test(cleaned)) return null;
|
||||||
|
const secretBuf = base32Decode(base32Secret);
|
||||||
|
const counter = Math.floor(atMs / 1000 / STEP_SECONDS);
|
||||||
|
const want = Buffer.from(cleaned);
|
||||||
|
for (let w = -window; w <= window; w++) {
|
||||||
|
const candidate = Buffer.from(hotp(secretBuf, counter + w));
|
||||||
|
if (candidate.length === want.length && timingSafeEqual(candidate, want)) return counter + w;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The otpauth:// URI an authenticator app scans. label/issuer show in the app.
|
||||||
|
export function otpauthURI(base32Secret, accountName, issuer = 'Dragonflight') {
|
||||||
|
const label = encodeURIComponent(`${issuer}:${accountName}`);
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
secret: base32Secret,
|
||||||
|
issuer,
|
||||||
|
algorithm: 'SHA1',
|
||||||
|
digits: String(DIGITS),
|
||||||
|
period: String(STEP_SECONDS),
|
||||||
|
});
|
||||||
|
return `otpauth://totp/${label}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate N human-friendly one-time recovery codes (raw form). Caller hashes
|
||||||
|
// them before storage and shows the raw set to the user exactly once.
|
||||||
|
export function generateRecoveryCodes(n = 10) {
|
||||||
|
const codes = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
// 10 hex chars in two dash-separated groups, e.g. "a1b2c-3d4e5".
|
||||||
|
const hex = randomBytes(5).toString('hex');
|
||||||
|
codes.push(hex.slice(0, 5) + '-' + hex.slice(5));
|
||||||
|
}
|
||||||
|
return codes;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- 2026-05: add 'live' to asset_status for growing-file ingest
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'live' AND enumtypid = 'asset_status'::regtype) THEN
|
||||||
|
ALTER TYPE asset_status ADD VALUE 'live' BEFORE 'ingesting';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
36
services/mam-api/src/db/migrations/002-groups-tokens.sql
Normal file
36
services/mam-api/src/db/migrations/002-groups-tokens.sql
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
-- Wild Dragon MAM – Groups & API Tokens
|
||||||
|
-- Idempotent: safe to re-run (IF NOT EXISTS guards throughout)
|
||||||
|
|
||||||
|
-- User groups
|
||||||
|
CREATE TABLE IF NOT EXISTS groups (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- User ↔ group memberships
|
||||||
|
CREATE TABLE IF NOT EXISTS user_groups (
|
||||||
|
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||||
|
group_id UUID NOT NULL REFERENCES groups ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_id, group_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Personal API tokens (Bearer auth alternative to session cookies)
|
||||||
|
-- token_hash : SHA-256(raw_token) stored as hex
|
||||||
|
-- token_prefix: first 8 chars of raw token for display only
|
||||||
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
token_prefix TEXT NOT NULL,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_groups_user ON user_groups(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_groups_group ON user_groups(group_id);
|
||||||
74
services/mam-api/src/db/migrations/003-editor-sequences.sql
Normal file
74
services/mam-api/src/db/migrations/003-editor-sequences.sql
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
-- Wild Dragon MAM – Editor sequences
|
||||||
|
-- Idempotent: safe to re-run (IF NOT EXISTS / DO $$ BEGIN guards throughout)
|
||||||
|
|
||||||
|
-- Named timelines within a project (multiple per project, like Premiere)
|
||||||
|
CREATE TABLE IF NOT EXISTS sequences (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL DEFAULT 'Sequence 1',
|
||||||
|
frame_rate NUMERIC(6,3) NOT NULL DEFAULT 59.94,
|
||||||
|
width INTEGER NOT NULL DEFAULT 1920,
|
||||||
|
height INTEGER NOT NULL DEFAULT 1080,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sequences_project_id ON sequences(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sequences_updated_at ON sequences(updated_at DESC);
|
||||||
|
|
||||||
|
-- Clips placed on a sequence timeline
|
||||||
|
CREATE TABLE IF NOT EXISTS sequence_clips (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
sequence_id UUID NOT NULL REFERENCES sequences ON DELETE CASCADE,
|
||||||
|
asset_id UUID NOT NULL REFERENCES assets ON DELETE CASCADE,
|
||||||
|
track INTEGER NOT NULL DEFAULT 0 CHECK (track >= 0),
|
||||||
|
-- track encoding: 0=V1, 1=V2, 100=A1, 101=A2
|
||||||
|
timeline_in_frames BIGINT NOT NULL,
|
||||||
|
timeline_out_frames BIGINT NOT NULL,
|
||||||
|
source_in_frames BIGINT NOT NULL DEFAULT 0,
|
||||||
|
source_out_frames BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sequence_clips_sequence_id ON sequence_clips(sequence_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sequence_clips_track_position ON sequence_clips(sequence_id, track, timeline_in_frames);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sequence_clips_asset_id ON sequence_clips(asset_id);
|
||||||
|
|
||||||
|
-- Unique sequence name per project
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'uq_sequences_project_name' AND conrelid = 'sequences'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE sequences ADD CONSTRAINT uq_sequences_project_name UNIQUE (project_id, name);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Timeline range constraints
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'chk_timeline_range' AND conrelid = 'sequence_clips'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE sequence_clips ADD CONSTRAINT chk_timeline_range CHECK (timeline_out_frames > timeline_in_frames);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'chk_source_range' AND conrelid = 'sequence_clips'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE sequence_clips ADD CONSTRAINT chk_source_range CHECK (source_out_frames > source_in_frames);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'chk_track_valid' AND conrelid = 'sequence_clips'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE sequence_clips ADD CONSTRAINT chk_track_valid CHECK (track >= 0);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
15
services/mam-api/src/db/migrations/004-cluster-nodes.sql
Normal file
15
services/mam-api/src/db/migrations/004-cluster-nodes.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS cluster_nodes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
ip_address TEXT,
|
||||||
|
role TEXT NOT NULL DEFAULT 'worker',
|
||||||
|
version TEXT,
|
||||||
|
api_url TEXT,
|
||||||
|
cpu_usage NUMERIC(5,2),
|
||||||
|
mem_used_mb INTEGER,
|
||||||
|
mem_total_mb INTEGER,
|
||||||
|
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
metadata JSONB,
|
||||||
|
CONSTRAINT cluster_nodes_hostname_uq UNIQUE (hostname)
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
-- Add hardware capabilities column to cluster_nodes
|
||||||
|
-- Stores GPUs and capture cards detected/reported by node-agent
|
||||||
|
ALTER TABLE cluster_nodes
|
||||||
|
ADD COLUMN IF NOT EXISTS capabilities JSONB DEFAULT '{}';
|
||||||
5
services/mam-api/src/db/migrations/006-settings.sql
Normal file
5
services/mam-api/src/db/migrations/006-settings.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
-- 007 — De-duplicate cluster_nodes by hostname and enforce uniqueness.
|
||||||
|
--
|
||||||
|
-- Migration 004 created the table with `CREATE TABLE IF NOT EXISTS` and an
|
||||||
|
-- inline UNIQUE constraint; on deploys where the table predated 004 the
|
||||||
|
-- constraint was never applied, which let the same hostname accumulate
|
||||||
|
-- multiple rows (one per container restart in some setups).
|
||||||
|
--
|
||||||
|
-- This migration:
|
||||||
|
-- 1. Deletes older duplicates keeping only the most-recently-seen row
|
||||||
|
-- per hostname.
|
||||||
|
-- 2. Adds a UNIQUE INDEX on (hostname) which is idempotent and satisfies
|
||||||
|
-- the ON CONFLICT (hostname) upsert in routes/cluster.js.
|
||||||
|
|
||||||
|
DELETE FROM cluster_nodes a
|
||||||
|
USING cluster_nodes b
|
||||||
|
WHERE a.hostname = b.hostname
|
||||||
|
AND a.last_seen < b.last_seen;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS cluster_nodes_hostname_uniq
|
||||||
|
ON cluster_nodes (hostname);
|
||||||
26
services/mam-api/src/db/migrations/008-codec-settings.sql
Normal file
26
services/mam-api/src/db/migrations/008-codec-settings.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
-- 008 — Extended codec controls for recorders.
|
||||||
|
--
|
||||||
|
-- Adds video bitrate, framerate, audio codec / bitrate / channels, and
|
||||||
|
-- container format columns to recorders so the UI can offer granular
|
||||||
|
-- control instead of the four-options dropdown. capture-manager.js reads
|
||||||
|
-- these via env vars and builds ffmpeg args from them.
|
||||||
|
|
||||||
|
ALTER TABLE recorders
|
||||||
|
ADD COLUMN IF NOT EXISTS recording_video_bitrate TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS recording_framerate TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS recording_audio_codec TEXT DEFAULT 'pcm_s24le',
|
||||||
|
ADD COLUMN IF NOT EXISTS recording_audio_bitrate TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS recording_audio_channels INTEGER DEFAULT 2,
|
||||||
|
ADD COLUMN IF NOT EXISTS recording_container TEXT DEFAULT 'mov',
|
||||||
|
ADD COLUMN IF NOT EXISTS proxy_video_bitrate TEXT DEFAULT '8M',
|
||||||
|
ADD COLUMN IF NOT EXISTS proxy_framerate TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS proxy_audio_codec TEXT DEFAULT 'aac',
|
||||||
|
ADD COLUMN IF NOT EXISTS proxy_audio_bitrate TEXT DEFAULT '192k',
|
||||||
|
ADD COLUMN IF NOT EXISTS proxy_audio_channels INTEGER DEFAULT 2,
|
||||||
|
ADD COLUMN IF NOT EXISTS proxy_container TEXT DEFAULT 'mp4',
|
||||||
|
ADD COLUMN IF NOT EXISTS node_id UUID,
|
||||||
|
ADD COLUMN IF NOT EXISTS device_index INTEGER;
|
||||||
|
|
||||||
|
-- node_id is the cluster_nodes.id the recorder is pinned to (for SDI
|
||||||
|
-- recorders this is the node hosting the DeckLink card). device_index is
|
||||||
|
-- the DeckLink port index on that node.
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
-- Recorder schedules
|
||||||
|
--
|
||||||
|
-- Lets operators schedule a recorder to start at a future time and stop
|
||||||
|
-- after a duration. The scheduler tick loop in mam-api (src/scheduler.js)
|
||||||
|
-- watches this table every 15s and triggers the existing /recorders/:id
|
||||||
|
-- start + stop endpoints when each schedule's window opens or closes.
|
||||||
|
--
|
||||||
|
-- recurrence: 'none' (one-shot) or 'daily' for the MVP. When a 'daily'
|
||||||
|
-- schedule completes, the tick loop clones it forward by 24h.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recorder_schedules (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
recorder_id UUID NOT NULL REFERENCES recorders(id) ON DELETE CASCADE,
|
||||||
|
start_at TIMESTAMPTZ NOT NULL,
|
||||||
|
end_at TIMESTAMPTZ NOT NULL,
|
||||||
|
recurrence TEXT NOT NULL DEFAULT 'none',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
last_asset_id UUID,
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CHECK (end_at > start_at),
|
||||||
|
CHECK (recurrence IN ('none','daily','weekly')),
|
||||||
|
CHECK (status IN ('pending','running','completed','failed','cancelled'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recorder_schedules_status_start
|
||||||
|
ON recorder_schedules (status, start_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recorder_schedules_recorder
|
||||||
|
ON recorder_schedules (recorder_id);
|
||||||
22
services/mam-api/src/db/migrations/010-asset-comments.sql
Normal file
22
services/mam-api/src/db/migrations/010-asset-comments.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
-- Asset comments — frame-anchored notes on the Asset Detail page.
|
||||||
|
--
|
||||||
|
-- Comments are scoped to an asset and optionally to a timecode within that
|
||||||
|
-- asset. `frame_ms` is the playhead position when the comment was posted.
|
||||||
|
-- `resolved` lets editors hide rolled-up notes once addressed.
|
||||||
|
--
|
||||||
|
-- User ID is optional (nullable) so comments still attach when AUTH_ENABLED
|
||||||
|
-- is off and there's no real session user.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS asset_comments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
asset_id UUID NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
frame_ms INTEGER,
|
||||||
|
resolved BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_asset_comments_asset
|
||||||
|
ON asset_comments (asset_id, created_at);
|
||||||
15
services/mam-api/src/db/migrations/011-youtube-import.sql
Normal file
15
services/mam-api/src/db/migrations/011-youtube-import.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
-- 2026-05: YouTube importer — new job type + remember source URL on assets.
|
||||||
|
--
|
||||||
|
-- Job type enum gains 'youtube_import' so the Jobs screen can show imports
|
||||||
|
-- alongside proxy / thumbnail / conform. Assets gain source_url so an
|
||||||
|
-- imported asset remembers where it came from (used by the Asset Detail
|
||||||
|
-- page and, later, dedup checks).
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'youtube_import' AND enumtypid = 'job_type'::regtype) THEN
|
||||||
|
ALTER TYPE job_type ADD VALUE 'youtube_import';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE assets ADD COLUMN IF NOT EXISTS source_url TEXT;
|
||||||
31
services/mam-api/src/db/migrations/012-advanced-features.sql
Normal file
31
services/mam-api/src/db/migrations/012-advanced-features.sql
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
-- 2026-05: Advanced features — trim jobs, temp segments, conform tracking.
|
||||||
|
-- Idempotent: safe to re-run (IF NOT EXISTS / DO $$ BEGIN guards throughout).
|
||||||
|
|
||||||
|
-- 1. Add 'trim' to job_type enum
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'trim' AND enumtypid = 'job_type'::regtype) THEN
|
||||||
|
ALTER TYPE job_type ADD VALUE 'trim';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 2. Temp segments table — tracks per-clip trimmed hi-res segments
|
||||||
|
-- with a 24-hour TTL for auto-cleanup.
|
||||||
|
CREATE TABLE IF NOT EXISTS temp_segments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
job_id UUID NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
||||||
|
clip_instance_id UUID NOT NULL,
|
||||||
|
asset_id UUID NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
||||||
|
s3_key TEXT NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_temp_segments_expires_at ON temp_segments(expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_temp_segments_job_id ON temp_segments(job_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_temp_segments_asset_id ON temp_segments(asset_id);
|
||||||
|
|
||||||
|
-- 3. Asset conform tracking — remember which sequence this asset was
|
||||||
|
-- conformed from so the UI can show lineage and prevent double-conform.
|
||||||
|
ALTER TABLE assets ADD COLUMN IF NOT EXISTS conform_source_sequence_id UUID REFERENCES sequences(id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assets_conform_source ON assets(conform_source_sequence_id);
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- 2026-05: Add missing updated_at column to bins table.
|
||||||
|
-- The INSERT and PATCH handlers already reference updated_at.
|
||||||
|
ALTER TABLE bins ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW();
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- Migration 014: Per-recorder growing_enabled override
|
||||||
|
-- Adds a nullable boolean to the recorders table so each recorder can
|
||||||
|
-- independently override the global growing_enabled setting. NULL means
|
||||||
|
-- "use global"; TRUE/FALSE means "force on/off for this recorder".
|
||||||
|
|
||||||
|
ALTER TABLE recorders
|
||||||
|
ADD COLUMN IF NOT EXISTS growing_enabled BOOLEAN DEFAULT NULL;
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- Migration 015: Add growing_retention_days to settings table
|
||||||
|
-- Default 30 days. ON CONFLICT DO NOTHING is idempotent -- safe to re-run.
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value)
|
||||||
|
VALUES ('growing_retention_days', '30')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
37
services/mam-api/src/db/migrations/016-fix-job-type-enum.sql
Normal file
37
services/mam-api/src/db/migrations/016-fix-job-type-enum.sql
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
-- Migration 016: Fix job_type/job_status enums and add jobs TTL (#75, #70)
|
||||||
|
--
|
||||||
|
-- 1. Add 'proxy' and 'import' to job_type so queue names match enum values.
|
||||||
|
-- 2. Add 'completed' to job_status to match the trimWorker status string.
|
||||||
|
-- 3. Add expires_at column to jobs so stale trim rows auto-expire (#70).
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_enum
|
||||||
|
WHERE enumlabel = 'proxy' AND enumtypid = 'job_type'::regtype
|
||||||
|
) THEN
|
||||||
|
ALTER TYPE job_type ADD VALUE 'proxy';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_enum
|
||||||
|
WHERE enumlabel = 'import' AND enumtypid = 'job_type'::regtype
|
||||||
|
) THEN
|
||||||
|
ALTER TYPE job_type ADD VALUE 'import';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_enum
|
||||||
|
WHERE enumlabel = 'completed' AND enumtypid = 'job_status'::regtype
|
||||||
|
) THEN
|
||||||
|
ALTER TYPE job_status ADD VALUE 'completed';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Add TTL column to jobs (NULL = no expiry; trim jobs set 24h from creation)
|
||||||
|
ALTER TABLE jobs
|
||||||
|
ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ DEFAULT NULL;
|
||||||
|
|
||||||
|
-- Backfill: give any existing trim rows a 24h TTL from their creation time
|
||||||
|
UPDATE jobs SET expires_at = created_at + INTERVAL '24 hours'
|
||||||
|
WHERE type = 'trim' AND expires_at IS NULL;
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- Migration 017: Partial unique index on live assets (#63)
|
||||||
|
--
|
||||||
|
-- Prevents two simultaneous captures from registering the same
|
||||||
|
-- (project_id, display_name) pair with status='live'. The INSERT in
|
||||||
|
-- POST /assets will fail with a unique-constraint violation instead of
|
||||||
|
-- silently overwriting the first capture's metadata.
|
||||||
|
--
|
||||||
|
-- Only applies to live rows — archived, ready, error etc. are unaffected,
|
||||||
|
-- so duplicate names are still allowed across historical recordings.
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_assets_live_unique
|
||||||
|
ON assets (project_id, display_name)
|
||||||
|
WHERE status = 'live';
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- Migration 018: Add filmstrip_s3_key to assets
|
||||||
|
-- Stores the S3 path to a JSON array of base64 JPEG frames generated
|
||||||
|
-- server-side by the filmstrip worker. Allows the UI to fetch a pre-built
|
||||||
|
-- filmstrip instead of seeking through the proxy in the browser.
|
||||||
|
|
||||||
|
ALTER TABLE assets
|
||||||
|
ADD COLUMN IF NOT EXISTS filmstrip_s3_key TEXT DEFAULT NULL;
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
-- Issue #106 — bind cluster tokens to a specific hostname so a compromised
|
||||||
|
-- worker token can't be used to hijack another node's `api_url` via
|
||||||
|
-- POST /cluster/heartbeat.
|
||||||
|
--
|
||||||
|
-- `bound_hostname` is NULL for ordinary user tokens (no binding) and set
|
||||||
|
-- to the node's hostname for node-agent tokens. The heartbeat handler
|
||||||
|
-- checks that body.hostname === token.bound_hostname when bound_hostname
|
||||||
|
-- is non-null.
|
||||||
|
|
||||||
|
ALTER TABLE api_tokens
|
||||||
|
ADD COLUMN IF NOT EXISTS bound_hostname TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS api_tokens_bound_hostname_idx
|
||||||
|
ON api_tokens (bound_hostname)
|
||||||
|
WHERE bound_hostname IS NOT NULL;
|
||||||
19
services/mam-api/src/db/migrations/020-ampp-sync-retry.sql
Normal file
19
services/mam-api/src/db/migrations/020-ampp-sync-retry.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
-- Issue #77 — AMPP sync used to be fire-and-forget: failures were swallowed
|
||||||
|
-- with a console.error and never retried. Track the state of every asset's
|
||||||
|
-- AMPP sync so the scheduler tick can retry pending/failed rows on a
|
||||||
|
-- backoff schedule.
|
||||||
|
--
|
||||||
|
-- ampp_sync_status: 'pending' | 'synced' | 'failed' | 'disabled'
|
||||||
|
-- ampp_sync_attempts: count, used for exponential backoff
|
||||||
|
-- ampp_sync_next_attempt_at: when the scheduler should next try this asset
|
||||||
|
-- ampp_sync_last_error: short error message for the operator (truncated)
|
||||||
|
|
||||||
|
ALTER TABLE assets
|
||||||
|
ADD COLUMN IF NOT EXISTS ampp_sync_status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
ADD COLUMN IF NOT EXISTS ampp_sync_attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS ampp_sync_next_attempt_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS ampp_sync_last_error TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS assets_ampp_sync_idx
|
||||||
|
ON assets (ampp_sync_status, ampp_sync_next_attempt_at)
|
||||||
|
WHERE ampp_sync_status IN ('pending', 'failed');
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
-- connect-pg-simple's session store needs this table. It's defined in
|
||||||
|
-- schema.sql which only runs on first DB init via the postgres entrypoint;
|
||||||
|
-- on instances bootstrapped via migrations only (no entrypoint init), the
|
||||||
|
-- table never existed and every login silently failed to persist the
|
||||||
|
-- session — manifesting as a redirect loop after submitting valid creds.
|
||||||
|
-- Idempotent so this is safe to re-run.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
sid TEXT PRIMARY KEY,
|
||||||
|
sess JSONB NOT NULL,
|
||||||
|
expire TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_expire ON sessions (expire);
|
||||||
12
services/mam-api/src/db/migrations/022-audio-metadata.sql
Normal file
12
services/mam-api/src/db/migrations/022-audio-metadata.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- 022-audio-metadata.sql
|
||||||
|
-- Store per-track audio metadata extracted by ffprobe during proxy generation.
|
||||||
|
-- Shape: JSON array of objects, one per audio stream, e.g.:
|
||||||
|
-- [
|
||||||
|
-- {"index":1,"codec":"pcm_s24le","channels":2,"channel_layout":"stereo",
|
||||||
|
-- "sample_rate":48000,"bit_depth":24,"bit_rate":2304000,"language":null},
|
||||||
|
-- {"index":2,"codec":"aac","channels":2,"channel_layout":"stereo",
|
||||||
|
-- "sample_rate":48000,"bit_depth":null,"bit_rate":128000,"language":"en"}
|
||||||
|
-- ]
|
||||||
|
-- NULL means the asset has not been probed yet or has no audio streams.
|
||||||
|
|
||||||
|
ALTER TABLE assets ADD COLUMN IF NOT EXISTS audio_metadata JSONB;
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
-- Migration 023 — auth-related user timestamps + idempotent dev user.
|
||||||
|
--
|
||||||
|
-- See docs/superpowers/specs/2026-05-27-auth-system-design.md
|
||||||
|
--
|
||||||
|
-- password_updated_at + last_login_at are operator visibility, no logic depends on them yet.
|
||||||
|
-- The dev user is seeded with a fixed UUID so FK-bearing routes (api_tokens,
|
||||||
|
-- future audit fields) keep working when AUTH_ENABLED=false. The seeded
|
||||||
|
-- password_hash is a placeholder that no bcrypt.compare will accept, so the
|
||||||
|
-- dev row cannot be used to log in even if AUTH_ENABLED is later flipped on.
|
||||||
|
--
|
||||||
|
-- password_updated_at is backfilled with NOW() for existing rows at migration time;
|
||||||
|
-- treat values from before this deploy as approximate.
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_updated_at TIMESTAMPTZ DEFAULT NOW();
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
INSERT INTO users (id, username, password_hash, display_name, role)
|
||||||
|
VALUES (
|
||||||
|
'00000000-0000-4000-8000-000000000000',
|
||||||
|
'dev',
|
||||||
|
'!disabled-no-login!',
|
||||||
|
'Dev (AUTH_ENABLED=false)',
|
||||||
|
'admin'
|
||||||
|
)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- Migration 024: add 'deltacast' to the source_type enum
|
||||||
|
-- Allows recorders to be configured for Deltacast VideoMaster SDI cards.
|
||||||
|
-- ALTER TYPE ... ADD VALUE is not transactional in PG < 12 but is safe in PG 12+.
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_enum
|
||||||
|
WHERE enumtypid = 'source_type'::regtype
|
||||||
|
AND enumlabel = 'deltacast'
|
||||||
|
) THEN
|
||||||
|
ALTER TYPE source_type ADD VALUE 'deltacast';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
5
services/mam-api/src/db/migrations/025-hls-key.sql
Normal file
5
services/mam-api/src/db/migrations/025-hls-key.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- HLS VOD playback: per-asset HLS rendition (fMP4) generated by the proxy
|
||||||
|
-- worker alongside the MP4 proxy. Presence of hls_s3_key means an HLS
|
||||||
|
-- playlist exists at hls/<asset_id>/playlist.m3u8 and /assets/:id/stream
|
||||||
|
-- should prefer it (type: 'hls') over the MP4 range-stitched /video path.
|
||||||
|
ALTER TABLE assets ADD COLUMN IF NOT EXISTS hls_s3_key TEXT;
|
||||||
30
services/mam-api/src/db/migrations/026-project-access.sql
Normal file
30
services/mam-api/src/db/migrations/026-project-access.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
-- Migration 026 — per-project access grants (RBAC v2).
|
||||||
|
--
|
||||||
|
-- v1 auth is flat: any logged-in user can do everything. This adds per-project
|
||||||
|
-- scoping. A grant targets either a user or a group (polymorphic subject) and
|
||||||
|
-- carries a level: 'view' (read-only) or 'edit' (read-write). Admins bypass all
|
||||||
|
-- of this in code (authz.js) and need no rows here.
|
||||||
|
--
|
||||||
|
-- subject_id is intentionally NOT a foreign key — it points at either users.id
|
||||||
|
-- or groups.id depending on subject_type. Rows are cleaned up when the project
|
||||||
|
-- is deleted (FK cascade). A deleted user/group leaves an orphan row that
|
||||||
|
-- resolves to nobody (harmless); a later sweep can prune them if desired.
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'access_level') THEN
|
||||||
|
CREATE TYPE access_level AS ENUM ('view', 'edit');
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS project_access (
|
||||||
|
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
|
||||||
|
subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group')),
|
||||||
|
subject_id UUID NOT NULL,
|
||||||
|
level access_level NOT NULL DEFAULT 'view',
|
||||||
|
granted_by UUID REFERENCES users ON DELETE SET NULL,
|
||||||
|
granted_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (project_id, subject_type, subject_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_project_access_subject
|
||||||
|
ON project_access (subject_type, subject_id);
|
||||||
20
services/mam-api/src/db/migrations/027-totp-2fa.sql
Normal file
20
services/mam-api/src/db/migrations/027-totp-2fa.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
-- Migration 027 — TOTP two-factor auth.
|
||||||
|
--
|
||||||
|
-- totp_secret holds the base32 shared secret once enrollment is confirmed
|
||||||
|
-- (NULL while disabled or mid-enrollment). totp_enabled flips true only after
|
||||||
|
-- the user verifies their first code, so a half-finished enrollment never locks
|
||||||
|
-- anyone out. Recovery codes are one-time bcrypt-hashed fallbacks; used_at marks
|
||||||
|
-- a code as spent.
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_recovery_codes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||||
|
code_hash TEXT NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_recovery_codes_user ON user_recovery_codes(user_id);
|
||||||
13
services/mam-api/src/db/migrations/028-google-oauth.sql
Normal file
13
services/mam-api/src/db/migrations/028-google-oauth.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- Migration 028 — Google OAuth (OIDC) sign-in.
|
||||||
|
--
|
||||||
|
-- google_sub is Google's stable subject identifier — the join key for a linked
|
||||||
|
-- or auto-provisioned account (unique, but NULL for password-only users).
|
||||||
|
-- email is captured for display + domain checks. password_hash becomes nullable
|
||||||
|
-- so an OAuth-only account can exist without a local password; such an account
|
||||||
|
-- simply can't use the password login path until an admin sets one.
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS google_sub TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT;
|
||||||
|
ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_google_sub ON users(google_sub) WHERE google_sub IS NOT NULL;
|
||||||
165
services/mam-api/src/db/migrations/029-playout.sql
Normal file
165
services/mam-api/src/db/migrations/029-playout.sql
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
-- Migration 029 — Playout / Master Control (MCR).
|
||||||
|
--
|
||||||
|
-- Adds a broadcast playout subsystem: take library assets, arrange them on a
|
||||||
|
-- playlist (Phase A) or a wall-clock timeline (Phase B), and play them out
|
||||||
|
-- continuously to SDI (DeckLink) / NDI / SRT / RTMP via a CasparCG sidecar.
|
||||||
|
--
|
||||||
|
-- This is the mirror of the capture path (input -> ffmpeg -> S3). A channel is
|
||||||
|
-- placed on a cluster node by capability the same way recorders claim input
|
||||||
|
-- ports; the engine container is spawned via the same Docker-socket /
|
||||||
|
-- node-agent orchestration. See docs/superpowers/specs/2026-05-30-playout-mcr-design.md.
|
||||||
|
--
|
||||||
|
-- Tables:
|
||||||
|
-- playout_channels — a logical output (one channel -> one CasparCG instance -> one target)
|
||||||
|
-- playout_playlists — an ordered list of items bound to a channel (Phase A)
|
||||||
|
-- playout_items — one clip on a playlist OR one row on the timeline
|
||||||
|
-- playout_sidecars — running CasparCG sidecar registry (one per channel; health-checked)
|
||||||
|
-- playout_schedule — wall-clock day-ahead rows (Phase B; unused in A)
|
||||||
|
-- playout_as_run — append-only log of what actually played (compliance)
|
||||||
|
|
||||||
|
-- ── Channels ───────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS playout_channels (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
node_id UUID REFERENCES cluster_nodes(id) ON DELETE SET NULL,
|
||||||
|
output_type TEXT NOT NULL DEFAULT 'srt',
|
||||||
|
-- output_config is consumer-shape-specific:
|
||||||
|
-- decklink: { "device_index": 1 }
|
||||||
|
-- ndi: { "ndi_name": "DRAGONFLIGHT CH1" }
|
||||||
|
-- srt: { "url": "srt://host:9000", "latency": 200 }
|
||||||
|
-- rtmp: { "url": "rtmp://host/live", "key": "streamkey" }
|
||||||
|
output_config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
-- 1080p59.94 is the house standard (matches capture cadence, streaming-friendly,
|
||||||
|
-- accepted by current SDI gear). Per-channel override allowed.
|
||||||
|
video_format TEXT NOT NULL DEFAULT '1080p5994',
|
||||||
|
status TEXT NOT NULL DEFAULT 'stopped',
|
||||||
|
container_id TEXT,
|
||||||
|
-- For remote channels the node-agent reports the reachable host:port of the
|
||||||
|
-- sidecar HTTP shim; stored here so the API can proxy transport calls.
|
||||||
|
container_meta JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
error_message TEXT,
|
||||||
|
-- Failover bookkeeping. Scheduler tick health-checks the sidecar; on N missed
|
||||||
|
-- checks the channel is re-placed on a healthy node (auto for ndi/srt/rtmp,
|
||||||
|
-- alert-only for decklink — device-index pinning makes re-placement non-trivial).
|
||||||
|
restart_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_restart_at TIMESTAMPTZ,
|
||||||
|
last_heartbeat_at TIMESTAMPTZ,
|
||||||
|
-- RBAC scoping: a NULL project_id resolves to admin-only (authz.js), the same
|
||||||
|
-- convention recorders use for unassigned resources.
|
||||||
|
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CHECK (output_type IN ('decklink','ndi','srt','rtmp')),
|
||||||
|
CHECK (status IN ('stopped','starting','running','error'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_playout_channels_node ON playout_channels (node_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_playout_channels_project ON playout_channels (project_id);
|
||||||
|
|
||||||
|
-- ── Playlists ──────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS playout_playlists (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
channel_id UUID NOT NULL REFERENCES playout_channels(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
loop BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_playout_playlists_channel ON playout_playlists (channel_id);
|
||||||
|
|
||||||
|
-- ── Items ──────────────────────────────────────────────────────────────────
|
||||||
|
-- One entry on a playlist (Phase A, ordered by sort_order) OR one entry on the
|
||||||
|
-- timeline (Phase B, ordered by scheduled_at). in/out points reuse the editor's
|
||||||
|
-- subclip trim model (seconds). media_status tracks the S3 -> /media staging
|
||||||
|
-- (see playout-stage worker job); a clip cannot go on air until 'ready'.
|
||||||
|
CREATE TABLE IF NOT EXISTS playout_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
playlist_id UUID REFERENCES playout_playlists(id) ON DELETE CASCADE,
|
||||||
|
asset_id UUID NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
scheduled_at TIMESTAMPTZ,
|
||||||
|
in_point NUMERIC,
|
||||||
|
out_point NUMERIC,
|
||||||
|
transition TEXT NOT NULL DEFAULT 'cut',
|
||||||
|
transition_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
graphics JSONB,
|
||||||
|
media_status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
media_path TEXT,
|
||||||
|
-- Set when playout-stage has run loudnorm (EBU R128, -23 LUFS / -1 dBTP) on
|
||||||
|
-- the staged file. Re-stages skip the loudnorm pass when true.
|
||||||
|
audio_normalized BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CHECK (transition IN ('cut','mix','wipe')),
|
||||||
|
CHECK (media_status IN ('pending','staging','ready','error'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_playout_items_playlist ON playout_items (playlist_id, sort_order);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_playout_items_asset ON playout_items (asset_id);
|
||||||
|
|
||||||
|
-- ── Sidecars ───────────────────────────────────────────────────────────────
|
||||||
|
-- Running CasparCG container registry, one row per running channel. The
|
||||||
|
-- scheduler tick (src/scheduler.js) pings each sidecar's /status endpoint and
|
||||||
|
-- updates last_heartbeat_at; missed checks trigger the failover path in
|
||||||
|
-- routes/playout.js.
|
||||||
|
CREATE TABLE IF NOT EXISTS playout_sidecars (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
channel_id UUID NOT NULL REFERENCES playout_channels(id) ON DELETE CASCADE,
|
||||||
|
node_id UUID REFERENCES cluster_nodes(id) ON DELETE SET NULL,
|
||||||
|
container_id TEXT NOT NULL,
|
||||||
|
sidecar_url TEXT, -- http://host:port for the shim
|
||||||
|
amcp_port INTEGER, -- in-container AMCP port (default 5250)
|
||||||
|
status TEXT NOT NULL DEFAULT 'running',
|
||||||
|
last_heartbeat_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CHECK (status IN ('starting','running','error','stopped'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_playout_sidecars_channel ON playout_sidecars (channel_id)
|
||||||
|
WHERE status IN ('starting','running');
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_playout_sidecars_status ON playout_sidecars (status);
|
||||||
|
|
||||||
|
-- ── Schedule (Phase B) ─────────────────────────────────────────────────────
|
||||||
|
-- Wall-clock day-ahead timeline. The scheduler tick (src/scheduler.js, under
|
||||||
|
-- the existing PG advisory lock) drives transitions and gap-fill. Unused by the
|
||||||
|
-- Phase A playlist player but created now so the schema is stable.
|
||||||
|
CREATE TABLE IF NOT EXISTS playout_schedule (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
channel_id UUID NOT NULL REFERENCES playout_channels(id) ON DELETE CASCADE,
|
||||||
|
asset_id UUID REFERENCES assets(id) ON DELETE SET NULL,
|
||||||
|
scheduled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
in_point NUMERIC,
|
||||||
|
out_point NUMERIC,
|
||||||
|
transition TEXT NOT NULL DEFAULT 'cut',
|
||||||
|
transition_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_filler BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'scheduled',
|
||||||
|
media_status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
media_path TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CHECK (transition IN ('cut','mix','wipe')),
|
||||||
|
CHECK (status IN ('scheduled','playing','played','skipped','error')),
|
||||||
|
CHECK (media_status IN ('pending','staging','ready','error'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_playout_schedule_channel_time ON playout_schedule (channel_id, scheduled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_playout_schedule_status ON playout_schedule (status, scheduled_at);
|
||||||
|
|
||||||
|
-- ── As-run log ─────────────────────────────────────────────────────────────
|
||||||
|
-- Append-only record of what actually went to air. Never updated after insert.
|
||||||
|
CREATE TABLE IF NOT EXISTS playout_as_run (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
channel_id UUID NOT NULL REFERENCES playout_channels(id) ON DELETE CASCADE,
|
||||||
|
asset_id UUID REFERENCES assets(id) ON DELETE SET NULL,
|
||||||
|
item_id UUID,
|
||||||
|
clip_name TEXT,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
ended_at TIMESTAMPTZ,
|
||||||
|
duration_s NUMERIC,
|
||||||
|
result TEXT NOT NULL DEFAULT 'played',
|
||||||
|
CHECK (result IN ('played','skipped','error'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_playout_as_run_channel ON playout_as_run (channel_id, started_at DESC);
|
||||||
9
services/mam-api/src/db/migrations/030-totp-replay.sql
Normal file
9
services/mam-api/src/db/migrations/030-totp-replay.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- Migration 030 — TOTP replay protection.
|
||||||
|
--
|
||||||
|
-- RFC 6238 §5.2 hardening: track the last counter value we accepted for each
|
||||||
|
-- user and reject codes at counters ≤ the last one. Without this, the same
|
||||||
|
-- 6-digit code can be submitted N times within its 30s step. Low impact in
|
||||||
|
-- practice (the code is only valid for ~90s with ±1 drift) but standard.
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS totp_last_counter BIGINT NOT NULL DEFAULT 0;
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- Migration 031 — Add last_seen_at to cluster_nodes
|
||||||
|
--
|
||||||
|
-- Playout failover (routes/playout.js restartChannel) queries cluster_nodes.last_seen_at
|
||||||
|
-- to find healthy nodes for channel re-placement. Column was missing from original
|
||||||
|
-- cluster schema; heartbeat endpoint updates it via /cluster/heartbeat.
|
||||||
|
|
||||||
|
ALTER TABLE cluster_nodes ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Backfill existing nodes to NOW() so they're immediately eligible for failover
|
||||||
|
UPDATE cluster_nodes SET last_seen_at = NOW() WHERE last_seen_at IS NULL;
|
||||||
|
|
@ -49,7 +49,8 @@ CREATE TABLE bins (
|
||||||
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
|
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
|
||||||
parent_id UUID REFERENCES bins ON DELETE SET NULL,
|
parent_id UUID REFERENCES bins ON DELETE SET NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Assets table
|
-- Assets table
|
||||||
|
|
@ -138,7 +139,7 @@ CREATE INDEX idx_bins_project_id ON bins(project_id);
|
||||||
CREATE INDEX idx_sessions_expire ON sessions(expire);
|
CREATE INDEX idx_sessions_expire ON sessions(expire);
|
||||||
|
|
||||||
-- Recorder source types
|
-- Recorder source types
|
||||||
CREATE TYPE source_type AS ENUM ('sdi', 'srt', 'rtmp');
|
CREATE TYPE source_type AS ENUM ('sdi', 'srt', 'rtmp', 'deltacast');
|
||||||
|
|
||||||
-- Recorder instances table
|
-- Recorder instances table
|
||||||
-- NOTE: current_session_id is TEXT (stores a human-readable clip name, not a UUID)
|
-- NOTE: current_session_id is TEXT (stores a human-readable clip name, not a UUID)
|
||||||
|
|
|
||||||
86
services/mam-api/src/db/schema_patch_editor.sql
Normal file
86
services/mam-api/src/db/schema_patch_editor.sql
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
-- Wild Dragon MAM – Editor sequences schema patch
|
||||||
|
-- Run with: psql $DATABASE_URL -f schema_patch_editor.sql
|
||||||
|
|
||||||
|
-- Named timelines within a project (multiple per project, like Premiere)
|
||||||
|
CREATE TABLE IF NOT EXISTS sequences (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL DEFAULT 'Sequence 1',
|
||||||
|
frame_rate NUMERIC(6,3) NOT NULL DEFAULT 59.94,
|
||||||
|
width INTEGER NOT NULL DEFAULT 1920,
|
||||||
|
height INTEGER NOT NULL DEFAULT 1080,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
CONSTRAINT uq_sequences_project_name UNIQUE (project_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sequences_project_id ON sequences(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sequences_updated_at ON sequences(updated_at DESC);
|
||||||
|
|
||||||
|
-- Clips placed on a sequence timeline
|
||||||
|
CREATE TABLE IF NOT EXISTS sequence_clips (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
sequence_id UUID NOT NULL REFERENCES sequences ON DELETE CASCADE,
|
||||||
|
asset_id UUID NOT NULL REFERENCES assets ON DELETE CASCADE,
|
||||||
|
track INTEGER NOT NULL DEFAULT 0 CHECK (track >= 0),
|
||||||
|
-- track encoding: 0=V1, 1=V2, 100=A1, 101=A2
|
||||||
|
-- Open-ended CHECK (track >= 0) used instead of an enumerated list so that
|
||||||
|
-- additional tracks can be added in the future without a schema migration.
|
||||||
|
timeline_in_frames BIGINT NOT NULL,
|
||||||
|
timeline_out_frames BIGINT NOT NULL,
|
||||||
|
source_in_frames BIGINT NOT NULL DEFAULT 0,
|
||||||
|
source_out_frames BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
CONSTRAINT chk_timeline_range CHECK (timeline_out_frames > timeline_in_frames),
|
||||||
|
CONSTRAINT chk_source_range CHECK (source_out_frames > source_in_frames)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sequence_clips_sequence_id ON sequence_clips(sequence_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sequence_clips_track_position ON sequence_clips(sequence_id, track, timeline_in_frames);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sequence_clips_asset_id ON sequence_clips(asset_id);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Idempotent ALTER TABLE block — applies the new constraints and index to
|
||||||
|
-- tables that were already created by an earlier run of this file.
|
||||||
|
-- Uses DO blocks because PostgreSQL does not support ADD CONSTRAINT IF NOT EXISTS.
|
||||||
|
-- Safe to re-run.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'uq_sequences_project_name' AND conrelid = 'sequences'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE sequences ADD CONSTRAINT uq_sequences_project_name UNIQUE (project_id, name);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'chk_timeline_range' AND conrelid = 'sequence_clips'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE sequence_clips ADD CONSTRAINT chk_timeline_range CHECK (timeline_out_frames > timeline_in_frames);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'chk_source_range' AND conrelid = 'sequence_clips'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE sequence_clips ADD CONSTRAINT chk_source_range CHECK (source_out_frames > source_in_frames);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'chk_track_valid' AND conrelid = 'sequence_clips'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE sequence_clips ADD CONSTRAINT chk_track_valid CHECK (track >= 0);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sequence_clips_asset_id ON sequence_clips(asset_id);
|
||||||
36
services/mam-api/src/db/schema_patch_groups_tokens.sql
Normal file
36
services/mam-api/src/db/schema_patch_groups_tokens.sql
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
-- Wild Dragon MAM – Groups & API Tokens schema patch
|
||||||
|
-- Run with: psql $DATABASE_URL -f schema_patch_groups_tokens.sql
|
||||||
|
|
||||||
|
-- User groups
|
||||||
|
CREATE TABLE IF NOT EXISTS groups (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- User ↔ group memberships
|
||||||
|
CREATE TABLE IF NOT EXISTS user_groups (
|
||||||
|
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||||
|
group_id UUID NOT NULL REFERENCES groups ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_id, group_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Personal API tokens (Bearer auth alternative to session cookies)
|
||||||
|
-- token_hash : SHA-256(raw_token) stored as hex
|
||||||
|
-- token_prefix: first 8 chars of raw token for display only
|
||||||
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
token_prefix TEXT NOT NULL,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_groups_user ON user_groups(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_groups_group ON user_groups(group_id);
|
||||||
|
|
@ -2,12 +2,19 @@ import 'dotenv/config';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import session from 'express-session';
|
import session from 'express-session';
|
||||||
import ConnectPgSimple from 'connect-pg-simple';
|
import connectPgSimple from 'connect-pg-simple';
|
||||||
|
const PgStore = connectPgSimple(session);
|
||||||
|
import os from 'node:os';
|
||||||
|
import { exec } from 'node:child_process';
|
||||||
import pool from './db/pool.js';
|
import pool from './db/pool.js';
|
||||||
import { errorHandler } from './middleware/errors.js';
|
import { errorHandler } from './middleware/errors.js';
|
||||||
|
import { requireAuth, requireUiHeader, requireAdmin } from './middleware/auth.js';
|
||||||
|
import { loadS3ConfigFromDb } from './s3/client.js';
|
||||||
|
|
||||||
// Routes
|
|
||||||
import authRouter from './routes/auth.js';
|
import authRouter from './routes/auth.js';
|
||||||
|
import tokensRouter from './routes/tokens.js';
|
||||||
|
import usersRouter from './routes/users.js';
|
||||||
|
// Routes
|
||||||
import assetsRouter from './routes/assets.js';
|
import assetsRouter from './routes/assets.js';
|
||||||
import projectsRouter from './routes/projects.js';
|
import projectsRouter from './routes/projects.js';
|
||||||
import binsRouter from './routes/bins.js';
|
import binsRouter from './routes/bins.js';
|
||||||
|
|
@ -15,45 +22,84 @@ import jobsRouter from './routes/jobs.js';
|
||||||
import captureRouter from './routes/capture.js';
|
import captureRouter from './routes/capture.js';
|
||||||
import uploadRouter from './routes/upload.js';
|
import uploadRouter from './routes/upload.js';
|
||||||
import recordersRouter from './routes/recorders.js';
|
import recordersRouter from './routes/recorders.js';
|
||||||
|
import playoutRouter from './routes/playout.js';
|
||||||
import settingsRouter from './routes/settings.js';
|
import settingsRouter from './routes/settings.js';
|
||||||
import amppRouter from './routes/ampp.js';
|
import amppRouter from './routes/ampp.js';
|
||||||
|
import groupsRouter from './routes/groups.js';
|
||||||
|
import sequencesRouter from './routes/sequences.js';
|
||||||
|
import systemRouter from './routes/system.js';
|
||||||
|
import clusterRouter from './routes/cluster.js';
|
||||||
|
import sdkRouter from './routes/sdk.js';
|
||||||
|
import schedulesRouter from './routes/schedules.js';
|
||||||
|
import metricsRouter from './routes/metrics.js';
|
||||||
|
import commentsRouter from './routes/comments.js';
|
||||||
|
import importsRouter from './routes/imports.js';
|
||||||
|
import storageRouter from './routes/storage.js';
|
||||||
|
import { startSchedulerLoop, stopSchedulerLoop } from './scheduler.js';
|
||||||
|
import { startCleanupLoop } from './tasks/cleanupTempSegments.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
// ── Middleware ────────────────────────────────────────────────────────────────
|
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
|
||||||
app.use(cors({ origin: true, credentials: true }));
|
.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
app.use(cors({
|
||||||
|
origin: (origin, cb) => {
|
||||||
|
if (!origin) return cb(null, true);
|
||||||
|
if (allowedOrigins.length === 0 || allowedOrigins.includes(origin)) return cb(null, true);
|
||||||
|
console.warn('[cors] rejected origin:', origin);
|
||||||
|
return cb(null, false);
|
||||||
|
},
|
||||||
|
credentials: true,
|
||||||
|
}));
|
||||||
app.use(express.json({ limit: '50mb' }));
|
app.use(express.json({ limit: '50mb' }));
|
||||||
|
|
||||||
const PgSession = ConnectPgSimple(session);
|
if (process.env.TRUST_PROXY === 'true') app.set('trust proxy', 1);
|
||||||
|
|
||||||
app.use(
|
if (process.env.AUTH_ENABLED === 'true') {
|
||||||
session({
|
app.use((req, res, next) => {
|
||||||
store: new PgSession({
|
if (req.secure) res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||||
pool,
|
next();
|
||||||
tableName: 'sessions',
|
});
|
||||||
// Prune expired sessions every hour
|
}
|
||||||
pruneSessionInterval: 3600,
|
|
||||||
}),
|
if (process.env.AUTH_ENABLED === 'true' && !process.env.SESSION_SECRET) {
|
||||||
secret: process.env.SESSION_SECRET || 'change-me-in-production',
|
console.error('[fatal] SESSION_SECRET is required when AUTH_ENABLED=true');
|
||||||
resave: false,
|
process.exit(1);
|
||||||
saveUninitialized: false,
|
}
|
||||||
cookie: {
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
app.use(session({
|
||||||
httpOnly: true,
|
store: new PgStore({ pool, tableName: 'sessions', pruneSessionInterval: 60 * 15 }),
|
||||||
maxAge: 1000 * 60 * 60 * 24, // 24 h
|
secret: process.env.SESSION_SECRET,
|
||||||
},
|
name: 'dragonflight.sid',
|
||||||
})
|
cookie: {
|
||||||
);
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: process.env.TRUST_PROXY === 'true',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 8 * 3600 * 1000,
|
||||||
|
},
|
||||||
|
rolling: false,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
}));
|
||||||
|
|
||||||
// ── Health (no auth) ──────────────────────────────────────────────────────────
|
|
||||||
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
||||||
|
|
||||||
// ── API Routes ────────────────────────────────────────────────────────────────
|
const UNAUTH_PATHS = new Set([
|
||||||
// Auth routes are always open (login/logout don't require a session)
|
'/auth/login', '/auth/login/totp', '/auth/setup', '/auth/setup-required',
|
||||||
app.use('/api/v1/auth', authRouter);
|
'/auth/google', '/auth/google/callback', '/auth/google/enabled',
|
||||||
|
]);
|
||||||
|
app.use('/api/v1', requireUiHeader);
|
||||||
|
app.use('/api/v1', (req, res, next) => {
|
||||||
|
if (UNAUTH_PATHS.has(req.path)) return next();
|
||||||
|
return requireAuth(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
// All other routes are gated by requireAuth (no-op unless AUTH_ENABLED=true)
|
app.use('/api/v1/auth', authRouter);
|
||||||
|
app.use('/api/v1/auth/users', requireAdmin, usersRouter);
|
||||||
|
app.use('/api/v1/users', requireAdmin, usersRouter);
|
||||||
|
app.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
|
||||||
app.use('/api/v1/assets', assetsRouter);
|
app.use('/api/v1/assets', assetsRouter);
|
||||||
app.use('/api/v1/projects', projectsRouter);
|
app.use('/api/v1/projects', projectsRouter);
|
||||||
app.use('/api/v1/bins', binsRouter);
|
app.use('/api/v1/bins', binsRouter);
|
||||||
|
|
@ -61,15 +107,188 @@ app.use('/api/v1/jobs', jobsRouter);
|
||||||
app.use('/api/v1/capture', captureRouter);
|
app.use('/api/v1/capture', captureRouter);
|
||||||
app.use('/api/v1/upload', uploadRouter);
|
app.use('/api/v1/upload', uploadRouter);
|
||||||
app.use('/api/v1/recorders', recordersRouter);
|
app.use('/api/v1/recorders', recordersRouter);
|
||||||
|
app.use('/api/v1/playout', playoutRouter);
|
||||||
app.use('/api/v1/settings', settingsRouter);
|
app.use('/api/v1/settings', settingsRouter);
|
||||||
app.use('/api/v1/ampp', amppRouter);
|
app.use('/api/v1/ampp', amppRouter);
|
||||||
|
app.use('/api/v1/groups', requireAdmin, groupsRouter);
|
||||||
|
app.use('/api/v1/sequences', sequencesRouter);
|
||||||
|
app.use('/api/v1/system', systemRouter);
|
||||||
|
app.use('/api/v1/cluster', clusterRouter);
|
||||||
|
app.use('/api/v1/sdk', sdkRouter);
|
||||||
|
app.use('/api/v1/schedules', schedulesRouter);
|
||||||
|
app.use('/api/v1/metrics', metricsRouter);
|
||||||
|
app.use('/api/v1/assets/:assetId/comments', commentsRouter);
|
||||||
|
app.use('/api/v1/imports', importsRouter);
|
||||||
|
app.use('/api/v1/storage', storageRouter);
|
||||||
|
|
||||||
// ── Error handler ─────────────────────────────────────────────────────────────
|
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
// ── Start ────────────────────────────────────────────────────────────────────
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
app.listen(PORT, () => {
|
import { fileURLToPath } from 'node:url';
|
||||||
const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const __dirnameMig = dirname(fileURLToPath(import.meta.url));
|
||||||
|
async function runMigrations() {
|
||||||
|
const dir = join(__dirnameMig, 'db', 'migrations');
|
||||||
|
let files = [];
|
||||||
|
try { files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort(); } catch { return; }
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
filename TEXT PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
checksum_sha TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const force = process.env.MIGRATIONS_FORCE === '1';
|
||||||
|
const allowFailures = process.env.MIGRATIONS_ALLOW_FAILURES === '1';
|
||||||
|
|
||||||
|
const appliedRes = await pool.query('SELECT filename FROM schema_migrations');
|
||||||
|
const applied = new Set(appliedRes.rows.map(r => r.filename));
|
||||||
|
|
||||||
|
for (const f of files) {
|
||||||
|
if (!force && applied.has(f)) continue;
|
||||||
|
const sql = readFileSync(join(dir, f), 'utf8');
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
await client.query(sql);
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO schema_migrations (filename) VALUES ($1)
|
||||||
|
ON CONFLICT (filename) DO UPDATE SET applied_at = NOW()`,
|
||||||
|
[f]
|
||||||
|
);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
console.log('[migration] applied ' + f);
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK').catch(() => {});
|
||||||
|
console.error('[migration] FAILED ' + f + ': ' + err.message);
|
||||||
|
client.release();
|
||||||
|
if (allowFailures) continue;
|
||||||
|
console.error('[migration] aborting startup. Set MIGRATIONS_ALLOW_FAILURES=1 to override.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await runMigrations();
|
||||||
|
|
||||||
|
await loadS3ConfigFromDb();
|
||||||
|
|
||||||
|
function getLocalIp() {
|
||||||
|
if (process.env.NODE_IP) return process.env.NODE_IP;
|
||||||
|
|
||||||
|
const ifaces = os.networkInterfaces();
|
||||||
|
for (const name of Object.keys(ifaces)) {
|
||||||
|
for (const iface of (ifaces[name] || [])) {
|
||||||
|
if (iface.family === 'IPv4' && !iface.internal) return iface.address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '127.0.0.1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectGpus() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
exec(
|
||||||
|
'nvidia-smi --query-gpu=index,name,memory.total --format=csv,noheader,nounits',
|
||||||
|
{ timeout: 5000 },
|
||||||
|
(err, stdout) => {
|
||||||
|
if (err || !stdout.trim()) return resolve([]);
|
||||||
|
const gpus = stdout.trim().split('\n').map(line => {
|
||||||
|
const parts = line.split(',').map(s => s.trim());
|
||||||
|
return {
|
||||||
|
index: parseInt(parts[0], 10),
|
||||||
|
name: parts[1] || 'Unknown GPU',
|
||||||
|
memory_mb: parseInt(parts[2], 10) || 0,
|
||||||
|
};
|
||||||
|
}).filter(g => !isNaN(g.index));
|
||||||
|
resolve(gpus);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primary mam-api node self-registers in cluster_nodes every 30s. Must write
|
||||||
|
// BOTH last_seen (legacy column) and last_seen_at (added by mig 031, used by
|
||||||
|
// playout failover) — otherwise the primary appears stale to the failover
|
||||||
|
// query and channels get re-placed off it incorrectly.
|
||||||
|
async function selfHeartbeat() {
|
||||||
|
const load = os.loadavg()[0];
|
||||||
|
const total = os.totalmem();
|
||||||
|
const used = total - os.freemem();
|
||||||
|
const gpus = await detectGpus();
|
||||||
|
|
||||||
|
const capabilities = { gpus, blackmagic: [] };
|
||||||
|
|
||||||
|
pool.query(
|
||||||
|
`INSERT INTO cluster_nodes
|
||||||
|
(hostname, ip_address, role, version, api_url,
|
||||||
|
cpu_usage, mem_used_mb, mem_total_mb, capabilities, last_seen, last_seen_at)
|
||||||
|
VALUES ($1,$2,'primary',$3,$4,$5,$6,$7,$8,NOW(),NOW())
|
||||||
|
ON CONFLICT (hostname) DO UPDATE SET
|
||||||
|
ip_address = EXCLUDED.ip_address,
|
||||||
|
cpu_usage = EXCLUDED.cpu_usage,
|
||||||
|
mem_used_mb = EXCLUDED.mem_used_mb,
|
||||||
|
mem_total_mb = EXCLUDED.mem_total_mb,
|
||||||
|
capabilities = EXCLUDED.capabilities,
|
||||||
|
last_seen_at = NOW(),
|
||||||
|
last_seen = NOW()`,
|
||||||
|
[
|
||||||
|
process.env.NODE_HOSTNAME || os.hostname(),
|
||||||
|
getLocalIp(),
|
||||||
|
process.env.npm_package_version || null,
|
||||||
|
`http://${getLocalIp()}:${PORT}`,
|
||||||
|
parseFloat(load.toFixed(2)),
|
||||||
|
Math.round(used / 1024 / 1024),
|
||||||
|
Math.round(total / 1024 / 1024),
|
||||||
|
JSON.stringify(capabilities),
|
||||||
|
]
|
||||||
|
).catch(err => console.error('[cluster] heartbeat failed:', err.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(selfHeartbeat, 30_000);
|
||||||
|
selfHeartbeat();
|
||||||
|
|
||||||
|
const server = app.listen(PORT, () => {
|
||||||
|
const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED — dev mode (dev user attached to every request)';
|
||||||
console.log(`MAM API listening on port ${PORT}`);
|
console.log(`MAM API listening on port ${PORT}`);
|
||||||
console.log(`Authentication: ${authMode}`);
|
console.log(`Authentication: ${authMode}`);
|
||||||
|
if (process.env.AUTH_ENABLED === 'true' && process.env.TRUST_PROXY !== 'true') {
|
||||||
|
console.warn('[auth] WARNING: AUTH_ENABLED=true but TRUST_PROXY=false — req.ip will be the proxy IP, login rate-limit will throttle all clients together. Set TRUST_PROXY=true when behind nginx/HTTPS.');
|
||||||
|
}
|
||||||
|
startSchedulerLoop();
|
||||||
|
startCleanupLoop();
|
||||||
|
});
|
||||||
|
|
||||||
|
let _shuttingDown = false;
|
||||||
|
async function gracefulShutdown(signal) {
|
||||||
|
if (_shuttingDown) return;
|
||||||
|
_shuttingDown = true;
|
||||||
|
console.log(`[shutdown] received ${signal} — closing gracefully…`);
|
||||||
|
|
||||||
|
try { stopSchedulerLoop(); } catch (_) {}
|
||||||
|
|
||||||
|
const killSwitch = setTimeout(() => {
|
||||||
|
console.error('[shutdown] forced exit after 25s timeout');
|
||||||
|
process.exit(1);
|
||||||
|
}, 25_000);
|
||||||
|
killSwitch.unref();
|
||||||
|
|
||||||
|
await new Promise(resolve => server.close(resolve));
|
||||||
|
|
||||||
|
try { await pool.end(); } catch (e) { console.warn('[shutdown] pool.end:', e.message); }
|
||||||
|
|
||||||
|
console.log('[shutdown] clean exit');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
console.error('[fatal] uncaughtException:', err);
|
||||||
|
gracefulShutdown('uncaughtException');
|
||||||
|
});
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
console.error('[fatal] unhandledRejection:', reason);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,134 @@
|
||||||
/**
|
import crypto from 'crypto';
|
||||||
* Authentication middleware.
|
import pool from '../db/pool.js';
|
||||||
*
|
import { parseBearer, hashToken } from '../auth/tokens.js';
|
||||||
* When AUTH_ENABLED=true in the environment, every protected route requires
|
|
||||||
* an active session (set by POST /api/v1/auth/login).
|
// In-process service token for the scheduler's loopback self-calls
|
||||||
*
|
// (scheduler.js -> /recorders|/playout). The scheduler runs in THIS process, so
|
||||||
* When AUTH_ENABLED is unset or any other value, the middleware is a no-op
|
// a per-boot random constant needs no env/compose config and is never exposed:
|
||||||
* so the stack can be deployed and tested without setting up users first.
|
// it only travels over the loopback fetch inside the same process. Multi-replica
|
||||||
* Set AUTH_ENABLED=true in production after running POST /api/v1/auth/setup
|
// is safe because each replica's scheduler only ever calls 127.0.0.1 (itself),
|
||||||
* to create the first admin account.
|
// matching that replica's token. Requests bearing it are treated as the seeded
|
||||||
*/
|
// admin (DEV_USER) so RBAC + FK-bearing routes work.
|
||||||
export const requireAuth = (req, res, next) => {
|
export const INTERNAL_TOKEN = crypto.randomBytes(32).toString('hex');
|
||||||
if (process.env.AUTH_ENABLED !== 'true') return next();
|
const INTERNAL_HEADER = 'x-internal-token';
|
||||||
if (!req.session || !req.session.userId) {
|
|
||||||
return res.status(401).json({ error: 'Unauthorized' });
|
function isInternalCall(req) {
|
||||||
|
const got = req.headers[INTERNAL_HEADER];
|
||||||
|
if (typeof got !== 'string' || got.length !== INTERNAL_TOKEN.length) return false;
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(got), Buffer.from(INTERNAL_TOKEN));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable UUID matching migration 023's seeded dev user.
|
||||||
|
/** UUID of the seeded dev-mode placeholder. NOT a sentinel for "any unauthenticated user". */
|
||||||
|
export const DEV_USER_ID = '00000000-0000-4000-8000-000000000000';
|
||||||
|
// role 'admin' so dev mode (AUTH_ENABLED=false) keeps full access through the
|
||||||
|
// RBAC v2 gates — matches migration 023's seeded dev row.
|
||||||
|
export const DEV_USER = { id: DEV_USER_ID, username: 'dev', display_name: 'Dev (AUTH_ENABLED=false)', role: 'admin' };
|
||||||
|
|
||||||
|
const ABSOLUTE_MS = 8 * 3600 * 1000;
|
||||||
|
const IDLE_MS = 1 * 3600 * 1000;
|
||||||
|
|
||||||
|
async function destroyAnd401(req, res) {
|
||||||
|
if (req.session?.destroy) {
|
||||||
|
await new Promise(r => req.session.destroy(() => r()));
|
||||||
}
|
}
|
||||||
next();
|
return res.status(401).json({ error: 'unauthorized' });
|
||||||
};
|
}
|
||||||
|
|
||||||
|
async function loadUser(id) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, username, display_name, role, totp_enabled FROM users WHERE id = $1`, [id]);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAuth(req, res, next) {
|
||||||
|
// Internal loopback self-call (scheduler). Acts as the seeded admin so RBAC
|
||||||
|
// and FK-bearing routes work, regardless of AUTH_ENABLED.
|
||||||
|
if (isInternalCall(req)) {
|
||||||
|
req.user = DEV_USER;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dev mode — attach the seeded dev user so FK-bearing routes work.
|
||||||
|
if (process.env.AUTH_ENABLED !== 'true') {
|
||||||
|
req.user = DEV_USER;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Session
|
||||||
|
if (req.session?.user_id) {
|
||||||
|
const now = Date.now();
|
||||||
|
const first = req.session.first_seen_at || 0;
|
||||||
|
const last = req.session.last_seen_at || 0;
|
||||||
|
if (now - first > ABSOLUTE_MS) return destroyAnd401(req, res);
|
||||||
|
if (now - last > IDLE_MS) return destroyAnd401(req, res);
|
||||||
|
const u = await loadUser(req.session.user_id);
|
||||||
|
if (!u) return destroyAnd401(req, res);
|
||||||
|
req.session.last_seen_at = now; // stamp only after user is confirmed; avoids extending idle window if loadUser throws or the user was deleted
|
||||||
|
req.user = u;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Bearer
|
||||||
|
const bearer = parseBearer(req.headers.authorization);
|
||||||
|
if (bearer) {
|
||||||
|
const hash = hashToken(bearer);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT t.id AS token_id, t.user_id, t.expires_at, t.bound_hostname,
|
||||||
|
u.username, u.display_name, u.role
|
||||||
|
FROM api_tokens t JOIN users u ON u.id = t.user_id
|
||||||
|
WHERE t.token_hash = $1`, [hash]);
|
||||||
|
if (rows.length && (!rows[0].expires_at || rows[0].expires_at > new Date())) {
|
||||||
|
pool.query(`UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1`, [rows[0].token_id])
|
||||||
|
.catch(err => console.error('[auth] token last_used_at update failed:', err.message));
|
||||||
|
req.user = {
|
||||||
|
id: rows[0].user_id,
|
||||||
|
username: rows[0].username,
|
||||||
|
display_name: rows[0].display_name,
|
||||||
|
role: rows[0].role,
|
||||||
|
};
|
||||||
|
// Per migration 019: tokens with a bound_hostname can only be used by
|
||||||
|
// node-agents reporting that hostname. The /cluster/heartbeat handler
|
||||||
|
// enforces this; we just surface the binding here.
|
||||||
|
if (rows[0].bound_hostname) req.tokenBoundHostname = rows[0].bound_hostname;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Nothing matched
|
||||||
|
return res.status(401).json({ error: 'unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate a route to admins only. requireAuth must run first (it sets req.user).
|
||||||
|
// 401 when unauthenticated, 403 when authenticated but not an admin.
|
||||||
|
export function requireAdmin(req, res, next) {
|
||||||
|
if (!req.user) return res.status(401).json({ error: 'unauthorized' });
|
||||||
|
if (req.user.role !== 'admin') return res.status(403).json({ error: 'forbidden' });
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Belt-and-suspenders CSRF: SameSite=Lax already blocks most cross-site
|
||||||
|
// cookie sends, but a custom header that no <form> can produce hardens
|
||||||
|
// against the edge cases. Applied to mutating verbs only.
|
||||||
|
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||||
|
const REQUIRED_HEADER = 'dragonflight-ui';
|
||||||
|
|
||||||
|
// Paths exempt from the CSRF header check. The bearer-auth exemption (above)
|
||||||
|
// already covers node-agent because it sends Authorization: Bearer; this set
|
||||||
|
// is the belt for any future service path that might call us without a
|
||||||
|
// bearer header. Today it just lets an unauthenticated heartbeat probe
|
||||||
|
// surface as a clean 401 from requireAuth instead of a confusing 403 CSRF.
|
||||||
|
const CSRF_EXEMPT_PATHS = new Set(['/cluster/heartbeat']);
|
||||||
|
|
||||||
|
export function requireUiHeader(req, res, next) {
|
||||||
|
if (!MUTATING.has(req.method)) return next();
|
||||||
|
// Internal loopback self-call (scheduler) — not a browser, can't be drive-by'd.
|
||||||
|
if (isInternalCall(req)) return next();
|
||||||
|
// Bearer-authed requests (Premiere panel, scripts) are exempt — they're not
|
||||||
|
// browsers and can't be drive-by'd from another origin.
|
||||||
|
if (req.headers.authorization?.toLowerCase().startsWith('bearer ')) return next();
|
||||||
|
// Service path carve-outs (e.g. node-agent heartbeat — not a browser).
|
||||||
|
if (CSRF_EXEMPT_PATHS.has(req.path)) return next();
|
||||||
|
if (req.headers['x-requested-with'] === REQUIRED_HEADER) return next();
|
||||||
|
return res.status(403).json({ error: 'missing X-Requested-With header' });
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,73 @@
|
||||||
|
// Error & validation middleware.
|
||||||
|
//
|
||||||
|
// Issue #101 — the previous handler echoed every error's `.message` straight
|
||||||
|
// to the client, leaking raw Postgres column names, schema details, and
|
||||||
|
// invalid UUID syntax errors to anyone hitting a malformed route.
|
||||||
|
//
|
||||||
|
// Issue #102 — every /:id route was hitting Postgres with the raw param,
|
||||||
|
// returning a 500 (with a PG error in the body) instead of a clean 400.
|
||||||
|
//
|
||||||
|
// Both are addressed here: `validateUuid` checks param shape before the
|
||||||
|
// route runs; `errorHandler` keeps detailed messages server-side and only
|
||||||
|
// surfaces a generic message + the response status to the client.
|
||||||
|
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
export function validateUuid(paramName = 'id') {
|
||||||
|
return (req, res, next) => {
|
||||||
|
const v = req.params[paramName];
|
||||||
|
if (!v || !UUID_RE.test(v)) {
|
||||||
|
return res.status(400).json({ error: `Invalid ${paramName} — must be a UUID` });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patterns Postgres uses for its error codes that are operator-only noise.
|
||||||
|
const PG_LEAKY_CODES = new Set([
|
||||||
|
'22P02', // invalid_text_representation (bad UUID, etc.)
|
||||||
|
'23502', // not_null_violation
|
||||||
|
'23503', // foreign_key_violation
|
||||||
|
'23505', // unique_violation
|
||||||
|
'42703', // undefined_column
|
||||||
|
'42P01', // undefined_table
|
||||||
|
'42601', // syntax_error
|
||||||
|
]);
|
||||||
|
|
||||||
|
const GENERIC_MESSAGES = {
|
||||||
|
'22P02': 'Invalid input format',
|
||||||
|
'23502': 'Required field missing',
|
||||||
|
'23503': 'Referenced record not found',
|
||||||
|
'23505': 'Record already exists',
|
||||||
|
'42703': 'Internal database error',
|
||||||
|
'42P01': 'Internal database error',
|
||||||
|
'42601': 'Internal database error',
|
||||||
|
};
|
||||||
|
|
||||||
export const errorHandler = (err, req, res, next) => {
|
export const errorHandler = (err, req, res, next) => {
|
||||||
console.error('Error:', err);
|
// Log the full error server-side; operators get the detail.
|
||||||
|
console.error('[error]', req.method, req.originalUrl, err);
|
||||||
|
|
||||||
|
// Postgres errors carry a `.code` (string from SQLSTATE).
|
||||||
|
if (err && err.code && PG_LEAKY_CODES.has(err.code)) {
|
||||||
|
const generic = GENERIC_MESSAGES[err.code] || 'Database error';
|
||||||
|
const status = err.code === '22P02' || err.code === '23502' ? 400 : 409;
|
||||||
|
return res.status(status).json({ error: generic, code: err.code });
|
||||||
|
}
|
||||||
|
|
||||||
const status = err.status || 500;
|
const status = err.status || 500;
|
||||||
const message = err.message || 'Internal Server Error';
|
|
||||||
|
|
||||||
res.status(status).json({
|
// 5xx — never let a raw Error.message escape; clients get a stable shape.
|
||||||
error: message,
|
if (status >= 500) {
|
||||||
|
return res.status(status).json({
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4xx — operator-authored messages are safe to surface.
|
||||||
|
return res.status(status).json({
|
||||||
|
error: err.message || 'Bad request',
|
||||||
status,
|
status,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,136 +1,461 @@
|
||||||
/**
|
|
||||||
* Authentication routes
|
|
||||||
*
|
|
||||||
* POST /api/v1/auth/login — exchange username+password for a session cookie
|
|
||||||
* POST /api/v1/auth/logout — destroy the current session
|
|
||||||
* GET /api/v1/auth/me — return the currently authenticated user
|
|
||||||
* POST /api/v1/auth/setup — one-time admin bootstrap (disabled after first user exists)
|
|
||||||
*/
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import bcrypt from 'bcrypt';
|
|
||||||
import pool from '../db/pool.js';
|
import pool from '../db/pool.js';
|
||||||
|
import { DEV_USER_ID, requireAuth } from '../middleware/auth.js';
|
||||||
|
import { hashPassword, comparePassword } from '../auth/passwords.js';
|
||||||
|
import { ipBackoff } from '../auth/rate-limit.js';
|
||||||
|
import {
|
||||||
|
generateSecret, verifyToken, otpauthURI, generateRecoveryCodes,
|
||||||
|
} from '../auth/totp.js';
|
||||||
|
import { issueTicket, redeemTicket } from '../auth/mfa-tickets.js';
|
||||||
|
import {
|
||||||
|
isConfigured as googleConfigured, buildAuthUrl, exchangeAndVerify,
|
||||||
|
} from '../auth/google-oauth.js';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
const DUMMY_PASSWORD_HASH = '$2b$12$gSeC58PregWedNFK/8Q61OephUo.JJ7EUs0LCTdnJV5AzCS5qQH7K';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// Real users = anyone except the seeded dev row.
|
||||||
// POST /login
|
async function realUserCount() {
|
||||||
// ---------------------------------------------------------------------------
|
const { rows } = await pool.query(
|
||||||
router.post('/login', async (req, res, next) => {
|
`SELECT COUNT(*)::int AS n FROM users WHERE id <> $1`, [DEV_USER_ID]);
|
||||||
|
return rows[0].n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/auth/setup-required
|
||||||
|
// Cheap, no auth. Used by AuthGate to decide between Login and Setup screens.
|
||||||
|
router.get('/setup-required', async (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { username, password } = req.body;
|
res.json({ required: (await realUserCount()) === 0 });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
if (!username || !password) {
|
|
||||||
return res.status(400).json({ error: 'Username and password are required' });
|
const MIN_PASSWORD_LEN = 12;
|
||||||
|
|
||||||
|
function badRequest(res, msg) { return res.status(400).json({ error: msg }); }
|
||||||
|
|
||||||
|
// POST /api/v1/auth/setup — first-run admin creation, locked out forever once a real user exists.
|
||||||
|
router.post('/setup', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { username, password } = req.body || {};
|
||||||
|
if (!username || typeof username !== 'string') return badRequest(res, 'username required');
|
||||||
|
if (!password || typeof password !== 'string') return badRequest(res, 'password required');
|
||||||
|
if (password.length < MIN_PASSWORD_LEN) return badRequest(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' characters');
|
||||||
|
|
||||||
|
if ((await realUserCount()) > 0) {
|
||||||
|
return res.status(409).json({ error: 'setup already complete' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await pool.query(
|
const hash = await hashPassword(password);
|
||||||
'SELECT * FROM users WHERE username = $1',
|
const { rows } = await pool.query(
|
||||||
[username.trim().toLowerCase()]
|
`INSERT INTO users (username, password_hash, display_name, role)
|
||||||
|
VALUES ($1, $2, $1, 'admin')
|
||||||
|
RETURNING id, username, display_name`,
|
||||||
|
[username.trim(), hash]
|
||||||
);
|
);
|
||||||
|
const user = rows[0];
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
// Immediately log them in.
|
||||||
// Timing-safe: still run compare on a dummy hash so response time is constant
|
req.session.user_id = user.id;
|
||||||
await bcrypt.compare(password, '$2b$12$invalidhashpadding000000000000000000000000000000000000');
|
req.session.first_seen_at = Date.now();
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
req.session.last_seen_at = Date.now();
|
||||||
}
|
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
|
||||||
|
|
||||||
const user = result.rows[0];
|
res.json({ user });
|
||||||
const valid = await bcrypt.compare(password, user.password_hash);
|
|
||||||
|
|
||||||
if (!valid) {
|
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regenerate session ID to prevent fixation attacks
|
|
||||||
req.session.regenerate((err) => {
|
|
||||||
if (err) return next(err);
|
|
||||||
req.session.userId = user.id;
|
|
||||||
req.session.username = user.username;
|
|
||||||
req.session.role = user.role;
|
|
||||||
res.json({
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
display_name: user.display_name,
|
|
||||||
role: user.role,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err.code === '23505') return res.status(409).json({ error: 'username already exists' });
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// POST /api/v1/auth/login — authenticate an existing user by username + password.
|
||||||
// POST /logout
|
router.post('/login', async (req, res, next) => {
|
||||||
// ---------------------------------------------------------------------------
|
try {
|
||||||
router.post('/logout', (req, res, next) => {
|
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||||
req.session.destroy((err) => {
|
const delay = ipBackoff.delayMs(ip);
|
||||||
if (err) return next(err);
|
if (delay > 0) await new Promise(r => setTimeout(r, delay));
|
||||||
res.clearCookie('connect.sid');
|
|
||||||
res.json({ message: 'Logged out' });
|
const { username, password } = req.body || {};
|
||||||
|
if (!username || !password) {
|
||||||
|
ipBackoff.recordFailure(ip);
|
||||||
|
return res.status(401).json({ error: 'invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, username, display_name, password_hash, totp_enabled FROM users WHERE username = $1 AND id <> $2`,
|
||||||
|
[username.trim(), DEV_USER_ID]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
// Pre-computed bcrypt hash of a value that no real password input will match.
|
||||||
|
// Used to keep the user-not-found response time uniform with the wrong-password
|
||||||
|
// path (~180ms at cost 12) so user enumeration via timing isn't possible.
|
||||||
|
await comparePassword(password, DUMMY_PASSWORD_HASH);
|
||||||
|
ipBackoff.recordFailure(ip);
|
||||||
|
return res.status(401).json({ error: 'invalid credentials' });
|
||||||
|
}
|
||||||
|
const user = rows[0];
|
||||||
|
if (!(await comparePassword(password, user.password_hash))) {
|
||||||
|
ipBackoff.recordFailure(ip);
|
||||||
|
return res.status(401).json({ error: 'invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second factor: if TOTP is enabled, don't create a session yet. Hand back
|
||||||
|
// a short-lived ticket the client redeems via /login/totp with a code.
|
||||||
|
// Crucially: do NOT clear the per-IP failure counter here. If we did, each
|
||||||
|
// /login retry would reset the backoff and let an attacker brute the 6-digit
|
||||||
|
// TOTP space (10^6) with no per-attempt delay. The counter is cleared
|
||||||
|
// inside establishSession() once MFA has actually passed.
|
||||||
|
if (user.totp_enabled) {
|
||||||
|
return res.json({
|
||||||
|
mfa_required: true,
|
||||||
|
ticket: issueTicket(user.id, { ip, userAgent: req.get('user-agent') }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await establishSession(req, user, ip);
|
||||||
|
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write the session and wait for it to persist before responding. Extracted so
|
||||||
|
// both the password-only and the MFA-completion paths share one implementation.
|
||||||
|
// Clears the per-IP failure counter only here — after every required factor has
|
||||||
|
// actually been proven (password [+ TOTP if enabled, or OAuth + TOTP]).
|
||||||
|
async function establishSession(req, user, ip) {
|
||||||
|
req.session.user_id = user.id;
|
||||||
|
req.session.first_seen_at = Date.now();
|
||||||
|
req.session.last_seen_at = Date.now();
|
||||||
|
// The critical line — wait for the row to land in `sessions` before responding.
|
||||||
|
// Without this, the SPA's next request races the store write, hits 401, and
|
||||||
|
// the prior bounce-to-login logic produced an infinite loop.
|
||||||
|
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
|
||||||
|
if (ip) ipBackoff.recordSuccess(ip);
|
||||||
|
pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id])
|
||||||
|
.catch(err => console.error('[auth] last_login_at update failed:', err.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/auth/login/totp { ticket?, code } — second login step. `code` is
|
||||||
|
// either a 6-digit TOTP or a one-time recovery code. The ticket comes from the
|
||||||
|
// request body (password-login path) or req.session.mfa_ticket (Google path).
|
||||||
|
router.post('/login/totp', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||||
|
// Rate-limit the second factor with the same per-IP backoff as /login so
|
||||||
|
// the 6-digit code space can't be hammered.
|
||||||
|
const delay = ipBackoff.delayMs(ip);
|
||||||
|
if (delay > 0) await new Promise(r => setTimeout(r, delay));
|
||||||
|
|
||||||
|
const { ticket: bodyTicket, code } = req.body || {};
|
||||||
|
const ticket = bodyTicket || req.session?.mfa_ticket;
|
||||||
|
if (req.session?.mfa_ticket) delete req.session.mfa_ticket;
|
||||||
|
// Bound to the issuing request's IP + UA — replays from a different origin
|
||||||
|
// redeem to null. See mfa-tickets.js for the binding model.
|
||||||
|
const userId = redeemTicket(ticket, { ip, userAgent: req.get('user-agent') });
|
||||||
|
if (!userId) {
|
||||||
|
ipBackoff.recordFailure(ip);
|
||||||
|
return res.status(401).json({ error: 'invalid or expired ticket' });
|
||||||
|
}
|
||||||
|
if (!code) return res.status(400).json({ error: 'code required' });
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, username, display_name, totp_secret, totp_enabled, totp_last_counter
|
||||||
|
FROM users WHERE id = $1`, [userId]);
|
||||||
|
const user = rows[0];
|
||||||
|
if (!user || !user.totp_enabled || !user.totp_secret) {
|
||||||
|
return res.status(401).json({ error: 'invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyToken returns the matched counter on success. Reject codes at
|
||||||
|
// counters ≤ totp_last_counter to prevent replay within the same step.
|
||||||
|
// The CAS-style UPDATE makes this race-free under concurrent submissions.
|
||||||
|
const matchedCounter = verifyToken(user.totp_secret, code);
|
||||||
|
let ok = false;
|
||||||
|
if (matchedCounter !== null) {
|
||||||
|
const lastCounter = BigInt(user.totp_last_counter || 0);
|
||||||
|
if (BigInt(matchedCounter) > lastCounter) {
|
||||||
|
const upd = await pool.query(
|
||||||
|
`UPDATE users SET totp_last_counter = $1
|
||||||
|
WHERE id = $2 AND totp_last_counter < $1`,
|
||||||
|
[String(matchedCounter), user.id]
|
||||||
|
);
|
||||||
|
ok = upd.rowCount === 1;
|
||||||
|
}
|
||||||
|
// matchedCounter ≤ last → silent replay; falls through to recovery-code
|
||||||
|
// path which also fails → 401. Same UX as a wrong code, no info leak.
|
||||||
|
}
|
||||||
|
if (!ok) ok = await consumeRecoveryCode(user.id, code);
|
||||||
|
if (!ok) {
|
||||||
|
ipBackoff.recordFailure(ip);
|
||||||
|
// The ticket was single-use; the client must restart from /login.
|
||||||
|
return res.status(401).json({ error: 'invalid code' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordSuccess is called by establishSession once the session lands —
|
||||||
|
// that's the first moment we know every required factor has passed.
|
||||||
|
await establishSession(req, user, ip);
|
||||||
|
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check a recovery code against the user's unused codes; mark it spent on match.
|
||||||
|
// The marking is atomic (UPDATE ... WHERE used_at IS NULL with a rowCount check)
|
||||||
|
// so two concurrent redemptions of the same code can't both succeed.
|
||||||
|
async function consumeRecoveryCode(userId, code) {
|
||||||
|
const cleaned = String(code).trim().toLowerCase();
|
||||||
|
if (!/^[0-9a-f]{5}-[0-9a-f]{5}$/.test(cleaned)) return false;
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, code_hash FROM user_recovery_codes WHERE user_id = $1 AND used_at IS NULL`, [userId]);
|
||||||
|
for (const row of rows) {
|
||||||
|
if (await comparePassword(cleaned, row.code_hash)) {
|
||||||
|
const upd = await pool.query(
|
||||||
|
`UPDATE user_recovery_codes SET used_at = NOW() WHERE id = $1 AND used_at IS NULL`, [row.id]);
|
||||||
|
// Lost the race if another request already consumed it.
|
||||||
|
return upd.rowCount === 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/auth/logout — destroys the session and clears the cookie.
|
||||||
|
router.post('/logout', (req, res) => {
|
||||||
|
if (!req.session) return res.status(204).end();
|
||||||
|
req.session.destroy(err => {
|
||||||
|
if (err) console.error('[auth] session destroy failed:', err.message);
|
||||||
|
res.clearCookie('dragonflight.sid', { path: '/' });
|
||||||
|
res.status(204).end();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// GET /api/v1/auth/me
|
||||||
// GET /me
|
router.get('/me', requireAuth, (req, res) => {
|
||||||
// ---------------------------------------------------------------------------
|
res.json({
|
||||||
router.get('/me', async (req, res) => {
|
id: req.user.id,
|
||||||
if (!req.session || !req.session.userId) {
|
username: req.user.username,
|
||||||
return res.status(401).json({ error: 'Not authenticated' });
|
display_name: req.user.display_name,
|
||||||
}
|
role: req.user.role,
|
||||||
try {
|
totp_enabled: !!req.user.totp_enabled,
|
||||||
const result = await pool.query(
|
});
|
||||||
'SELECT id, username, display_name, role FROM users WHERE id = $1',
|
|
||||||
[req.session.userId]
|
|
||||||
);
|
|
||||||
if (result.rows.length === 0) {
|
|
||||||
req.session.destroy(() => {});
|
|
||||||
return res.status(401).json({ error: 'User not found' });
|
|
||||||
}
|
|
||||||
res.json(result.rows[0]);
|
|
||||||
} catch (err) {
|
|
||||||
// Fallback to session data if DB unreachable
|
|
||||||
res.json({
|
|
||||||
id: req.session.userId,
|
|
||||||
username: req.session.username,
|
|
||||||
role: req.session.role,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// POST /api/v1/auth/password { current_password, new_password }
|
||||||
// POST /setup — one-time first-admin bootstrap
|
router.post('/password', requireAuth, async (req, res, next) => {
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
router.post('/setup', async (req, res, next) => {
|
|
||||||
try {
|
try {
|
||||||
const { username, password, display_name } = req.body;
|
const { current_password, new_password } = req.body || {};
|
||||||
|
if (!current_password || !new_password) return badRequest(res, 'current_password and new_password required');
|
||||||
|
if (new_password.length < MIN_PASSWORD_LEN) return badRequest(res, 'new password must be at least ' + MIN_PASSWORD_LEN + ' characters');
|
||||||
|
|
||||||
if (!username || !password) {
|
const { rows } = await pool.query(`SELECT password_hash FROM users WHERE id = $1`, [req.user.id]);
|
||||||
return res.status(400).json({ error: 'Username and password are required' });
|
if (!rows.length) return res.status(401).json({ error: 'unauthorized' });
|
||||||
|
if (!(await comparePassword(current_password, rows[0].password_hash))) {
|
||||||
|
return badRequest(res, 'current password is incorrect');
|
||||||
}
|
}
|
||||||
if (password.length < 8) {
|
const newHash = await hashPassword(new_password);
|
||||||
return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
await pool.query(
|
||||||
}
|
`UPDATE users SET password_hash = $1, password_updated_at = NOW() WHERE id = $2`,
|
||||||
|
[newHash, req.user.id]
|
||||||
// Block if any user already exists
|
|
||||||
const count = await pool.query('SELECT COUNT(*) FROM users');
|
|
||||||
if (parseInt(count.rows[0].count, 10) > 0) {
|
|
||||||
return res.status(403).json({
|
|
||||||
error: 'Setup is already complete. Use an existing admin account to add more users.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const hash = await bcrypt.hash(password, 12);
|
|
||||||
const result = await pool.query(
|
|
||||||
`INSERT INTO users (username, password_hash, display_name, role)
|
|
||||||
VALUES ($1, $2, $3, 'admin')
|
|
||||||
RETURNING id, username, display_name, role`,
|
|
||||||
[username.trim().toLowerCase(), hash, display_name || username]
|
|
||||||
);
|
);
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
res.status(201).json(result.rows[0]);
|
// ── TOTP enrollment (all require an active session) ─────────────────────────
|
||||||
|
|
||||||
|
// POST /api/v1/auth/totp/setup — begin enrollment. Generates a fresh secret,
|
||||||
|
// stores it (but leaves totp_enabled=false), and returns the otpauth URI + the
|
||||||
|
// base32 secret for manual entry. Enrollment isn't active until /enable
|
||||||
|
// confirms a code, so a started-but-abandoned setup never locks the user out.
|
||||||
|
router.post('/totp/setup', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(`SELECT totp_enabled FROM users WHERE id = $1`, [req.user.id]);
|
||||||
|
if (rows[0]?.totp_enabled) return res.status(409).json({ error: 'TOTP already enabled' });
|
||||||
|
|
||||||
|
const secret = generateSecret();
|
||||||
|
await pool.query(`UPDATE users SET totp_secret = $1 WHERE id = $2`, [secret, req.user.id]);
|
||||||
|
const uri = otpauthURI(secret, req.user.username || 'user');
|
||||||
|
|
||||||
|
// QR rendering is optional — the otpauth URI + manual secret are sufficient
|
||||||
|
// to enroll. Render a data-URL QR only if the optional `qrcode` dep is
|
||||||
|
// present, so a missing dependency degrades instead of 500-ing.
|
||||||
|
let qr = null;
|
||||||
|
try {
|
||||||
|
const QRCode = (await import('qrcode')).default;
|
||||||
|
qr = await QRCode.toDataURL(uri);
|
||||||
|
} catch { /* qrcode not installed — client falls back to manual entry */ }
|
||||||
|
|
||||||
|
res.json({ secret, otpauth_uri: uri, qr });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/auth/totp/enable { code } — confirm enrollment with a code from
|
||||||
|
// the authenticator. On success, flips totp_enabled and returns one-time
|
||||||
|
// recovery codes (shown exactly once).
|
||||||
|
router.post('/totp/enable', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { code } = req.body || {};
|
||||||
|
if (!code) return badRequest(res, 'code required');
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT totp_secret, totp_enabled FROM users WHERE id = $1`, [req.user.id]);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row?.totp_secret) return badRequest(res, 'start setup first');
|
||||||
|
if (row.totp_enabled) return res.status(409).json({ error: 'TOTP already enabled' });
|
||||||
|
const enrollCounter = verifyToken(row.totp_secret, code);
|
||||||
|
if (enrollCounter === null) return badRequest(res, 'incorrect code');
|
||||||
|
|
||||||
|
const recovery = generateRecoveryCodes(10);
|
||||||
|
const hashes = await Promise.all(recovery.map(c => hashPassword(c)));
|
||||||
|
// Enable + seed totp_last_counter to the enrollment code's counter so the
|
||||||
|
// same code can't be reused on first login. Replace any stale recovery
|
||||||
|
// codes atomically.
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
await client.query(
|
||||||
|
`UPDATE users SET totp_enabled = TRUE, totp_last_counter = $2 WHERE id = $1`,
|
||||||
|
[req.user.id, String(enrollCounter)]
|
||||||
|
);
|
||||||
|
await client.query(`DELETE FROM user_recovery_codes WHERE user_id = $1`, [req.user.id]);
|
||||||
|
for (const h of hashes) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO user_recovery_codes (user_id, code_hash) VALUES ($1, $2)`, [req.user.id, h]);
|
||||||
|
}
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK').catch(() => {});
|
||||||
|
throw e;
|
||||||
|
} finally { client.release(); }
|
||||||
|
|
||||||
|
res.json({ enabled: true, recovery_codes: recovery });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/auth/totp/disable { password } — turn off 2FA. Requires the
|
||||||
|
// account password as a confirmation so a hijacked live session can't silently
|
||||||
|
// strip the second factor.
|
||||||
|
router.post('/totp/disable', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { password } = req.body || {};
|
||||||
|
if (!password) return badRequest(res, 'password required');
|
||||||
|
const { rows } = await pool.query(`SELECT password_hash FROM users WHERE id = $1`, [req.user.id]);
|
||||||
|
if (!rows.length) return res.status(401).json({ error: 'unauthorized' });
|
||||||
|
if (!(await comparePassword(password, rows[0].password_hash))) {
|
||||||
|
return badRequest(res, 'incorrect password');
|
||||||
|
}
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE users SET totp_enabled = FALSE, totp_secret = NULL, totp_last_counter = 0 WHERE id = $1`,
|
||||||
|
[req.user.id]
|
||||||
|
);
|
||||||
|
await pool.query(`DELETE FROM user_recovery_codes WHERE user_id = $1`, [req.user.id]);
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Google OAuth (OIDC) sign-in ─────────────────────────────────────────────
|
||||||
|
// All Google routes are config-gated: without GOOGLE_CLIENT_ID/SECRET and
|
||||||
|
// OAUTH_REDIRECT_URL they 404, so a deployment without SSO is unaffected.
|
||||||
|
|
||||||
|
// GET /api/v1/auth/google/enabled — cheap, no auth. Lets the login screen decide
|
||||||
|
// whether to render the "Sign in with Google" button.
|
||||||
|
router.get('/google/enabled', (_req, res) => {
|
||||||
|
res.json({ enabled: googleConfigured() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/auth/google — kick off the OAuth dance. Stores an anti-CSRF state
|
||||||
|
// in the session and redirects to Google's consent screen.
|
||||||
|
router.get('/google', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!googleConfigured()) return res.status(404).json({ error: 'google sign-in not configured' });
|
||||||
|
const state = randomBytes(16).toString('hex');
|
||||||
|
req.session.oauth_state = state;
|
||||||
|
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
|
||||||
|
res.redirect(await buildAuthUrl(state));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/auth/google/callback — Google redirects back here with ?code&state.
|
||||||
|
// Verifies the ID token, enforces the allowed domain, auto-provisions a viewer
|
||||||
|
// on first login, establishes the session, then redirects to the SPA.
|
||||||
|
router.get('/google/callback', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!googleConfigured()) return res.status(404).json({ error: 'google sign-in not configured' });
|
||||||
|
const { code, state } = req.query;
|
||||||
|
const expected = req.session.oauth_state;
|
||||||
|
delete req.session.oauth_state;
|
||||||
|
if (!code || !state || !expected || state !== expected) {
|
||||||
|
return res.status(400).json({ error: 'invalid oauth state' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await exchangeAndVerify(code);
|
||||||
|
const user = await resolveGoogleUser(profile);
|
||||||
|
|
||||||
|
// If this account has TOTP enabled, Google is only the FIRST factor — route
|
||||||
|
// through the same second-factor step as password login. The ticket lives in
|
||||||
|
// the session (not the URL) and the SPA prompts for the code.
|
||||||
|
if (user.totp_enabled) {
|
||||||
|
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||||
|
req.session.mfa_ticket = issueTicket(user.id, {
|
||||||
|
ip,
|
||||||
|
userAgent: req.get('user-agent'),
|
||||||
|
});
|
||||||
|
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
|
||||||
|
return res.redirect('/?mfa=1');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||||
|
await establishSession(req, user, ip);
|
||||||
|
|
||||||
|
// Redirect to the SPA root; AuthGate will re-check /auth/me and render the app.
|
||||||
|
res.redirect('/');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Surface a friendly message on the login screen rather than a raw 500.
|
||||||
|
if (err.status === 403) return res.redirect('/?auth_error=domain');
|
||||||
|
if (err.status === 401) return res.redirect('/?auth_error=google');
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Map a verified Google profile to a Dragonflight user row.
|
||||||
|
//
|
||||||
|
// Resolution order:
|
||||||
|
// 1. Existing link by google_sub → that user.
|
||||||
|
// 2. Otherwise auto-provision a fresh 'viewer'.
|
||||||
|
//
|
||||||
|
// We deliberately do NOT auto-link to an existing account by matching email:
|
||||||
|
// that would let anyone who controls a Google address with the same email sign
|
||||||
|
// in as a pre-existing local (possibly admin) account, bypassing its password
|
||||||
|
// and TOTP. Linking an existing account to Google is an explicit, authenticated
|
||||||
|
// action (a future "connect Google" under Settings), not something a login does.
|
||||||
|
async function resolveGoogleUser(profile) {
|
||||||
|
const found = await pool.query(
|
||||||
|
`SELECT id, username, display_name, totp_enabled FROM users WHERE google_sub = $1`, [profile.sub]);
|
||||||
|
if (found.rows.length) return found.rows[0];
|
||||||
|
|
||||||
|
const base = (profile.email.split('@')[0] || 'user').replace(/[^a-z0-9._-]/gi, '').toLowerCase() || 'user';
|
||||||
|
let username = base, n = 1;
|
||||||
|
while ((await pool.query(`SELECT 1 FROM users WHERE username = $1`, [username])).rows.length) {
|
||||||
|
username = base + (++n);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ins = await pool.query(
|
||||||
|
`INSERT INTO users (username, password_hash, display_name, role, email, google_sub)
|
||||||
|
VALUES ($1, NULL, $2, 'viewer', $3, $4)
|
||||||
|
RETURNING id, username, display_name, totp_enabled`,
|
||||||
|
[username, profile.name, profile.email, profile.sub]);
|
||||||
|
return ins.rows[0];
|
||||||
|
} catch (err) {
|
||||||
|
// Concurrent first-login race: the unique google_sub index rejected our
|
||||||
|
// INSERT because a sibling request just created the row. Re-resolve.
|
||||||
|
if (err.code === '23505') {
|
||||||
|
const retry = await pool.query(
|
||||||
|
`SELECT id, username, display_name, totp_enabled FROM users WHERE google_sub = $1`, [profile.sub]);
|
||||||
|
if (retry.rows.length) return retry.rows[0];
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
export { realUserCount, resolveGoogleUser, consumeRecoveryCode };
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,76 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import pool from '../db/pool.js';
|
import pool from '../db/pool.js';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { validateUuid } from '../middleware/errors.js';
|
||||||
|
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.use(requireAuth);
|
// Resolve the owning project for a /:id bin, assert 'view' baseline, stash the
|
||||||
|
// project_id for mutating routes to escalate to 'edit'.
|
||||||
|
router.param('id', async (req, res, next) => {
|
||||||
|
validateUuid('id')(req, res, () => {});
|
||||||
|
if (res.headersSent) return;
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query('SELECT project_id FROM bins WHERE id = $1', [req.params.id]);
|
||||||
|
if (rows.length === 0) return res.status(404).json({ error: 'Bin not found' });
|
||||||
|
req.binProjectId = rows[0].project_id;
|
||||||
|
await assertProjectAccess(req.user, req.binProjectId, 'view');
|
||||||
|
next();
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
// GET / - List bins for a project_id
|
async function requireBinEdit(req, res, next) {
|
||||||
|
try {
|
||||||
|
await assertProjectAccess(req.user, req.binProjectId, 'edit');
|
||||||
|
next();
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET / - List bins. When project_id is supplied, scope to it (after an access
|
||||||
|
// check); otherwise return bins across every project the caller can access.
|
||||||
router.get('/', async (req, res, next) => {
|
router.get('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { project_id } = req.query;
|
const { project_id } = req.query;
|
||||||
|
|
||||||
if (!project_id) {
|
if (project_id) {
|
||||||
return res.status(400).json({ error: 'project_id is required' });
|
await assertProjectAccess(req.user, project_id, 'view');
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT b.*, p.name AS project_name,
|
||||||
|
(SELECT COUNT(*)::int FROM assets a WHERE a.bin_id = b.id) AS asset_count
|
||||||
|
FROM bins b
|
||||||
|
LEFT JOIN projects p ON p.id = b.project_id
|
||||||
|
WHERE b.project_id = $1
|
||||||
|
ORDER BY b.created_at DESC`,
|
||||||
|
[project_id]
|
||||||
|
);
|
||||||
|
return res.json(result.rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const access = await accessibleProjectIds(req.user);
|
||||||
|
let where = '';
|
||||||
|
const params = [];
|
||||||
|
if (!access.all) {
|
||||||
|
if (access.ids.size === 0) return res.json([]);
|
||||||
|
where = 'WHERE b.project_id = ANY($1::uuid[])';
|
||||||
|
params.push([...access.ids]);
|
||||||
|
}
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT * FROM bins WHERE project_id = $1 ORDER BY created_at DESC`,
|
`SELECT b.*, p.name AS project_name,
|
||||||
[project_id]
|
(SELECT COUNT(*)::int FROM assets a WHERE a.bin_id = b.id) AS asset_count
|
||||||
|
FROM bins b
|
||||||
|
LEFT JOIN projects p ON p.id = b.project_id
|
||||||
|
${where}
|
||||||
|
ORDER BY b.created_at DESC`,
|
||||||
|
params
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json(result.rows);
|
res.json(result.rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST / - Create bin
|
// POST / - Create bin (requires edit on the target project).
|
||||||
router.post('/', async (req, res, next) => {
|
router.post('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { project_id, name, parent_id } = req.body;
|
const { project_id, name, parent_id } = req.body;
|
||||||
|
|
@ -35,6 +78,7 @@ router.post('/', async (req, res, next) => {
|
||||||
if (!project_id || !name) {
|
if (!project_id || !name) {
|
||||||
return res.status(400).json({ error: 'project_id and name are required' });
|
return res.status(400).json({ error: 'project_id and name are required' });
|
||||||
}
|
}
|
||||||
|
await assertProjectAccess(req.user, project_id, 'edit');
|
||||||
|
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
|
|
||||||
|
|
@ -52,7 +96,7 @@ router.post('/', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /:id - Update bin
|
// PATCH /:id - Update bin
|
||||||
router.patch('/:id', async (req, res, next) => {
|
router.patch('/:id', requireBinEdit, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { name, parent_id } = req.body;
|
const { name, parent_id } = req.body;
|
||||||
|
|
@ -98,7 +142,7 @@ router.patch('/:id', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /:id - Delete bin
|
// DELETE /:id - Delete bin
|
||||||
router.delete('/:id', async (req, res, next) => {
|
router.delete('/:id', requireBinEdit, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
|
|
@ -117,8 +161,8 @@ router.delete('/:id', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/assets - Add asset to bin
|
// POST /:id/assets - Add asset to bin (requires edit on the bin's project).
|
||||||
router.post('/:id/assets', async (req, res, next) => {
|
router.post('/:id/assets', requireBinEdit, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { asset_id } = req.body;
|
const { asset_id } = req.body;
|
||||||
|
|
@ -127,10 +171,13 @@ router.post('/:id/assets', async (req, res, next) => {
|
||||||
return res.status(400).json({ error: 'asset_id is required' });
|
return res.status(400).json({ error: 'asset_id is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify bin exists
|
// Asset must live in the bin's own project. Without this, an editor in
|
||||||
const binCheck = await pool.query('SELECT id FROM bins WHERE id = $1', [id]);
|
// project A (where the bin lives) could pull an asset from project B (no
|
||||||
if (binCheck.rows.length === 0) {
|
// grant) into A's bin tree, exposing it in A's views.
|
||||||
return res.status(404).json({ error: 'Bin not found' });
|
const a = await pool.query('SELECT project_id FROM assets WHERE id = $1', [asset_id]);
|
||||||
|
if (a.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||||
|
if (a.rows[0].project_id !== req.binProjectId) {
|
||||||
|
return res.status(400).json({ error: 'asset belongs to a different project than the bin' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update asset's bin_id
|
// Update asset's bin_id
|
||||||
|
|
@ -149,8 +196,8 @@ router.post('/:id/assets', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /:id/assets/:assetId - Remove asset from bin
|
// DELETE /:id/assets/:assetId - Remove asset from bin (requires edit).
|
||||||
router.delete('/:id/assets/:assetId', async (req, res, next) => {
|
router.delete('/:id/assets/:assetId', requireBinEdit, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id, assetId } = req.params;
|
const { id, assetId } = req.params;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,73 +1,65 @@
|
||||||
|
// authz: intentionally any-logged-in (no per-project scoping). This is a thin
|
||||||
|
// proxy to shared capture hardware with no project_id of its own; the resulting
|
||||||
|
// asset is scoped when it's registered via the /assets route. Gated by the
|
||||||
|
// global requireAuth in index.js, like the rest of /api/v1.
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.use(requireAuth);
|
|
||||||
|
|
||||||
const CAPTURE_URL = process.env.CAPTURE_URL || 'http://capture:3001';
|
const CAPTURE_URL = process.env.CAPTURE_URL || 'http://capture:3001';
|
||||||
|
|
||||||
// Helper to proxy requests
|
async function proxyRequest(method, path, body = null) {
|
||||||
const proxyRequest = async (method, path, body = null) => {
|
|
||||||
const options = {
|
const options = {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
signal: AbortSignal.timeout(8000),
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
if (body) options.body = JSON.stringify(body);
|
||||||
|
|
||||||
if (body) {
|
const response = await fetch(`${CAPTURE_URL}${path}`, options);
|
||||||
options.body = JSON.stringify(body);
|
const text = await response.text();
|
||||||
}
|
|
||||||
|
|
||||||
|
let data;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${CAPTURE_URL}${path}`, options);
|
data = JSON.parse(text);
|
||||||
const data = await response.json();
|
} catch {
|
||||||
return { status: response.status, data };
|
// Capture service returned non-JSON (HTML error page, plain text, etc.)
|
||||||
} catch (err) {
|
data = { message: text.slice(0, 300) || '(empty response)' };
|
||||||
console.error('Capture service error:', err);
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// POST /start - Forward start request
|
return { status: response.status, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /start
|
||||||
router.post('/start', async (req, res, next) => {
|
router.post('/start', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { status, data } = await proxyRequest('POST', '/start', req.body);
|
const { status, data } = await proxyRequest('POST', '/start', req.body);
|
||||||
res.status(status).json(data);
|
res.status(status).json(data);
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /stop - Forward stop request
|
// POST /stop
|
||||||
router.post('/stop', async (req, res, next) => {
|
router.post('/stop', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { status, data } = await proxyRequest('POST', '/stop', req.body);
|
const { status, data } = await proxyRequest('POST', '/stop', req.body);
|
||||||
res.status(status).json(data);
|
res.status(status).json(data);
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /status - Forward status request
|
// GET /status
|
||||||
router.get('/status', async (req, res, next) => {
|
router.get('/status', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { status, data } = await proxyRequest('GET', '/status');
|
const { status, data } = await proxyRequest('GET', '/status');
|
||||||
res.status(status).json(data);
|
res.status(status).json(data);
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /devices - Forward devices request
|
// GET /devices
|
||||||
router.get('/devices', async (req, res, next) => {
|
router.get('/devices', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { status, data } = await proxyRequest('GET', '/devices');
|
const { status, data } = await proxyRequest('GET', '/devices');
|
||||||
res.status(status).json(data);
|
res.status(status).json(data);
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
378
services/mam-api/src/routes/cluster.js
Normal file
378
services/mam-api/src/routes/cluster.js
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
import express from 'express';
|
||||||
|
import http from 'http';
|
||||||
|
import pool from '../db/pool.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function pickIp(reportedIp, reqIp) {
|
||||||
|
const clean = (s) => (s || '').replace(/^::ffff:/, '');
|
||||||
|
const isDockerBridge = (ip) => /^172\.17\./.test(ip || '');
|
||||||
|
const r = clean(reqIp);
|
||||||
|
if (!reportedIp) return r || null;
|
||||||
|
if (isDockerBridge(reportedIp) && r && !isDockerBridge(r)) return r;
|
||||||
|
return reportedIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dockerRequest(path, method = 'GET', body = null) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const opts = {
|
||||||
|
socketPath: '/var/run/docker.sock',
|
||||||
|
path: `/v1.41${path}`,
|
||||||
|
method,
|
||||||
|
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
|
||||||
|
};
|
||||||
|
const req = http.request(opts, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', d => { data += d; });
|
||||||
|
res.on('end', () => {
|
||||||
|
if (!data.trim()) return resolve(null);
|
||||||
|
try { resolve(JSON.parse(data)); }
|
||||||
|
catch (e) { resolve(null); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.setTimeout(5000, () => { req.destroy(); reject(new Error('Docker socket timeout')); });
|
||||||
|
if (body) req.write(JSON.stringify(body));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const r = await pool.query(
|
||||||
|
`SELECT *,
|
||||||
|
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
||||||
|
FROM cluster_nodes
|
||||||
|
ORDER BY registered_at ASC`
|
||||||
|
);
|
||||||
|
res.json(r.rows.map(row => ({
|
||||||
|
...row,
|
||||||
|
online: Number(row.stale_seconds) < 120,
|
||||||
|
})));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/containers', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const containers = await dockerRequest('/containers/json?all=true');
|
||||||
|
if (!Array.isArray(containers)) return res.json([]);
|
||||||
|
const out = containers.map(c => {
|
||||||
|
const rawName = (c.Names[0] || '').replace(/^\//, '');
|
||||||
|
const name = rawName.replace(/^wild-dragon-/, '').replace(/-\d+$/, '');
|
||||||
|
const ports = (c.Ports || [])
|
||||||
|
.filter(p => p.PublicPort)
|
||||||
|
.map(p => `${p.PublicPort}→${p.PrivatePort}`)
|
||||||
|
.join(', ');
|
||||||
|
return {
|
||||||
|
id: c.Id.slice(0, 12),
|
||||||
|
name,
|
||||||
|
image: (c.Image || '').replace(/^sha256:/, '').slice(0, 40),
|
||||||
|
state: c.State,
|
||||||
|
uptime: (c.Status || '').replace(/\s*\(.*\)/, '').trim(),
|
||||||
|
healthy: (c.Status || '').includes('healthy'),
|
||||||
|
ports,
|
||||||
|
cpu: 0,
|
||||||
|
mem: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
res.json(out);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT' || err.code === 'EACCES') return res.json([]);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/containers/:nameOrId/restart', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await dockerRequest(`/containers/${encodeURIComponent(req.params.nameOrId)}/restart`, 'POST');
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/heartbeat', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
hostname, ip_address,
|
||||||
|
role = 'worker', version, api_url,
|
||||||
|
cpu_usage, mem_used_mb, mem_total_mb,
|
||||||
|
capabilities, metadata, metrics,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!hostname) return res.status(400).json({ error: 'hostname is required' });
|
||||||
|
|
||||||
|
if (process.env.AUTH_ENABLED === 'true') {
|
||||||
|
const bound = req.tokenBoundHostname;
|
||||||
|
if (bound && bound !== hostname) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: `Token is bound to "${bound}" but heartbeat reported "${hostname}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!bound && req.user?.role !== 'admin') {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Heartbeat requires a node-bound token or admin session',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveIp = pickIp(ip_address, req.ip || req.socket?.remoteAddress);
|
||||||
|
|
||||||
|
const r = await pool.query(
|
||||||
|
`INSERT INTO cluster_nodes
|
||||||
|
(hostname, ip_address, role, version, api_url,
|
||||||
|
cpu_usage, mem_used_mb, mem_total_mb, last_seen, last_seen_at, capabilities, metadata, metrics)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW(),NOW(),$9,$10,$11)
|
||||||
|
ON CONFLICT (hostname) DO UPDATE SET
|
||||||
|
ip_address = EXCLUDED.ip_address,
|
||||||
|
role = EXCLUDED.role,
|
||||||
|
version = EXCLUDED.version,
|
||||||
|
api_url = EXCLUDED.api_url,
|
||||||
|
cpu_usage = EXCLUDED.cpu_usage,
|
||||||
|
mem_used_mb = EXCLUDED.mem_used_mb,
|
||||||
|
mem_total_mb = EXCLUDED.mem_total_mb,
|
||||||
|
last_seen = NOW(),
|
||||||
|
last_seen_at = NOW(),
|
||||||
|
capabilities = EXCLUDED.capabilities,
|
||||||
|
metadata = EXCLUDED.metadata,
|
||||||
|
metrics = COALESCE(EXCLUDED.metrics, cluster_nodes.metrics)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
hostname,
|
||||||
|
effectiveIp,
|
||||||
|
role,
|
||||||
|
version || null,
|
||||||
|
api_url || null,
|
||||||
|
cpu_usage != null ? cpu_usage : null,
|
||||||
|
mem_used_mb != null ? mem_used_mb : null,
|
||||||
|
mem_total_mb != null ? mem_total_mb : null,
|
||||||
|
capabilities != null ? JSON.stringify(capabilities) : '{}',
|
||||||
|
metadata != null ? JSON.stringify(metadata) : null,
|
||||||
|
metrics != null ? JSON.stringify(metrics) : null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
res.json(r.rows[0]);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/devices/blackmagic/signal', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const nodesResult = await pool.query(
|
||||||
|
`SELECT id, hostname, ip_address, api_url, capabilities,
|
||||||
|
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
||||||
|
FROM cluster_nodes
|
||||||
|
WHERE capabilities IS NOT NULL`
|
||||||
|
);
|
||||||
|
const recResult = await pool.query(
|
||||||
|
`SELECT id, name, status, container_id, node_id, device_index,
|
||||||
|
source_config
|
||||||
|
FROM recorders
|
||||||
|
WHERE source_type = 'sdi' AND node_id IS NOT NULL`
|
||||||
|
);
|
||||||
|
const recByPort = new Map();
|
||||||
|
for (const r of recResult.rows) {
|
||||||
|
const devIdx = r.device_index ?? r.source_config?.device ?? 0;
|
||||||
|
recByPort.set(`${r.node_id}:${devIdx}`, r);
|
||||||
|
}
|
||||||
|
const tasks = [];
|
||||||
|
for (const node of nodesResult.rows) {
|
||||||
|
const nodeOnline = Number(node.stale_seconds) < 120;
|
||||||
|
const bm = (node.capabilities && node.capabilities.blackmagic) || [];
|
||||||
|
const model = (node.capabilities && node.capabilities.blackmagic_model) || null;
|
||||||
|
const localHostname = process.env.NODE_HOSTNAME || '';
|
||||||
|
const isRemote = node.api_url && node.hostname !== localHostname;
|
||||||
|
bm.forEach((d, idx) => {
|
||||||
|
const portIndex = d.index !== undefined ? d.index : idx;
|
||||||
|
const rec = recByPort.get(`${node.id}:${portIndex}`);
|
||||||
|
tasks.push((async () => {
|
||||||
|
const base = {
|
||||||
|
node_id: node.id, hostname: node.hostname, index: portIndex,
|
||||||
|
device: d.device || null, model, node_online: nodeOnline,
|
||||||
|
recorder_id: rec ? rec.id : null, recorder_name: rec ? rec.name : null,
|
||||||
|
recorder_status: rec ? rec.status : null,
|
||||||
|
signal: 'no-recorder', framesReceived: null, currentFps: null,
|
||||||
|
};
|
||||||
|
if (!rec || rec.status !== 'recording' || !rec.container_id) {
|
||||||
|
if (rec && rec.status !== 'recording') base.signal = 'idle';
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let live = null;
|
||||||
|
if (isRemote) {
|
||||||
|
const r = await fetch(`${node.api_url}/sidecar/${rec.container_id}/status`, { signal: AbortSignal.timeout(2500) });
|
||||||
|
if (r.ok) live = (await r.json()).live;
|
||||||
|
} else {
|
||||||
|
const r = await fetch(`http://recorder-${rec.id}:3001/capture/status`, { signal: AbortSignal.timeout(2000) });
|
||||||
|
if (r.ok) live = await r.json();
|
||||||
|
}
|
||||||
|
if (live && live.signal) {
|
||||||
|
base.signal = live.signal;
|
||||||
|
base.framesReceived = live.framesReceived ?? null;
|
||||||
|
base.currentFps = live.currentFps ?? null;
|
||||||
|
} else { base.signal = 'connecting'; }
|
||||||
|
} catch (_) { base.signal = 'connecting'; }
|
||||||
|
return base;
|
||||||
|
})());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const results = await Promise.all(tasks);
|
||||||
|
res.json(results);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/devices/blackmagic', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const r = await pool.query(
|
||||||
|
`SELECT id, hostname, ip_address, role, capabilities,
|
||||||
|
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
||||||
|
FROM cluster_nodes WHERE capabilities IS NOT NULL`
|
||||||
|
);
|
||||||
|
const out = [];
|
||||||
|
for (const row of r.rows) {
|
||||||
|
const online = Number(row.stale_seconds) < 120;
|
||||||
|
const bm = (row.capabilities && row.capabilities.blackmagic) || [];
|
||||||
|
const model = (row.capabilities && row.capabilities.blackmagic_model) || null;
|
||||||
|
bm.forEach((d, idx) => {
|
||||||
|
out.push({ node_id: row.id, hostname: row.hostname, ip_address: row.ip_address,
|
||||||
|
role: row.role, online, model, index: d.index !== undefined ? d.index : idx, device: d.device });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json(out);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/devices/deltacast', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const r = await pool.query(
|
||||||
|
`SELECT id, hostname, ip_address, role, capabilities,
|
||||||
|
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
||||||
|
FROM cluster_nodes WHERE capabilities IS NOT NULL`
|
||||||
|
);
|
||||||
|
const out = [];
|
||||||
|
for (const row of r.rows) {
|
||||||
|
const online = Number(row.stale_seconds) < 120;
|
||||||
|
const dc = (row.capabilities && row.capabilities.deltacast) || [];
|
||||||
|
const model = (row.capabilities && row.capabilities.deltacast_model) || null;
|
||||||
|
dc.forEach((d, idx) => {
|
||||||
|
out.push({ node_id: row.id, hostname: row.hostname, ip_address: row.ip_address,
|
||||||
|
role: row.role, online, model: model || 'Deltacast',
|
||||||
|
index: d.index !== undefined ? d.index : idx, device: d.device,
|
||||||
|
present: d.present !== false, port_count: dc.length });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json(out);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/devices/deltacast/signal', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [nodesRes, recordersRes] = await Promise.all([
|
||||||
|
pool.query(`SELECT id, hostname, ip_address, api_url, capabilities,
|
||||||
|
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
||||||
|
FROM cluster_nodes WHERE capabilities IS NOT NULL`),
|
||||||
|
pool.query(`SELECT id, node_id, device_index, status, source_type, container_id
|
||||||
|
FROM recorders WHERE source_type = 'deltacast'`),
|
||||||
|
]);
|
||||||
|
const recByNodePort = {};
|
||||||
|
for (const rec of recordersRes.rows) {
|
||||||
|
recByNodePort[`${rec.node_id}:${rec.device_index}`] = rec;
|
||||||
|
}
|
||||||
|
const results = [];
|
||||||
|
const fetchPromises = [];
|
||||||
|
for (const node of nodesRes.rows) {
|
||||||
|
const online = Number(node.stale_seconds) < 120;
|
||||||
|
const dc = (node.capabilities && node.capabilities.deltacast) || [];
|
||||||
|
const model = (node.capabilities && node.capabilities.deltacast_model) || 'Deltacast';
|
||||||
|
for (const port of dc) {
|
||||||
|
const idx = port.index !== undefined ? port.index : dc.indexOf(port);
|
||||||
|
const rec = recByNodePort[`${node.id}:${idx}`];
|
||||||
|
const base = { node_id: node.id, hostname: node.hostname, ip_address: node.ip_address,
|
||||||
|
online, model, index: idx, device: port.device, present: port.present !== false,
|
||||||
|
recorder_id: rec ? rec.id : null, recorder_status: rec ? rec.status : null,
|
||||||
|
signal: 'no-recorder', framesReceived: null, currentFps: null };
|
||||||
|
if (!rec) { results.push(base); continue; }
|
||||||
|
if (rec.status !== 'recording') { base.signal = 'idle'; results.push(base); continue; }
|
||||||
|
const fetchIdx = results.length;
|
||||||
|
results.push(base);
|
||||||
|
fetchPromises.push((async () => {
|
||||||
|
try {
|
||||||
|
const url = node.api_url ? `${node.api_url}/sidecar/${rec.container_id}/status`
|
||||||
|
: `http://recorder-${rec.id}:3001/capture/status`;
|
||||||
|
const r = await fetch(url, { signal: AbortSignal.timeout(2500) });
|
||||||
|
if (r.ok) {
|
||||||
|
const live = await r.json();
|
||||||
|
if (live && live.signal) {
|
||||||
|
results[fetchIdx].signal = live.signal;
|
||||||
|
results[fetchIdx].framesReceived = live.framesReceived ?? null;
|
||||||
|
results[fetchIdx].currentFps = live.currentFps ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) { results[fetchIdx].signal = 'connecting'; }
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(fetchPromises);
|
||||||
|
res.json(results);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id/ping', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const r = await pool.query('SELECT id, hostname, api_url FROM cluster_nodes WHERE id = $1', [req.params.id]);
|
||||||
|
if (r.rowCount === 0) return res.status(404).json({ error: 'Node not found' });
|
||||||
|
const node = r.rows[0];
|
||||||
|
if (!node.api_url) return res.json({ reachable: false, reason: 'no api_url registered' });
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(`${node.api_url}/health`, { signal: AbortSignal.timeout(4000) });
|
||||||
|
const latency_ms = Date.now() - start;
|
||||||
|
const body = await upstream.json().catch(() => ({}));
|
||||||
|
res.json({ reachable: upstream.ok, latency_ms, status: upstream.status, agent: body });
|
||||||
|
} catch (err) {
|
||||||
|
res.json({ reachable: false, latency_ms: Date.now() - start, reason: err.message });
|
||||||
|
}
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/metrics', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const r = await pool.query(
|
||||||
|
`SELECT id, hostname, role, last_seen,
|
||||||
|
cpu_usage, mem_used_mb, mem_total_mb,
|
||||||
|
capabilities, metrics,
|
||||||
|
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
||||||
|
FROM cluster_nodes ORDER BY registered_at ASC`
|
||||||
|
);
|
||||||
|
const nodes = r.rows.map(row => {
|
||||||
|
const capGpus = (row.capabilities && row.capabilities.gpus) || [];
|
||||||
|
const liveGpus = (row.metrics && row.metrics.gpus) || [];
|
||||||
|
const gpus = capGpus.map((g, idx) => {
|
||||||
|
const live = liveGpus.find(l => l.index === g.index) || liveGpus[idx] || {};
|
||||||
|
return { name: g.name || null, util_pct: live.util_pct != null ? live.util_pct : null,
|
||||||
|
memory_used_mb: live.memory_used_mb != null ? live.memory_used_mb : null,
|
||||||
|
memory_total_mb: g.memory_mb != null ? g.memory_mb : (live.memory_total_mb ?? null) };
|
||||||
|
});
|
||||||
|
for (const lg of liveGpus) {
|
||||||
|
if (!capGpus.some(g => g.index === lg.index)) {
|
||||||
|
gpus.push({ name: lg.name || null, util_pct: lg.util_pct != null ? lg.util_pct : null,
|
||||||
|
memory_used_mb: lg.memory_used_mb != null ? lg.memory_used_mb : null,
|
||||||
|
memory_total_mb: lg.memory_total_mb != null ? lg.memory_total_mb : null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { id: row.id, hostname: row.hostname, role: row.role,
|
||||||
|
online: Number(row.stale_seconds) < 120, last_seen: row.last_seen,
|
||||||
|
cpu_util_pct: row.cpu_usage != null ? Number(row.cpu_usage) : null,
|
||||||
|
ram_used_mb: row.mem_used_mb != null ? row.mem_used_mb : null,
|
||||||
|
ram_total_mb: row.mem_total_mb != null ? row.mem_total_mb : null, gpus };
|
||||||
|
});
|
||||||
|
res.json({ nodes });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const r = await pool.query('DELETE FROM cluster_nodes WHERE id = $1 RETURNING id', [req.params.id]);
|
||||||
|
if (r.rowCount === 0) return res.status(404).json({ error: 'Node not found' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
128
services/mam-api/src/routes/comments.js
Normal file
128
services/mam-api/src/routes/comments.js
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
// Asset-scoped comments for the Asset Detail page.
|
||||||
|
//
|
||||||
|
// Mounted at /api/v1/assets/:assetId/comments via app.use('/api/v1/assets/:assetId/comments', router).
|
||||||
|
// Express's :assetId param flows through from the parent mount.
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import pool from '../db/pool.js';
|
||||||
|
import { assertProjectAccess } from '../auth/authz.js';
|
||||||
|
|
||||||
|
const router = express.Router({ mergeParams: true });
|
||||||
|
|
||||||
|
// Scope every comment route to the parent asset's project: resolve project_id
|
||||||
|
// via the asset, then require 'view' to read and 'edit' to write. A non-UUID or
|
||||||
|
// unknown asset is a clean 404 before any access decision leaks its existence.
|
||||||
|
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||||
|
router.use(async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query('SELECT project_id FROM assets WHERE id = $1', [req.params.assetId]);
|
||||||
|
if (rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||||
|
await assertProjectAccess(req.user, rows[0].project_id, MUTATING.has(req.method) ? 'edit' : 'view');
|
||||||
|
next();
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
function rowToJson(r) {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
asset_id: r.asset_id,
|
||||||
|
user_id: r.user_id,
|
||||||
|
body: r.body,
|
||||||
|
frame_ms: r.frame_ms,
|
||||||
|
resolved: r.resolved,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
author_name: r.author_name || null,
|
||||||
|
author_initials: r.author_initials || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/assets/:assetId/comments
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { assetId } = req.params;
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT c.*,
|
||||||
|
u.display_name AS author_name,
|
||||||
|
UPPER(SUBSTRING(COALESCE(u.display_name, u.username, '?'), 1, 2)) AS author_initials
|
||||||
|
FROM asset_comments c
|
||||||
|
LEFT JOIN users u ON u.id = c.user_id
|
||||||
|
WHERE c.asset_id = $1
|
||||||
|
ORDER BY c.created_at ASC`,
|
||||||
|
[assetId]
|
||||||
|
);
|
||||||
|
res.json({ comments: result.rows.map(rowToJson) });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/assets/:assetId/comments
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { assetId } = req.params;
|
||||||
|
const { body, frame_ms } = req.body || {};
|
||||||
|
if (!body || !String(body).trim()) {
|
||||||
|
return res.status(400).json({ error: 'body is required' });
|
||||||
|
}
|
||||||
|
// Author is the authenticated user (requireAuth sets req.user for both
|
||||||
|
// session and bearer auth, and the dev user when AUTH_ENABLED=false).
|
||||||
|
const userId = req.user?.id || null;
|
||||||
|
|
||||||
|
const ins = await pool.query(
|
||||||
|
`INSERT INTO asset_comments (asset_id, user_id, body, frame_ms)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING *`,
|
||||||
|
[assetId, userId, String(body).trim(), frame_ms != null ? Math.max(0, Math.round(Number(frame_ms))) : null]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-fetch with the author join so the response has the same shape as list.
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT c.*,
|
||||||
|
u.display_name AS author_name,
|
||||||
|
UPPER(SUBSTRING(COALESCE(u.display_name, u.username, '?'), 1, 2)) AS author_initials
|
||||||
|
FROM asset_comments c
|
||||||
|
LEFT JOIN users u ON u.id = c.user_id
|
||||||
|
WHERE c.id = $1`,
|
||||||
|
[ins.rows[0].id]
|
||||||
|
);
|
||||||
|
res.status(201).json(rowToJson(result.rows[0]));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/v1/assets/:assetId/comments/:id
|
||||||
|
router.patch('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id, assetId } = req.params;
|
||||||
|
const { body, resolved } = req.body || {};
|
||||||
|
const fields = [];
|
||||||
|
const values = [];
|
||||||
|
let i = 1;
|
||||||
|
if (body !== undefined) { fields.push(`body = $${i++}`); values.push(String(body).trim()); }
|
||||||
|
if (resolved !== undefined) { fields.push(`resolved = $${i++}`); values.push(!!resolved); }
|
||||||
|
if (fields.length === 0) return res.status(400).json({ error: 'Nothing to update' });
|
||||||
|
fields.push('updated_at = NOW()');
|
||||||
|
values.push(id, assetId);
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE asset_comments SET ${fields.join(', ')}
|
||||||
|
WHERE id = $${i++} AND asset_id = $${i}
|
||||||
|
RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Comment not found' });
|
||||||
|
res.json(rowToJson(result.rows[0]));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/v1/assets/:assetId/comments/:id
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id, assetId } = req.params;
|
||||||
|
const result = await pool.query(
|
||||||
|
`DELETE FROM asset_comments WHERE id = $1 AND asset_id = $2 RETURNING id`,
|
||||||
|
[id, assetId]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Comment not found' });
|
||||||
|
res.json({ id });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
112
services/mam-api/src/routes/groups.js
Normal file
112
services/mam-api/src/routes/groups.js
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
/**
|
||||||
|
* Group management routes (admin-only when AUTH_ENABLED=true)
|
||||||
|
*
|
||||||
|
* GET /api/v1/groups — list all groups
|
||||||
|
* POST /api/v1/groups — create group
|
||||||
|
* PATCH /api/v1/groups/:id — update group
|
||||||
|
* DELETE /api/v1/groups/:id — delete group
|
||||||
|
* GET /api/v1/groups/:id/members — list members
|
||||||
|
* POST /api/v1/groups/:id/members — add member { user_id }
|
||||||
|
* DELETE /api/v1/groups/:id/members/:uid — remove member
|
||||||
|
*/
|
||||||
|
import express from 'express';
|
||||||
|
import pool from '../db/pool.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ── List ──────────────────────────────────────────────────────
|
||||||
|
router.get('/', async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT g.id, g.name, g.description, g.created_at,
|
||||||
|
COUNT(ug.user_id)::int AS member_count
|
||||||
|
FROM groups g
|
||||||
|
LEFT JOIN user_groups ug ON ug.group_id = g.id
|
||||||
|
GROUP BY g.id
|
||||||
|
ORDER BY g.name`
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Create ────────────────────────────────────────────────────
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, description } = req.body;
|
||||||
|
if (!name) return res.status(400).json({ error: 'name required' });
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO groups (name, description) VALUES ($1, $2) RETURNING *`,
|
||||||
|
[name.trim(), description || null]
|
||||||
|
);
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === '23505') return res.status(409).json({ error: 'Group name already exists' });
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Update ────────────────────────────────────────────────────
|
||||||
|
router.patch('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, description } = req.body;
|
||||||
|
const sets = []; const vals = [];
|
||||||
|
if (name !== undefined) { sets.push(`name = $${sets.length + 1}`); vals.push(name); }
|
||||||
|
if (description !== undefined) { sets.push(`description = $${sets.length + 1}`); vals.push(description); }
|
||||||
|
if (!sets.length) return res.status(400).json({ error: 'Nothing to update' });
|
||||||
|
vals.push(req.params.id);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`UPDATE groups SET ${sets.join(', ')} WHERE id = $${vals.length} RETURNING *`,
|
||||||
|
vals
|
||||||
|
);
|
||||||
|
if (!rows.length) return res.status(404).json({ error: 'Group not found' });
|
||||||
|
res.json(rows[0]);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Delete ────────────────────────────────────────────────────
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { rowCount } = await pool.query('DELETE FROM groups WHERE id = $1', [req.params.id]);
|
||||||
|
if (!rowCount) return res.status(404).json({ error: 'Group not found' });
|
||||||
|
res.json({ message: 'Group deleted' });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Members ───────────────────────────────────────────────────
|
||||||
|
router.get('/:id/members', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT u.id, u.username, u.display_name, u.role
|
||||||
|
FROM user_groups ug
|
||||||
|
JOIN users u ON u.id = ug.user_id
|
||||||
|
WHERE ug.group_id = $1
|
||||||
|
ORDER BY u.username`,
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/members', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { user_id } = req.body;
|
||||||
|
if (!user_id) return res.status(400).json({ error: 'user_id required' });
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||||
|
[user_id, req.params.id]
|
||||||
|
);
|
||||||
|
res.status(201).json({ message: 'Member added' });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id/members/:uid', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
`DELETE FROM user_groups WHERE group_id = $1 AND user_id = $2`,
|
||||||
|
[req.params.id, req.params.uid]
|
||||||
|
);
|
||||||
|
res.json({ message: 'Member removed' });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
97
services/mam-api/src/routes/imports.js
Normal file
97
services/mam-api/src/routes/imports.js
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
// External media imports — currently YouTube only.
|
||||||
|
//
|
||||||
|
// The flow mirrors upload.js: create the asset row up front with a placeholder
|
||||||
|
// filename (the worker fills in the real title once yt-dlp prints metadata),
|
||||||
|
// then enqueue a BullMQ job. The worker downloads, lands the file in S3 at the
|
||||||
|
// same originals/{assetId}/... path uploads use, and hands off to the existing
|
||||||
|
// proxy queue — so an imported asset travels the same lifecycle as any upload.
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import pool from '../db/pool.js';
|
||||||
|
import { assertProjectAccess } from '../auth/authz.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const parseRedisUrl = (url) => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return { host: parsed.hostname, port: parseInt(parsed.port, 10) || 6379 };
|
||||||
|
} catch {
|
||||||
|
return { host: 'localhost', port: 6379 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const importQueue = new Queue('import', {
|
||||||
|
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Match the same three forms the client UI validates against. Server is the
|
||||||
|
// authoritative check — never trust the client to have validated.
|
||||||
|
const YT_PATTERNS = [
|
||||||
|
/^https?:\/\/(?:www\.|m\.)?youtube\.com\/watch\?[^ ]*v=[A-Za-z0-9_-]{11}/i,
|
||||||
|
/^https?:\/\/youtu\.be\/[A-Za-z0-9_-]{11}/i,
|
||||||
|
/^https?:\/\/(?:www\.)?youtube\.com\/shorts\/[A-Za-z0-9_-]{11}/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
function isYouTubeUrl(url) {
|
||||||
|
return typeof url === 'string' && YT_PATTERNS.some((re) => re.test(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/imports/youtube — body { url, projectId, binId? }
|
||||||
|
router.post('/youtube', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { url, projectId, binId } = req.body || {};
|
||||||
|
|
||||||
|
if (!url || !projectId) {
|
||||||
|
return res.status(400).json({ error: 'url and projectId are required' });
|
||||||
|
}
|
||||||
|
if (!isYouTubeUrl(url)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid YouTube URL' });
|
||||||
|
}
|
||||||
|
// A playlist URL has `list=…` — yt-dlp's --no-playlist would still grab
|
||||||
|
// the single video, but the operator probably meant "import the list" and
|
||||||
|
// we don't support that yet. Reject so the intent is explicit.
|
||||||
|
if (/[?&]list=/i.test(url)) {
|
||||||
|
return res.status(400).json({ error: "Playlists aren't supported yet" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const projCheck = await pool.query('SELECT id FROM projects WHERE id = $1', [projectId]);
|
||||||
|
if (projCheck.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Project not found' });
|
||||||
|
}
|
||||||
|
// Importing writes an asset into the project — require edit access.
|
||||||
|
await assertProjectAccess(req.user, projectId, 'edit');
|
||||||
|
|
||||||
|
const assetId = uuidv4();
|
||||||
|
|
||||||
|
// Placeholder filename/display_name — the worker overwrites both once
|
||||||
|
// yt-dlp resolves the video title (usually within a second or two).
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO assets (
|
||||||
|
id, project_id, bin_id, filename, display_name, status,
|
||||||
|
media_type, original_s3_key, source_url, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $4, 'ingesting', 'video', NULL, $5, NOW(), NOW())`,
|
||||||
|
[assetId, projectId, binId || null, url, url]
|
||||||
|
);
|
||||||
|
|
||||||
|
const bullJob = await importQueue.add('youtube', {
|
||||||
|
assetId,
|
||||||
|
url,
|
||||||
|
// Surface the URL in the Jobs screen until the worker fills in the title.
|
||||||
|
assetName: url,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(202).json({
|
||||||
|
assetId,
|
||||||
|
jobId: `import:${bullJob.id}`,
|
||||||
|
status: 'queued',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import pool from '../db/pool.js';
|
import pool from '../db/pool.js';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { assertProjectAccess } from '../auth/authz.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(requireAuth);
|
// Note: jobs use BullMQ id format "<queueType>:<bullId>" (e.g. "conform:42"),
|
||||||
|
// NOT UUIDs. The GET/:id, POST/:id/retry, and DELETE/:id handlers below split
|
||||||
|
// on the colon themselves and look up the queue. Adding a UUID validator
|
||||||
|
// here would 400 every BullMQ poll the panel makes (which is exactly what
|
||||||
|
// caused Export Timeline to stall "Rendering Hi-Res" forever — fixed 2026-05-28).
|
||||||
|
|
||||||
// ── Redis connection ──────────────────────────────────────────────────────────
|
// ── Redis connection ──────────────────────────────────────────────────────────
|
||||||
const parseRedisUrl = (url) => {
|
const parseRedisUrl = (url) => {
|
||||||
|
|
@ -19,26 +22,35 @@ const parseRedisUrl = (url) => {
|
||||||
|
|
||||||
const redisConn = parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379');
|
const redisConn = parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379');
|
||||||
|
|
||||||
const proxyQueue = new Queue('proxy', { connection: redisConn });
|
const proxyQueue = new Queue('proxy', { connection: redisConn });
|
||||||
const thumbnailQueue = new Queue('thumbnail', { connection: redisConn });
|
const thumbnailQueue = new Queue('thumbnail', { connection: redisConn });
|
||||||
const conformQueue = new Queue('conform', { connection: redisConn });
|
const filmstripQueue = new Queue('filmstrip', { connection: redisConn });
|
||||||
|
const conformQueue = new Queue('conform', { connection: redisConn });
|
||||||
|
const importQueue = new Queue('import', { connection: redisConn });
|
||||||
|
const trimQueue = new Queue('trim', { connection: redisConn });
|
||||||
|
|
||||||
const QUEUES = [
|
const QUEUES = [
|
||||||
{ queue: proxyQueue, type: 'proxy' },
|
{ queue: proxyQueue, type: 'proxy' },
|
||||||
{ queue: thumbnailQueue, type: 'thumbnail' },
|
{ queue: thumbnailQueue, type: 'thumbnail' },
|
||||||
{ queue: conformQueue, type: 'conform' },
|
{ queue: filmstripQueue, type: 'filmstrip' },
|
||||||
|
{ queue: conformQueue, type: 'conform' },
|
||||||
|
{ queue: importQueue, type: 'import' },
|
||||||
|
{ queue: trimQueue, type: 'trim' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// BullMQ state → API status mapping
|
// BullMQ state → API status mapping
|
||||||
const STATE_MAP = {
|
const STATE_MAP = {
|
||||||
waiting: 'waiting',
|
waiting: 'waiting',
|
||||||
active: 'active',
|
active: 'active',
|
||||||
completed:'completed',
|
completed: 'completed',
|
||||||
failed: 'failed',
|
failed: 'failed',
|
||||||
delayed: 'waiting',
|
delayed: 'waiting',
|
||||||
paused: 'waiting',
|
paused: 'waiting',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Ordered state buckets used for bulk fetch — avoids N+1 getState() calls.
|
||||||
|
const STATE_BUCKETS = ['active', 'waiting', 'completed', 'failed', 'delayed', 'paused'];
|
||||||
|
|
||||||
function normalizeJob(bullJob, type, apiStatus) {
|
function normalizeJob(bullJob, type, apiStatus) {
|
||||||
const isCompleted = apiStatus === 'completed';
|
const isCompleted = apiStatus === 'completed';
|
||||||
const isFailed = apiStatus === 'failed';
|
const isFailed = apiStatus === 'failed';
|
||||||
|
|
@ -58,28 +70,129 @@ function normalizeJob(bullJob, type, apiStatus) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch all jobs from all queues in bulk by state bucket (no per-job getState() calls).
|
||||||
async function getAllBullMQJobs() {
|
async function getAllBullMQJobs() {
|
||||||
const results = [];
|
const results = [];
|
||||||
for (const { queue, type } of QUEUES) {
|
for (const { queue, type } of QUEUES) {
|
||||||
for (const [bullState, apiStatus] of Object.entries(STATE_MAP)) {
|
for (const bucket of STATE_BUCKETS) {
|
||||||
try {
|
try {
|
||||||
const jobs = await queue.getJobs([bullState], 0, 200);
|
const apiStatus = STATE_MAP[bucket] || bucket;
|
||||||
|
const jobs = await queue.getJobs([bucket], 0, 200);
|
||||||
for (const job of jobs) {
|
for (const job of jobs) {
|
||||||
results.push(normalizeJob(job, type, apiStatus));
|
results.push(normalizeJob(job, type, apiStatus));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// queue may be empty or unavailable for this state – skip
|
// queue or bucket unavailable — skip
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GET / - List jobs (BullMQ queues) ────────────────────────────────────────
|
// Mutate `jobs` in place to fill in asset_name from the assets table for any
|
||||||
|
// job that has an assetId but no inline assetName in its payload. One bulk
|
||||||
|
// SQL query per refresh — cheap, and means we don't have to remember to pass
|
||||||
|
// assetName at every enqueue site (upload.js, capture stop, scheduler, etc.).
|
||||||
|
async function attachAssetNames(jobs) {
|
||||||
|
const idsNeedingLookup = [...new Set(
|
||||||
|
jobs.filter(j => j.asset_id && !j.asset_name).map(j => j.asset_id)
|
||||||
|
)];
|
||||||
|
if (idsNeedingLookup.length === 0) return;
|
||||||
|
|
||||||
|
let rows = [];
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
'SELECT id, display_name, filename FROM assets WHERE id = ANY($1::uuid[])',
|
||||||
|
[idsNeedingLookup]
|
||||||
|
);
|
||||||
|
rows = result.rows;
|
||||||
|
} catch {
|
||||||
|
// If the lookup fails (DB down, bad UUID in a stale BullMQ payload), keep
|
||||||
|
// serving jobs without names rather than 500-ing the whole list.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const byId = new Map(rows.map(r => [r.id, r.display_name || r.filename]));
|
||||||
|
for (const j of jobs) {
|
||||||
|
if (j.asset_id && !j.asset_name) {
|
||||||
|
const name = byId.get(j.asset_id);
|
||||||
|
if (name) j.asset_name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /events – Server-Sent Events stream of live job updates ───────────────
|
||||||
|
router.get('/events', async (req, res) => {
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no');
|
||||||
|
res.flushHeaders();
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
|
req.on('close', () => { closed = true; });
|
||||||
|
|
||||||
|
const push = async () => {
|
||||||
|
if (closed) return;
|
||||||
|
try {
|
||||||
|
const jobs = await getAllBullMQJobs();
|
||||||
|
await attachAssetNames(jobs);
|
||||||
|
if (!closed) res.write(`data: ${JSON.stringify({ type: 'jobs', jobs })}\n\n`);
|
||||||
|
} catch (err) {
|
||||||
|
if (!closed) res.write(`data: ${JSON.stringify({ type: 'error', message: err.message })}\n\n`);
|
||||||
|
}
|
||||||
|
if (!closed) setTimeout(push, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
await push();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch DB-tracked jobs (e.g. trim) and normalize to the same shape as BullMQ jobs.
|
||||||
|
// Only returns non-expired rows.
|
||||||
|
async function getDbJobs() {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT j.id, j.type, j.status, j.payload, j.created_at, j.updated_at,
|
||||||
|
ts.asset_id
|
||||||
|
FROM jobs j
|
||||||
|
LEFT JOIN temp_segments ts ON ts.job_id = j.id
|
||||||
|
WHERE (j.expires_at IS NULL OR j.expires_at > NOW())
|
||||||
|
ORDER BY j.created_at DESC
|
||||||
|
LIMIT 200`
|
||||||
|
);
|
||||||
|
// Dedupe — multiple temp_segments per job, take first asset_id found
|
||||||
|
const seen = new Map();
|
||||||
|
for (const row of result.rows) {
|
||||||
|
if (!seen.has(row.id)) {
|
||||||
|
seen.set(row.id, {
|
||||||
|
id: `trim:${row.id}`,
|
||||||
|
type: row.type,
|
||||||
|
status: row.status === 'completed' ? 'completed' : row.status,
|
||||||
|
progress: row.status === 'completed' ? 100 : (row.status === 'failed' ? 0 : 50),
|
||||||
|
asset_id: row.asset_id || null,
|
||||||
|
asset_name: null,
|
||||||
|
created_at: row.created_at ? new Date(row.created_at).toISOString() : null,
|
||||||
|
started_at: null,
|
||||||
|
completed_at: row.status === 'completed' && row.updated_at ? new Date(row.updated_at).toISOString() : null,
|
||||||
|
failed_at: row.status === 'failed' && row.updated_at ? new Date(row.updated_at).toISOString() : null,
|
||||||
|
error: null,
|
||||||
|
metadata: row.payload || {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...seen.values()];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET / - List jobs (BullMQ queues + DB trim jobs) ─────────────────────────
|
||||||
router.get('/', async (req, res, next) => {
|
router.get('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { type, status, asset_id } = req.query;
|
const { type, status, asset_id } = req.query;
|
||||||
let jobs = await getAllBullMQJobs();
|
let jobs = await getAllBullMQJobs();
|
||||||
|
const dbJobs = await getDbJobs();
|
||||||
|
jobs = jobs.concat(dbJobs);
|
||||||
|
await attachAssetNames(jobs);
|
||||||
|
|
||||||
if (type) jobs = jobs.filter(j => j.type === type);
|
if (type) jobs = jobs.filter(j => j.type === type);
|
||||||
if (status) jobs = jobs.filter(j => j.status === status);
|
if (status) jobs = jobs.filter(j => j.status === status);
|
||||||
|
|
@ -96,7 +209,6 @@ router.get('/', async (req, res, next) => {
|
||||||
router.get('/:id', async (req, res, next) => {
|
router.get('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
// id format: "type:bullId" e.g. "proxy:1"
|
|
||||||
const colonIdx = id.indexOf(':');
|
const colonIdx = id.indexOf(':');
|
||||||
const qType = colonIdx > -1 ? id.slice(0, colonIdx) : null;
|
const qType = colonIdx > -1 ? id.slice(0, colonIdx) : null;
|
||||||
const bullId = colonIdx > -1 ? id.slice(colonIdx + 1) : id;
|
const bullId = colonIdx > -1 ? id.slice(colonIdx + 1) : id;
|
||||||
|
|
@ -108,7 +220,9 @@ router.get('/:id', async (req, res, next) => {
|
||||||
if (job) {
|
if (job) {
|
||||||
const state = await job.getState();
|
const state = await job.getState();
|
||||||
const apiStatus = STATE_MAP[state] || state;
|
const apiStatus = STATE_MAP[state] || state;
|
||||||
return res.json(normalizeJob(job, type, apiStatus));
|
const normalized = normalizeJob(job, type, apiStatus);
|
||||||
|
await attachAssetNames([normalized]);
|
||||||
|
return res.json(normalized);
|
||||||
}
|
}
|
||||||
} catch { /* try next queue */ }
|
} catch { /* try next queue */ }
|
||||||
}
|
}
|
||||||
|
|
@ -118,8 +232,8 @@ router.get('/:id', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── DELETE /:id - Remove a job ────────────────────────────────────────────────
|
// ── POST /:id/retry - Retry a failed job ──────────────────────────────────────
|
||||||
router.delete('/:id', async (req, res, next) => {
|
router.post('/:id/retry', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const colonIdx = id.indexOf(':');
|
const colonIdx = id.indexOf(':');
|
||||||
|
|
@ -131,8 +245,8 @@ router.delete('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const job = await queue.getJob(bullId);
|
const job = await queue.getJob(bullId);
|
||||||
if (job) {
|
if (job) {
|
||||||
await job.remove();
|
await job.retry();
|
||||||
return res.json({ success: true });
|
return res.json({ id, status: 'queued' });
|
||||||
}
|
}
|
||||||
} catch { /* try next queue */ }
|
} catch { /* try next queue */ }
|
||||||
}
|
}
|
||||||
|
|
@ -142,7 +256,65 @@ router.delete('/:id', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── POST /conform - Submit a conform job ──────────────────────────────────────
|
// ── DELETE /:id - Remove a job (also handles cancel for active jobs) ─────────
|
||||||
|
// BullMQ refuses job.remove() while a job is in the 'active' state. Before this
|
||||||
|
// fix the route caught that error and fell through to a misleading 404, so
|
||||||
|
// operators couldn't kill a stalled-active job from the UI. Now we detect the
|
||||||
|
// active state explicitly: moveToFailed with the magic '0' token bypasses the
|
||||||
|
// per-worker lock check and transitions active → failed (freeing the queue's
|
||||||
|
// concurrency slot), then remove() drops the row.
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const colonIdx = id.indexOf(':');
|
||||||
|
const qType = colonIdx > -1 ? id.slice(0, colonIdx) : null;
|
||||||
|
const bullId = colonIdx > -1 ? id.slice(colonIdx + 1) : id;
|
||||||
|
|
||||||
|
let lastErr = null;
|
||||||
|
for (const { queue, type } of QUEUES) {
|
||||||
|
if (qType && type !== qType) continue;
|
||||||
|
let job;
|
||||||
|
try {
|
||||||
|
job = await queue.getJob(bullId);
|
||||||
|
} catch (err) {
|
||||||
|
// Queue-level lookup error: remember it so we don't mask it with 404.
|
||||||
|
lastErr = err;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!job) continue;
|
||||||
|
|
||||||
|
const state = await job.getState();
|
||||||
|
if (state === 'active') {
|
||||||
|
// Token '0' tells BullMQ to skip the worker-lock check — necessary
|
||||||
|
// because the operator-side cancel doesn't hold the worker's lock.
|
||||||
|
try {
|
||||||
|
await job.moveToFailed(new Error('Cancelled by operator'), '0', false);
|
||||||
|
} catch (err) {
|
||||||
|
// Lock owned by a still-living worker; fall back to discard + remove
|
||||||
|
// so at least the result is thrown away and the row is gone.
|
||||||
|
try { await job.discard(); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await job.remove();
|
||||||
|
} catch (err) {
|
||||||
|
// Last-resort obliteration of the job row via raw Redis. This is
|
||||||
|
// the path stalled jobs hit when moveToFailed couldn't transition
|
||||||
|
// them either.
|
||||||
|
const client = await queue.client;
|
||||||
|
const prefix = queue.toKey(bullId);
|
||||||
|
await client.del(prefix);
|
||||||
|
}
|
||||||
|
return res.json({ success: true, cancelled: state === 'active' });
|
||||||
|
}
|
||||||
|
if (lastErr) return next(lastErr);
|
||||||
|
res.status(404).json({ error: 'Job not found' });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /conform - Submit a conform (EDL export) job ────────────────────────
|
||||||
router.post('/conform', async (req, res, next) => {
|
router.post('/conform', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { edl, project_id, output_format } = req.body;
|
const { edl, project_id, output_format } = req.body;
|
||||||
|
|
@ -153,25 +325,17 @@ router.post('/conform', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobId = uuidv4();
|
// Conform writes back into a project — require edit on that project. Without
|
||||||
|
// this, any logged-in user could enqueue conform jobs targeting any project.
|
||||||
|
await assertProjectAccess(req.user, project_id, 'edit');
|
||||||
|
|
||||||
const result = await pool.query(
|
const bullJob = await conformQueue.add('conform-task', {
|
||||||
`INSERT INTO jobs (id, type, status, project_id, metadata, created_at, updated_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
|
|
||||||
RETURNING *`,
|
|
||||||
[jobId, 'conform', 'pending', project_id, JSON.stringify({ edl, output_format })]
|
|
||||||
);
|
|
||||||
|
|
||||||
const job = result.rows[0];
|
|
||||||
|
|
||||||
await conformQueue.add('conform-task', {
|
|
||||||
jobId,
|
|
||||||
edl,
|
edl,
|
||||||
projectId: project_id,
|
projectId: project_id,
|
||||||
outputFormat: output_format,
|
outputFormat: output_format,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json(job);
|
res.status(202).json({ id: `conform:${bullJob.id}`, status: 'queued' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
102
services/mam-api/src/routes/metrics.js
Normal file
102
services/mam-api/src/routes/metrics.js
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
// Real metrics for the Home page sparklines.
|
||||||
|
//
|
||||||
|
// Buckets the last N hours into N points, counting rows in each window.
|
||||||
|
// Returns a flat shape that's easy for the React Sparkline to consume.
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import pool from '../db/pool.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const DEFAULT_HOURS = 24;
|
||||||
|
const DEFAULT_POINTS = 13;
|
||||||
|
|
||||||
|
function bucketCountSQL(table, statusFilter) {
|
||||||
|
// Use date_trunc + generate_series so we always return `points` buckets
|
||||||
|
// (even hours with no rows show up as 0). All times are UTC.
|
||||||
|
return `
|
||||||
|
WITH series AS (
|
||||||
|
SELECT generate_series(
|
||||||
|
date_trunc('hour', NOW() - ($1 || ' hours')::interval),
|
||||||
|
date_trunc('hour', NOW()),
|
||||||
|
('1 hour')::interval
|
||||||
|
) AS bucket
|
||||||
|
)
|
||||||
|
SELECT s.bucket,
|
||||||
|
COALESCE(COUNT(t.created_at), 0)::int AS count
|
||||||
|
FROM series s
|
||||||
|
LEFT JOIN ${table} t
|
||||||
|
ON date_trunc('hour', t.created_at) = s.bucket
|
||||||
|
${statusFilter ? ` AND ${statusFilter}` : ''}
|
||||||
|
GROUP BY s.bucket
|
||||||
|
ORDER BY s.bucket ASC
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bucketSeries(table, hours, statusFilter = null) {
|
||||||
|
const result = await pool.query(bucketCountSQL(table, statusFilter), [hours]);
|
||||||
|
return result.rows.map(r => ({ t: r.bucket, v: r.count }));
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/home', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const hours = Math.min(parseInt(req.query.hours || DEFAULT_HOURS, 10), 168); // cap at 1 week
|
||||||
|
|
||||||
|
const [assets, jobsDone, jobsFailed, recordersTotal, recordersLive, jobsRunning, jobsDoneTotal, jobsFailedTotal] = await Promise.all([
|
||||||
|
bucketSeries('assets', hours),
|
||||||
|
bucketSeries('jobs', hours, `t.status = 'complete'`),
|
||||||
|
bucketSeries('jobs', hours, `t.status = 'failed'`),
|
||||||
|
pool.query(`SELECT COUNT(*)::int AS n FROM recorders`),
|
||||||
|
pool.query(`SELECT COUNT(*)::int AS n FROM recorders WHERE status = 'recording'`),
|
||||||
|
pool.query(`SELECT COUNT(*)::int AS n FROM jobs WHERE status IN ('queued','processing')`),
|
||||||
|
pool.query(`SELECT COUNT(*)::int AS n FROM jobs WHERE status = 'complete'`),
|
||||||
|
pool.query(`SELECT COUNT(*)::int AS n FROM jobs WHERE status = 'failed'`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Cluster snapshot — heartbeat freshness drives online/offline
|
||||||
|
const cluster = await pool.query(
|
||||||
|
`SELECT id, hostname, role,
|
||||||
|
EXTRACT(EPOCH FROM (NOW() - last_seen))::int AS stale_seconds
|
||||||
|
FROM cluster_nodes`
|
||||||
|
);
|
||||||
|
const nodes = cluster.rows.map(n => ({
|
||||||
|
id: n.id, hostname: n.hostname, role: n.role,
|
||||||
|
online: n.stale_seconds != null && n.stale_seconds < 120,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
hours,
|
||||||
|
generated_at: new Date().toISOString(),
|
||||||
|
cards: {
|
||||||
|
assets: {
|
||||||
|
total: (await pool.query(`SELECT COUNT(*)::int AS n FROM assets`)).rows[0].n,
|
||||||
|
series: assets,
|
||||||
|
},
|
||||||
|
recorders: {
|
||||||
|
total: recordersTotal.rows[0].n,
|
||||||
|
live: recordersLive.rows[0].n,
|
||||||
|
// No historical "active" metric yet — synthesize as the live count
|
||||||
|
// replayed across the window so the card has *something* to graph.
|
||||||
|
series: assets.map(p => ({ t: p.t, v: recordersLive.rows[0].n })),
|
||||||
|
},
|
||||||
|
jobs: {
|
||||||
|
running: jobsRunning.rows[0].n,
|
||||||
|
done_total: jobsDoneTotal.rows[0].n,
|
||||||
|
failed_total: jobsFailedTotal.rows[0].n,
|
||||||
|
series_done: jobsDone,
|
||||||
|
series_failed: jobsFailed,
|
||||||
|
},
|
||||||
|
cluster: {
|
||||||
|
total: nodes.length,
|
||||||
|
online: nodes.filter(n => n.online).length,
|
||||||
|
nodes,
|
||||||
|
// Heartbeat liveness is binary — emit a 1/0 across the window keyed
|
||||||
|
// to current state so the sparkline shows a sensible bar shape.
|
||||||
|
series: assets.map(p => ({ t: p.t, v: nodes.filter(n => n.online).length })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
620
services/mam-api/src/routes/playout.js
Normal file
620
services/mam-api/src/routes/playout.js
Normal file
|
|
@ -0,0 +1,620 @@
|
||||||
|
// Playout / Master Control routes.
|
||||||
|
//
|
||||||
|
// Control plane for the CasparCG-backed playout subsystem. Channels are placed
|
||||||
|
// on cluster nodes and their engine containers spawned via the same Docker-socket
|
||||||
|
// / node-agent path recorders use; the channel's transport (play / pause / skip)
|
||||||
|
// is proxied through to the sidecar's HTTP shim, which drives CasparCG over AMCP.
|
||||||
|
//
|
||||||
|
// RBAC: every channel carries a project_id (NULL = admin-only, the recorder
|
||||||
|
// convention). List routes filter by accessible projects; mutating routes assert
|
||||||
|
// 'edit'. See docs/superpowers/specs/2026-05-30-playout-mcr-design.md.
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import http from 'http';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import pool from '../db/pool.js';
|
||||||
|
import { validateUuid } from '../middleware/errors.js';
|
||||||
|
import {
|
||||||
|
assertProjectAccess, accessibleProjectIds, isAdmin,
|
||||||
|
} from '../auth/authz.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const parseRedisUrl = (url) => {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return { host: parsed.hostname, port: parseInt(parsed.port, 10) };
|
||||||
|
};
|
||||||
|
const stageQueue = new Queue('playout-stage', {
|
||||||
|
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const PLAYOUT_SIDECAR_IMAGE = process.env.PLAYOUT_IMAGE || 'wild-dragon-playout:latest';
|
||||||
|
|
||||||
|
function dockerApi(method, path, body = null) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const options = {
|
||||||
|
socketPath: '/var/run/docker.sock',
|
||||||
|
path: `/v1.43${path}`,
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
};
|
||||||
|
const req = http.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => (data += chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
try { resolve({ status: res.statusCode, data: data ? JSON.parse(data) : {} }); }
|
||||||
|
catch { resolve({ status: res.statusCode, data }); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.setTimeout(10000, () => req.destroy(new Error('Docker API timeout after 10s')));
|
||||||
|
if (body) req.write(JSON.stringify(body));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveNodeTarget(nodeId) {
|
||||||
|
if (!nodeId) return { remote: false };
|
||||||
|
const r = await pool.query(
|
||||||
|
'SELECT hostname, ip_address, api_url FROM cluster_nodes WHERE id = $1', [nodeId]
|
||||||
|
);
|
||||||
|
if (r.rows.length === 0) return { remote: false };
|
||||||
|
const node = r.rows[0];
|
||||||
|
const localHostname = process.env.NODE_HOSTNAME || '';
|
||||||
|
if (!node.api_url || node.hostname === localHostname) return { remote: false };
|
||||||
|
return { remote: true, apiUrl: node.api_url, ip: node.ip_address };
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIDECAR_HTTP_PORT = 3002;
|
||||||
|
|
||||||
|
function channelAlias(id) { return `playout-${id}`; }
|
||||||
|
|
||||||
|
function sidecarBaseUrl(channel) {
|
||||||
|
if (channel.container_meta && channel.container_meta.sidecar_url) {
|
||||||
|
return channel.container_meta.sidecar_url;
|
||||||
|
}
|
||||||
|
return `http://${channelAlias(channel.id)}:${SIDECAR_HTTP_PORT}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callSidecar(channel, path, method = 'POST', body = null) {
|
||||||
|
const url = `${sidecarBaseUrl(channel)}${path}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: AbortSignal.timeout(20000),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`sidecar ${method} ${path} -> HTTP ${res.status}: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
return res.json().catch(() => ({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelToJson(r) {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
node_id: r.node_id,
|
||||||
|
output_type: r.output_type,
|
||||||
|
output_config: r.output_config,
|
||||||
|
video_format: r.video_format,
|
||||||
|
status: r.status,
|
||||||
|
container_id: r.container_id,
|
||||||
|
error_message: r.error_message,
|
||||||
|
project_id: r.project_id,
|
||||||
|
restart_count: r.restart_count ?? 0,
|
||||||
|
last_restart_at: r.last_restart_at,
|
||||||
|
last_heartbeat_at: r.last_heartbeat_at,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const OUTPUT_TYPES = new Set(['decklink', 'ndi', 'srt', 'rtmp']);
|
||||||
|
|
||||||
|
router.param('id', async (req, res, next) => {
|
||||||
|
validateUuid('id')(req, res, () => {});
|
||||||
|
if (res.headersSent) return;
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'SELECT * FROM playout_channels WHERE id = $1', [req.params.id]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) return res.status(404).json({ error: 'Channel not found' });
|
||||||
|
req.channel = rows[0];
|
||||||
|
await assertProjectAccess(req.user, req.channel.project_id, 'view');
|
||||||
|
next();
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function requireChannelEdit(req, res, next) {
|
||||||
|
try { await assertProjectAccess(req.user, req.channel.project_id, 'edit'); next(); }
|
||||||
|
catch (err) { next(err); }
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/channels', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
let rows;
|
||||||
|
if (isAdmin(req.user)) {
|
||||||
|
({ rows } = await pool.query('SELECT * FROM playout_channels ORDER BY created_at DESC'));
|
||||||
|
} else {
|
||||||
|
const ids = await accessibleProjectIds(req.user);
|
||||||
|
if (ids.length === 0) return res.json([]);
|
||||||
|
({ rows } = await pool.query(
|
||||||
|
'SELECT * FROM playout_channels WHERE project_id = ANY($1) ORDER BY created_at DESC', [ids]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
res.json(rows.map(channelToJson));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/channels', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, node_id = null, output_type = 'srt', output_config = {},
|
||||||
|
video_format = '1080p5994', project_id = null } = req.body || {};
|
||||||
|
if (!name || typeof name !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'name is required' });
|
||||||
|
}
|
||||||
|
if (!OUTPUT_TYPES.has(output_type)) {
|
||||||
|
return res.status(400).json({ error: `output_type must be one of: ${[...OUTPUT_TYPES].join(', ')}` });
|
||||||
|
}
|
||||||
|
if (project_id) await assertProjectAccess(req.user, project_id, 'edit');
|
||||||
|
else if (!isAdmin(req.user)) return res.status(403).json({ error: 'admin required for unassigned channel' });
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO playout_channels (name, node_id, output_type, output_config, video_format, project_id)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
|
||||||
|
[name.trim(), node_id, output_type, JSON.stringify(output_config), video_format, project_id]
|
||||||
|
);
|
||||||
|
res.status(201).json(channelToJson(rows[0]));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/channels/:id', requireChannelEdit, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (req.channel.status === 'running') {
|
||||||
|
return res.status(409).json({ error: 'Cannot edit a running channel — stop it first' });
|
||||||
|
}
|
||||||
|
const allowed = ['name', 'node_id', 'output_type', 'output_config', 'video_format', 'project_id'];
|
||||||
|
const sets = [];
|
||||||
|
const vals = [];
|
||||||
|
let i = 1;
|
||||||
|
for (const k of allowed) {
|
||||||
|
if (req.body[k] === undefined) continue;
|
||||||
|
if (k === 'output_type' && !OUTPUT_TYPES.has(req.body[k])) {
|
||||||
|
return res.status(400).json({ error: 'invalid output_type' });
|
||||||
|
}
|
||||||
|
sets.push(`${k} = $${i++}`);
|
||||||
|
vals.push(k === 'output_config' ? JSON.stringify(req.body[k]) : req.body[k]);
|
||||||
|
}
|
||||||
|
if (sets.length === 0) return res.json(channelToJson(req.channel));
|
||||||
|
vals.push(req.channel.id);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`UPDATE playout_channels SET ${sets.join(', ')}, updated_at = NOW() WHERE id = $${i} RETURNING *`, vals
|
||||||
|
);
|
||||||
|
res.json(channelToJson(rows[0]));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/channels/:id', requireChannelEdit, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (req.channel.status === 'running') {
|
||||||
|
return res.status(409).json({ error: 'Stop the channel before deleting it' });
|
||||||
|
}
|
||||||
|
await pool.query('DELETE FROM playout_channels WHERE id = $1', [req.channel.id]);
|
||||||
|
res.json({ deleted: true });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function assertDeckLinkFree(channel) {
|
||||||
|
if (channel.output_type !== 'decklink') return;
|
||||||
|
const idx = (channel.output_config && channel.output_config.device_index) || 1;
|
||||||
|
const chan = await pool.query(
|
||||||
|
`SELECT id FROM playout_channels
|
||||||
|
WHERE id <> $1 AND node_id IS NOT DISTINCT FROM $2 AND status = 'running'
|
||||||
|
AND output_type = 'decklink' AND (output_config->>'device_index')::int = $3`,
|
||||||
|
[channel.id, channel.node_id, idx]
|
||||||
|
);
|
||||||
|
if (chan.rows.length > 0) {
|
||||||
|
throw Object.assign(new Error(`DeckLink device ${idx} already in use by another channel on this node`), { httpStatus: 409 });
|
||||||
|
}
|
||||||
|
const rec = await pool.query(
|
||||||
|
`SELECT id FROM recorders
|
||||||
|
WHERE node_id IS NOT DISTINCT FROM $1 AND device_index = $2
|
||||||
|
AND status = 'recording' AND source_type = 'sdi'`,
|
||||||
|
[channel.node_id, idx]
|
||||||
|
);
|
||||||
|
if (rec.rows.length > 0) {
|
||||||
|
throw Object.assign(new Error(`DeckLink device ${idx} is in use by a recorder on this node`), { httpStatus: 409 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function spawnChannelSidecar(channel) {
|
||||||
|
await pool.query('UPDATE playout_channels SET status = $1, error_message = NULL WHERE id = $2', ['starting', channel.id]);
|
||||||
|
|
||||||
|
const env = [
|
||||||
|
`OUTPUT_TYPE=${channel.output_type}`,
|
||||||
|
`OUTPUT_CONFIG=${JSON.stringify(channel.output_config || {})}`,
|
||||||
|
`VIDEO_FORMAT=${channel.video_format}`,
|
||||||
|
`PORT=${SIDECAR_HTTP_PORT}`,
|
||||||
|
`CHANNEL_ID=${channel.id}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(channel.node_id);
|
||||||
|
const dockerNetwork = process.env.DOCKER_NETWORK || 'wild-dragon_wild-dragon';
|
||||||
|
let containerId;
|
||||||
|
let containerMeta = {};
|
||||||
|
|
||||||
|
if (isRemote) {
|
||||||
|
const sidecarRes = await fetch(`${targetNodeApiUrl}/sidecar/start`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
image: PLAYOUT_SIDECAR_IMAGE, env,
|
||||||
|
capturePort: SIDECAR_HTTP_PORT,
|
||||||
|
sourceType: channel.output_type,
|
||||||
|
useGpu: false,
|
||||||
|
publishHttp: true,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(20000),
|
||||||
|
});
|
||||||
|
if (!sidecarRes.ok) {
|
||||||
|
const details = await sidecarRes.json().catch(() => ({}));
|
||||||
|
console.error('[playout] remote sidecar start failed:', JSON.stringify(details));
|
||||||
|
await pool.query('UPDATE playout_channels SET status = $1, error_message = $2 WHERE id = $3',
|
||||||
|
['error', 'remote node failed to start sidecar', channel.id]);
|
||||||
|
throw Object.assign(new Error('Remote node failed to start sidecar'), { httpStatus: 502 });
|
||||||
|
}
|
||||||
|
const data = await sidecarRes.json();
|
||||||
|
containerId = data.containerId;
|
||||||
|
if (data.sidecarUrl || data.host) {
|
||||||
|
containerMeta.sidecar_url = data.sidecarUrl || `http://${data.host}:${SIDECAR_HTTP_PORT}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const alias = channelAlias(channel.id);
|
||||||
|
const hostBinds = ['/mnt/NVME/MAM/wild-dragon-media:/media'];
|
||||||
|
if (channel.output_type === 'decklink') hostBinds.push('/dev/blackmagic:/dev/blackmagic');
|
||||||
|
|
||||||
|
const containerConfig = {
|
||||||
|
Image: PLAYOUT_SIDECAR_IMAGE,
|
||||||
|
Env: env,
|
||||||
|
HostConfig: {
|
||||||
|
// DeckLink SDI needs raw /dev access (privileged). SRT/NDI/RTMP/HLS run
|
||||||
|
// unprivileged — privileged exposes host GPUs to CasparCG, and the
|
||||||
|
// missing in-container NVIDIA driver crashes the engine within seconds.
|
||||||
|
Privileged: channel.output_type === 'decklink',
|
||||||
|
NetworkMode: dockerNetwork,
|
||||||
|
Binds: hostBinds,
|
||||||
|
},
|
||||||
|
NetworkingConfig: { EndpointsConfig: { [dockerNetwork]: { Aliases: [alias] } } },
|
||||||
|
Hostname: alias,
|
||||||
|
};
|
||||||
|
const createRes = await dockerApi('POST', '/containers/create', containerConfig);
|
||||||
|
if (createRes.status !== 201) {
|
||||||
|
console.error('[playout] container create failed:', JSON.stringify(createRes.data));
|
||||||
|
await pool.query('UPDATE playout_channels SET status = $1, error_message = $2 WHERE id = $3',
|
||||||
|
['error', 'container create failed', channel.id]);
|
||||||
|
throw Object.assign(new Error('Failed to create container'), { httpStatus: 500 });
|
||||||
|
}
|
||||||
|
containerId = createRes.data.Id;
|
||||||
|
const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
|
||||||
|
if (startRes.status !== 204) {
|
||||||
|
console.error('[playout] container start failed:', JSON.stringify(startRes.data));
|
||||||
|
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
|
||||||
|
await pool.query('UPDATE playout_channels SET status = $1, error_message = $2 WHERE id = $3',
|
||||||
|
['error', 'container start failed', channel.id]);
|
||||||
|
throw Object.assign(new Error('Failed to start container'), { httpStatus: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`UPDATE playout_channels
|
||||||
|
SET status = 'running', container_id = $1, container_meta = $2, updated_at = NOW()
|
||||||
|
WHERE id = $3 RETURNING *`,
|
||||||
|
[containerId, JSON.stringify(containerMeta), channel.id]
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/channels/:id/start', requireChannelEdit, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const channel = req.channel;
|
||||||
|
if (channel.status === 'running' || channel.status === 'starting') {
|
||||||
|
return res.status(409).json({ error: `Channel already ${channel.status}` });
|
||||||
|
}
|
||||||
|
await assertDeckLinkFree(channel);
|
||||||
|
const row = await spawnChannelSidecar(channel);
|
||||||
|
res.json(channelToJson(row));
|
||||||
|
} catch (err) {
|
||||||
|
if (err.httpStatus) return res.status(err.httpStatus).json({ error: err.message });
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/channels/:id/stop', requireChannelEdit, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const channel = req.channel;
|
||||||
|
if (channel.container_id) {
|
||||||
|
const { remote: isRemote, apiUrl } = await resolveNodeTarget(channel.node_id);
|
||||||
|
if (isRemote) {
|
||||||
|
await fetch(`${apiUrl}/sidecar/stop`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ containerId: channel.container_id }),
|
||||||
|
signal: AbortSignal.timeout(20000),
|
||||||
|
}).catch((e) => console.error('[playout] remote stop failed:', e.message));
|
||||||
|
} else {
|
||||||
|
await dockerApi('POST', `/containers/${channel.container_id}/stop?t=10`).catch(() => {});
|
||||||
|
await dockerApi('DELETE', `/containers/${channel.container_id}?force=true`).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`UPDATE playout_channels SET status = 'stopped', container_id = NULL, updated_at = NOW()
|
||||||
|
WHERE id = $1 RETURNING *`, [channel.id]
|
||||||
|
);
|
||||||
|
res.json(channelToJson(rows[0]));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/channels/:id/status', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (req.channel.status !== 'running') {
|
||||||
|
return res.json({ running: false, status: req.channel.status });
|
||||||
|
}
|
||||||
|
const out = await callSidecar(req.channel, '/status', 'GET');
|
||||||
|
res.json({ running: true, status: req.channel.status, engine: out });
|
||||||
|
} catch (err) {
|
||||||
|
res.json({ running: true, status: req.channel.status, engine: null, engine_error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function transport(req, res, action, body = null) {
|
||||||
|
if (req.channel.status !== 'running') {
|
||||||
|
return res.status(409).json({ error: 'Channel is not running' });
|
||||||
|
}
|
||||||
|
try { res.json(await callSidecar(req.channel, action, 'POST', body)); }
|
||||||
|
catch (err) { res.status(502).json({ error: err.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/channels/:id/play', requireChannelEdit, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (req.channel.status !== 'running') {
|
||||||
|
return res.status(409).json({ error: 'Start the channel before playing' });
|
||||||
|
}
|
||||||
|
const { playlist_id } = req.body || {};
|
||||||
|
if (!playlist_id) return res.status(400).json({ error: 'playlist_id is required' });
|
||||||
|
|
||||||
|
const pl = await pool.query('SELECT * FROM playout_playlists WHERE id = $1 AND channel_id = $2',
|
||||||
|
[playlist_id, req.channel.id]);
|
||||||
|
if (pl.rows.length === 0) return res.status(404).json({ error: 'Playlist not found for this channel' });
|
||||||
|
|
||||||
|
const items = await pool.query(
|
||||||
|
`SELECT i.*, a.filename AS clip_name, a.duration_ms AS asset_duration_ms
|
||||||
|
FROM playout_items i JOIN assets a ON a.id = i.asset_id
|
||||||
|
WHERE i.playlist_id = $1 ORDER BY i.sort_order ASC`, [playlist_id]);
|
||||||
|
|
||||||
|
const notReady = items.rows.filter((i) => i.media_status !== 'ready' || !i.media_path);
|
||||||
|
if (notReady.length > 0) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'Some items are not staged yet',
|
||||||
|
pending: notReady.map((i) => i.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
loop: pl.rows[0].loop,
|
||||||
|
items: items.rows.map((i) => ({
|
||||||
|
id: i.id, asset_id: i.asset_id, media_path: i.media_path,
|
||||||
|
in_point: i.in_point ? Number(i.in_point) : null,
|
||||||
|
out_point: i.out_point ? Number(i.out_point) : null,
|
||||||
|
transition: i.transition, transition_ms: i.transition_ms,
|
||||||
|
clip_name: i.clip_name,
|
||||||
|
asset_duration_ms: i.asset_duration_ms != null ? Number(i.asset_duration_ms) : null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const out = await callSidecar(req.channel, '/playlist/load', 'POST', payload);
|
||||||
|
res.json(out);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/channels/:id/pause', requireChannelEdit, (req, res) => transport(req, res, '/transport/pause'));
|
||||||
|
router.post('/channels/:id/resume', requireChannelEdit, (req, res) => transport(req, res, '/transport/resume'));
|
||||||
|
router.post('/channels/:id/skip', requireChannelEdit, (req, res) => transport(req, res, '/transport/skip'));
|
||||||
|
router.post('/channels/:id/stop-playback', requireChannelEdit, (req, res) => transport(req, res, '/channel/stop'));
|
||||||
|
|
||||||
|
router.get('/channels/:id/asrun', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT * FROM playout_as_run WHERE channel_id = $1 ORDER BY started_at DESC LIMIT 500`,
|
||||||
|
[req.channel.id]);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadChannelForBody(req, res, next) {
|
||||||
|
const channelId = req.body.channel_id || req.query.channel_id;
|
||||||
|
if (!channelId) return res.status(400).json({ error: 'channel_id is required' });
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query('SELECT * FROM playout_channels WHERE id = $1', [channelId]);
|
||||||
|
if (rows.length === 0) return res.status(404).json({ error: 'Channel not found' });
|
||||||
|
req.channel = rows[0];
|
||||||
|
await assertProjectAccess(req.user, req.channel.project_id, 'edit');
|
||||||
|
next();
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/playlists', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const channelId = req.query.channel_id;
|
||||||
|
if (!channelId) return res.status(400).json({ error: 'channel_id is required' });
|
||||||
|
const ch = await pool.query('SELECT project_id FROM playout_channels WHERE id = $1', [channelId]);
|
||||||
|
if (ch.rows.length === 0) return res.status(404).json({ error: 'Channel not found' });
|
||||||
|
await assertProjectAccess(req.user, ch.rows[0].project_id, 'view');
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'SELECT * FROM playout_playlists WHERE channel_id = $1 ORDER BY created_at ASC', [channelId]);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/playlists', loadChannelForBody, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, loop = false } = req.body || {};
|
||||||
|
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'INSERT INTO playout_playlists (channel_id, name, loop) VALUES ($1,$2,$3) RETURNING *',
|
||||||
|
[req.channel.id, name.trim(), !!loop]);
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/playlists/:plid/items', validateUuid('plid'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const pl = await pool.query(
|
||||||
|
`SELECT p.*, c.project_id FROM playout_playlists p
|
||||||
|
JOIN playout_channels c ON c.id = p.channel_id WHERE p.id = $1`, [req.params.plid]);
|
||||||
|
if (pl.rows.length === 0) return res.status(404).json({ error: 'Playlist not found' });
|
||||||
|
await assertProjectAccess(req.user, pl.rows[0].project_id, 'view');
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT i.*, a.filename AS clip_name, a.duration_ms AS asset_duration_ms
|
||||||
|
FROM playout_items i JOIN assets a ON a.id = i.asset_id
|
||||||
|
WHERE i.playlist_id = $1 ORDER BY i.sort_order ASC`, [req.params.plid]);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadPlaylistEdit(plid, user) {
|
||||||
|
const pl = await pool.query(
|
||||||
|
`SELECT p.*, c.project_id FROM playout_playlists p
|
||||||
|
JOIN playout_channels c ON c.id = p.channel_id WHERE p.id = $1`, [plid]);
|
||||||
|
if (pl.rows.length === 0) { throw Object.assign(new Error('Playlist not found'), { httpStatus: 404 }); }
|
||||||
|
await assertProjectAccess(user, pl.rows[0].project_id, 'edit');
|
||||||
|
return pl.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/playlists/:plid/items', validateUuid('plid'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await loadPlaylistEdit(req.params.plid, req.user);
|
||||||
|
const { asset_id, in_point = null, out_point = null,
|
||||||
|
transition = 'cut', transition_ms = 0 } = req.body || {};
|
||||||
|
if (!asset_id) return res.status(400).json({ error: 'asset_id is required' });
|
||||||
|
|
||||||
|
const ord = await pool.query(
|
||||||
|
'SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM playout_items WHERE playlist_id = $1',
|
||||||
|
[req.params.plid]);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO playout_items (playlist_id, asset_id, sort_order, in_point, out_point, transition, transition_ms)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
|
||||||
|
[req.params.plid, asset_id, ord.rows[0].next, in_point, out_point, transition, transition_ms]);
|
||||||
|
|
||||||
|
await stageQueue.add('stage', { itemId: rows[0].id, assetId: asset_id }).catch((e) =>
|
||||||
|
console.error('[playout] failed to enqueue stage job:', e.message));
|
||||||
|
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.httpStatus) return res.status(err.httpStatus).json({ error: err.message });
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/playlists/:plid/reorder', validateUuid('plid'), async (req, res, next) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await loadPlaylistEdit(req.params.plid, req.user);
|
||||||
|
const { order } = req.body || {};
|
||||||
|
if (!Array.isArray(order)) return res.status(400).json({ error: 'order must be an array of item ids' });
|
||||||
|
await client.query('BEGIN');
|
||||||
|
for (let i = 0; i < order.length; i++) {
|
||||||
|
await client.query(
|
||||||
|
'UPDATE playout_items SET sort_order = $1, updated_at = NOW() WHERE id = $2 AND playlist_id = $3',
|
||||||
|
[i, order[i], req.params.plid]);
|
||||||
|
}
|
||||||
|
await client.query('COMMIT');
|
||||||
|
res.json({ reordered: order.length });
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK').catch(() => {});
|
||||||
|
if (err.httpStatus) return res.status(err.httpStatus).json({ error: err.message });
|
||||||
|
next(err);
|
||||||
|
} finally { client.release(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/items/:itemId', validateUuid('itemId'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const it = await pool.query(
|
||||||
|
`SELECT i.id, c.project_id FROM playout_items i
|
||||||
|
JOIN playout_playlists p ON p.id = i.playlist_id
|
||||||
|
JOIN playout_channels c ON c.id = p.channel_id WHERE i.id = $1`, [req.params.itemId]);
|
||||||
|
if (it.rows.length === 0) return res.status(404).json({ error: 'Item not found' });
|
||||||
|
await assertProjectAccess(req.user, it.rows[0].project_id, 'edit');
|
||||||
|
await pool.query('DELETE FROM playout_items WHERE id = $1', [req.params.itemId]);
|
||||||
|
res.json({ deleted: true });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/items/:itemId/stage', validateUuid('itemId'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const it = await pool.query(
|
||||||
|
`SELECT i.id, i.asset_id, c.project_id FROM playout_items i
|
||||||
|
JOIN playout_playlists p ON p.id = i.playlist_id
|
||||||
|
JOIN playout_channels c ON c.id = p.channel_id WHERE i.id = $1`, [req.params.itemId]);
|
||||||
|
if (it.rows.length === 0) return res.status(404).json({ error: 'Item not found' });
|
||||||
|
await assertProjectAccess(req.user, it.rows[0].project_id, 'edit');
|
||||||
|
await pool.query("UPDATE playout_items SET media_status = 'pending' WHERE id = $1", [req.params.itemId]);
|
||||||
|
await stageQueue.add('stage', { itemId: it.rows[0].id, assetId: it.rows[0].asset_id });
|
||||||
|
res.json({ queued: true });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function restartChannel(channelId) {
|
||||||
|
const { rows } = await pool.query('SELECT * FROM playout_channels WHERE id = $1', [channelId]);
|
||||||
|
if (rows.length === 0) return { restarted: false, reason: 'channel not found' };
|
||||||
|
const channel = rows[0];
|
||||||
|
|
||||||
|
if (channel.output_type === 'decklink') {
|
||||||
|
return { restarted: false, reason: 'decklink channels are alert-only' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel.container_id) {
|
||||||
|
const { remote, apiUrl } = await resolveNodeTarget(channel.node_id);
|
||||||
|
if (remote && apiUrl) {
|
||||||
|
await fetch(`${apiUrl}/sidecar/stop`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ containerId: channel.container_id }),
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
}).catch(() => {});
|
||||||
|
} else {
|
||||||
|
await dockerApi('DELETE', `/containers/${channel.container_id}?force=true`).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = await pool.query(
|
||||||
|
`SELECT id, hostname, api_url, last_seen_at FROM cluster_nodes
|
||||||
|
WHERE id <> $1 AND last_seen_at > NOW() - INTERVAL '60 seconds'
|
||||||
|
ORDER BY last_seen_at DESC LIMIT 1`,
|
||||||
|
[channel.node_id]
|
||||||
|
);
|
||||||
|
if (nodes.rows.length === 0) {
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE playout_channels SET status = 'error', error_message = $1 WHERE id = $2",
|
||||||
|
['no healthy node available for failover', channel.id]
|
||||||
|
);
|
||||||
|
return { restarted: false, reason: 'no eligible node' };
|
||||||
|
}
|
||||||
|
const newNodeId = nodes.rows[0].id;
|
||||||
|
|
||||||
|
const { rows: moved } = await pool.query(
|
||||||
|
`UPDATE playout_channels
|
||||||
|
SET node_id = $1, status = 'stopped', container_id = NULL, container_meta = '{}'::jsonb,
|
||||||
|
restart_count = restart_count + 1, last_restart_at = NOW(),
|
||||||
|
error_message = NULL, updated_at = NOW()
|
||||||
|
WHERE id = $2 RETURNING *`,
|
||||||
|
[newNodeId, channel.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await spawnChannelSidecar(moved[0]);
|
||||||
|
return { restarted: true, new_node_id: newNodeId };
|
||||||
|
} catch (err) {
|
||||||
|
return { restarted: false, reason: `respawn failed: ${err.message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import pool from '../db/pool.js';
|
import pool from '../db/pool.js';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { validateUuid } from '../middleware/errors.js';
|
||||||
|
import { requireAdmin } from '../middleware/auth.js';
|
||||||
|
import { accessibleProjectIds, assertProjectAccess } from '../auth/authz.js';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
|
||||||
router.use(requireAuth);
|
|
||||||
|
|
||||||
// Helper function to slugify
|
// Helper function to slugify
|
||||||
const slugify = (str) => {
|
const slugify = (str) => {
|
||||||
|
|
@ -17,18 +18,29 @@ const slugify = (str) => {
|
||||||
.replace(/-+/g, '-');
|
.replace(/-+/g, '-');
|
||||||
};
|
};
|
||||||
|
|
||||||
// GET / - List all projects
|
// GET / - List projects the caller can access (admins see all).
|
||||||
router.get('/', async (req, res, next) => {
|
router.get('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query('SELECT * FROM projects ORDER BY created_at DESC');
|
const access = await accessibleProjectIds(req.user);
|
||||||
|
if (access.all) {
|
||||||
|
const result = await pool.query('SELECT * FROM projects ORDER BY created_at DESC');
|
||||||
|
return res.json(result.rows);
|
||||||
|
}
|
||||||
|
if (access.ids.size === 0) return res.json([]);
|
||||||
|
const ids = [...access.ids];
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM projects WHERE id = ANY($1::uuid[]) ORDER BY created_at DESC`,
|
||||||
|
[ids]
|
||||||
|
);
|
||||||
res.json(result.rows);
|
res.json(result.rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST / - Create project
|
// POST / - Create project (admin only; new projects have no grants, so a
|
||||||
router.post('/', async (req, res, next) => {
|
// scoped user could never reach one they just made).
|
||||||
|
router.post('/', requireAdmin, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { name, description } = req.body;
|
const { name, description } = req.body;
|
||||||
|
|
||||||
|
|
@ -52,10 +64,11 @@ router.post('/', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /:id - Single project with asset count
|
// GET /:id - Single project with asset count (requires view access).
|
||||||
router.get('/:id', async (req, res, next) => {
|
router.get('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
await assertProjectAccess(req.user, id, 'view');
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT p.*,
|
`SELECT p.*,
|
||||||
|
|
@ -77,10 +90,11 @@ router.get('/:id', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /:id - Update project
|
// PATCH /:id - Update project (requires edit access).
|
||||||
router.patch('/:id', async (req, res, next) => {
|
router.patch('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
await assertProjectAccess(req.user, id, 'edit');
|
||||||
const { name, description } = req.body;
|
const { name, description } = req.body;
|
||||||
|
|
||||||
const updates = [];
|
const updates = [];
|
||||||
|
|
@ -123,8 +137,9 @@ router.patch('/:id', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /:id - Delete project and cascade
|
// DELETE /:id - Delete project and cascade (admin only — destructive, wipes
|
||||||
router.delete('/:id', async (req, res, next) => {
|
// every asset/bin/recorder under it).
|
||||||
|
router.delete('/:id', requireAdmin, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
|
|
@ -144,4 +159,78 @@ router.delete('/:id', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Per-project access grants (admin only) ──────────────────────────────────
|
||||||
|
// GET /:id/access — list grants with resolved user/group display names.
|
||||||
|
router.get('/:id/access', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT pa.subject_type, pa.subject_id, pa.level, pa.granted_at,
|
||||||
|
CASE pa.subject_type
|
||||||
|
WHEN 'user' THEN u.display_name
|
||||||
|
WHEN 'group' THEN g.name
|
||||||
|
END AS subject_name,
|
||||||
|
CASE pa.subject_type
|
||||||
|
WHEN 'user' THEN u.username
|
||||||
|
ELSE NULL
|
||||||
|
END AS username
|
||||||
|
FROM project_access pa
|
||||||
|
LEFT JOIN users u ON pa.subject_type = 'user' AND u.id = pa.subject_id
|
||||||
|
LEFT JOIN groups g ON pa.subject_type = 'group' AND g.id = pa.subject_id
|
||||||
|
WHERE pa.project_id = $1
|
||||||
|
ORDER BY pa.subject_type, subject_name`,
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /:id/access { subject_type, subject_id, level } — grant or update.
|
||||||
|
router.post('/:id/access', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { subject_type, subject_id, level } = req.body || {};
|
||||||
|
if (!['user', 'group'].includes(subject_type)) {
|
||||||
|
return res.status(400).json({ error: "subject_type must be 'user' or 'group'" });
|
||||||
|
}
|
||||||
|
if (!subject_id) return res.status(400).json({ error: 'subject_id required' });
|
||||||
|
const lvl = level || 'view';
|
||||||
|
if (!['view', 'edit'].includes(lvl)) {
|
||||||
|
return res.status(400).json({ error: "level must be 'view' or 'edit'" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the subject actually exists so we don't create dead grants.
|
||||||
|
const tbl = subject_type === 'user' ? 'users' : 'groups';
|
||||||
|
const exists = await pool.query(`SELECT 1 FROM ${tbl} WHERE id = $1`, [subject_id]);
|
||||||
|
if (exists.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: subject_type + ' not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO project_access (project_id, subject_type, subject_id, level, granted_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (project_id, subject_type, subject_id)
|
||||||
|
DO UPDATE SET level = EXCLUDED.level, granted_by = EXCLUDED.granted_by, granted_at = NOW()
|
||||||
|
RETURNING project_id, subject_type, subject_id, level, granted_at`,
|
||||||
|
[req.params.id, subject_type, subject_id, lvl, req.user?.id || null]
|
||||||
|
);
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /:id/access/:subjectType/:subjectId — revoke a grant.
|
||||||
|
router.delete('/:id/access/:subjectType/:subjectId', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id, subjectType, subjectId } = req.params;
|
||||||
|
if (!['user', 'group'].includes(subjectType)) {
|
||||||
|
return res.status(400).json({ error: "subjectType must be 'user' or 'group'" });
|
||||||
|
}
|
||||||
|
const { rowCount } = await pool.query(
|
||||||
|
`DELETE FROM project_access
|
||||||
|
WHERE project_id = $1 AND subject_type = $2 AND subject_id = $3`,
|
||||||
|
[id, subjectType, subjectId]
|
||||||
|
);
|
||||||
|
if (rowCount === 0) return res.status(404).json({ error: 'grant not found' });
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,43 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
|
import fs from 'fs';
|
||||||
|
import net from 'net';
|
||||||
|
import dgram from 'dgram';
|
||||||
import pool from '../db/pool.js';
|
import pool from '../db/pool.js';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { getS3Bucket } from '../s3/client.js';
|
||||||
|
import { validateUuid } from '../middleware/errors.js';
|
||||||
|
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.use(requireAuth);
|
// Every /:id recorder route is scoped to the recorder's project. The param
|
||||||
|
// handler validates the UUID, resolves the owning project_id, and asserts the
|
||||||
|
// 'view' baseline; mutating routes escalate to 'edit' via requireRecorderEdit.
|
||||||
|
// A recorder with a NULL project_id resolves to admin-only (assertProjectAccess
|
||||||
|
// throws 403 for non-admins on a null project).
|
||||||
|
router.param('id', async (req, res, next) => {
|
||||||
|
validateUuid('id')(req, res, () => {});
|
||||||
|
if (res.headersSent) return;
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query('SELECT project_id FROM recorders WHERE id = $1', [req.params.id]);
|
||||||
|
if (rows.length === 0) return res.status(404).json({ error: 'Recorder not found' });
|
||||||
|
req.recorderProjectId = rows[0].project_id;
|
||||||
|
await assertProjectAccess(req.user, req.recorderProjectId, 'view');
|
||||||
|
next();
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function requireRecorderEdit(req, res, next) {
|
||||||
|
try {
|
||||||
|
await assertProjectAccess(req.user, req.recorderProjectId, 'edit');
|
||||||
|
next();
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base port for on-demand SDI sidecar containers on remote worker nodes.
|
||||||
|
// Device index 0 → 7438, index 1 → 7439, etc.
|
||||||
|
const SIDECAR_PORT_BASE = 7438;
|
||||||
|
|
||||||
// Docker API helper function
|
// Docker API helper function
|
||||||
function dockerApi(method, path, body = null) {
|
function dockerApi(method, path, body = null) {
|
||||||
|
|
@ -29,11 +60,31 @@ function dockerApi(method, path, body = null) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
req.on('error', reject);
|
req.on('error', reject);
|
||||||
|
// Add 10-second timeout to prevent indefinite hangs if Docker daemon is unresponsive
|
||||||
|
req.setTimeout(10000, () => {
|
||||||
|
req.destroy(new Error('Docker API timeout after 10s'));
|
||||||
|
});
|
||||||
if (body) req.write(JSON.stringify(body));
|
if (body) req.write(JSON.stringify(body));
|
||||||
req.end();
|
req.end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Look up the cluster node for a recorder and decide if it is remote.
|
||||||
|
// Returns { remote: false } when the node is local or unset;
|
||||||
|
// { remote: true, apiUrl, ip } when it is a different host.
|
||||||
|
async function resolveNodeTarget(nodeId) {
|
||||||
|
if (!nodeId) return { remote: false };
|
||||||
|
const r = await pool.query(
|
||||||
|
'SELECT hostname, ip_address, api_url FROM cluster_nodes WHERE id = $1',
|
||||||
|
[nodeId]
|
||||||
|
);
|
||||||
|
if (r.rows.length === 0) return { remote: false };
|
||||||
|
const node = r.rows[0];
|
||||||
|
const localHostname = process.env.NODE_HOSTNAME || '';
|
||||||
|
if (!node.api_url || node.hostname === localHostname) return { remote: false };
|
||||||
|
return { remote: true, apiUrl: node.api_url, ip: node.ip_address };
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to generate clip name with timestamp
|
// Helper function to generate clip name with timestamp
|
||||||
function generateClipName(recorderName) {
|
function generateClipName(recorderName) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
@ -43,12 +94,31 @@ function generateClipName(recorderName) {
|
||||||
const hours = String(now.getHours()).padStart(2, '0');
|
const hours = String(now.getHours()).padStart(2, '0');
|
||||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||||
return `${recorderName}_${year}${month}${day}_${hours}${minutes}${seconds}`;
|
// Strip filesystem-hostile characters out of the recorder name (spaces
|
||||||
|
// become underscores, anything outside [A-Za-z0-9._-] is dropped) so the
|
||||||
|
// clipName flows cleanly through S3 keys, SMB paths, and ffmpeg args.
|
||||||
|
const safe = String(recorderName || 'rec')
|
||||||
|
.replace(/\s+/g, '_')
|
||||||
|
.replace(/[^A-Za-z0-9._-]/g, '')
|
||||||
|
.slice(0, 40) || 'rec';
|
||||||
|
return `${safe}_${year}${month}${day}_${hours}${minutes}${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize an operator-provided clip name so it's safe as both an S3 key
|
||||||
|
// segment and an SMB/POSIX filename. Allow letters, digits, dot, dash,
|
||||||
|
// underscore, and spaces; collapse runs of whitespace; cap at 80 chars.
|
||||||
|
function sanitizeClipName(raw) {
|
||||||
|
if (typeof raw !== 'string') return null;
|
||||||
|
const cleaned = raw
|
||||||
|
.replace(/[^A-Za-z0-9._\- ]+/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 80);
|
||||||
|
return cleaned.length > 0 ? cleaned : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build Docker PortBindings and ExposedPorts for listener-mode recorders.
|
* Build Docker PortBindings and ExposedPorts for listener-mode recorders.
|
||||||
* Returns { portBindings, exposedPorts } — both empty objects for non-listener sources.
|
|
||||||
*/
|
*/
|
||||||
function buildPortConfig(sourceType, sourceConfig) {
|
function buildPortConfig(sourceType, sourceConfig) {
|
||||||
const portBindings = {};
|
const portBindings = {};
|
||||||
|
|
@ -71,13 +141,79 @@ function buildPortConfig(sourceType, sourceConfig) {
|
||||||
return { portBindings, exposedPorts };
|
return { portBindings, exposedPorts };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whitelist of recorder columns the API accepts on POST/PATCH. Keeping it
|
||||||
|
// explicit prevents accidental writes to status / container_id / timestamps.
|
||||||
|
const RECORDER_FIELDS = [
|
||||||
|
'name', 'source_type', 'source_config',
|
||||||
|
'recording_codec', 'recording_resolution',
|
||||||
|
'recording_video_bitrate', 'recording_framerate',
|
||||||
|
'recording_audio_codec', 'recording_audio_bitrate', 'recording_audio_channels',
|
||||||
|
'recording_container',
|
||||||
|
'proxy_enabled', 'proxy_codec', 'proxy_resolution',
|
||||||
|
'proxy_video_bitrate', 'proxy_framerate',
|
||||||
|
'proxy_audio_codec', 'proxy_audio_bitrate', 'proxy_audio_channels',
|
||||||
|
'proxy_container',
|
||||||
|
'project_id', 'node_id', 'device_index',
|
||||||
|
];
|
||||||
|
|
||||||
|
function pickRecorderFields(body) {
|
||||||
|
const out = {};
|
||||||
|
for (const k of RECORDER_FIELDS) {
|
||||||
|
if (body[k] !== undefined) out[k] = body[k];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// GET / - List all recorders
|
// GET / - List all recorders
|
||||||
|
//
|
||||||
|
// Issue #121 — previous version fired N PG queries + N Docker inspects per
|
||||||
|
// list call. Now we resolve `live_asset_id` for every recording row in a
|
||||||
|
// single LATERAL JOIN, and the Docker `started_at` lookups are bounded by
|
||||||
|
// the number of currently-recording rows (typically <10) and run in
|
||||||
|
// parallel with a per-call timeout from `dockerApi`.
|
||||||
router.get('/', async (req, res, next) => {
|
router.get('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
// Scope to recorders in projects the caller can access (admins unfiltered).
|
||||||
'SELECT * FROM recorders ORDER BY created_at DESC'
|
// Recorders with a NULL project are admin-only and never appear for scoped
|
||||||
);
|
// users (accessibleProjectIds never yields a null id).
|
||||||
res.json(result.rows);
|
const access = await accessibleProjectIds(req.user);
|
||||||
|
let scopeClause = '';
|
||||||
|
const params = [];
|
||||||
|
if (!access.all) {
|
||||||
|
if (access.ids.size === 0) return res.json([]);
|
||||||
|
scopeClause = 'WHERE r.project_id = ANY($1::uuid[])';
|
||||||
|
params.push([...access.ids]);
|
||||||
|
}
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT r.*, la.live_asset_id
|
||||||
|
FROM recorders r
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT a.id AS live_asset_id
|
||||||
|
FROM assets a
|
||||||
|
WHERE r.status = 'recording'
|
||||||
|
AND a.project_id = r.project_id
|
||||||
|
AND a.display_name = r.current_session_id
|
||||||
|
AND a.status = 'live'
|
||||||
|
ORDER BY a.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) la ON TRUE
|
||||||
|
${scopeClause}
|
||||||
|
ORDER BY r.created_at DESC
|
||||||
|
`, params);
|
||||||
|
const rows = result.rows;
|
||||||
|
|
||||||
|
// Only inspect containers for recorders that actually claim to be recording.
|
||||||
|
const inspectable = rows.filter(r => r.status === 'recording' && r.container_id);
|
||||||
|
await Promise.all(inspectable.map(async (r) => {
|
||||||
|
try {
|
||||||
|
const insp = await dockerApi('GET', `/containers/${r.container_id}/json`);
|
||||||
|
if (insp.status === 200 && insp.data && insp.data.State) {
|
||||||
|
r.started_at = insp.data.State.StartedAt;
|
||||||
|
}
|
||||||
|
} catch (_) { /* leave started_at undefined */ }
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
|
|
@ -86,56 +222,51 @@ router.get('/', async (req, res, next) => {
|
||||||
// POST / - Create a new recorder
|
// POST / - Create a new recorder
|
||||||
router.post('/', async (req, res, next) => {
|
router.post('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const b = req.body || {};
|
const fields = pickRecorderFields(req.body);
|
||||||
const name = b.name;
|
|
||||||
const source_type = b.source_type;
|
|
||||||
const source_config = b.source_config;
|
|
||||||
const recording_codec = b.recording_codec || b.codec;
|
|
||||||
const recording_resolution = b.recording_resolution || b.resolution;
|
|
||||||
const proxy_enabled = b.proxy_enabled !== undefined ? b.proxy_enabled : (b.proxy_config ? true : undefined);
|
|
||||||
const proxy_codec = b.proxy_codec || (b.proxy_config && b.proxy_config.codec);
|
|
||||||
const proxy_resolution = b.proxy_resolution || (b.proxy_config && (b.proxy_config.resolution || b.proxy_config.bitrate));
|
|
||||||
const project_id = b.project_id;
|
|
||||||
|
|
||||||
if (!name || !source_type) {
|
if (!fields.name || !fields.source_type) {
|
||||||
return res
|
return res
|
||||||
.status(400)
|
.status(400)
|
||||||
.json({ error: 'Name and source_type are required' });
|
.json({ error: 'Name and source_type are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = uuidv4();
|
// Creating a recorder writes into a project — require edit there. A recorder
|
||||||
|
// with no project_id is admin-only (assertProjectAccess denies non-admins on
|
||||||
|
// a null project).
|
||||||
|
await assertProjectAccess(req.user, fields.project_id ?? null, 'edit');
|
||||||
|
|
||||||
|
// Defaults — written on insert so the DB row is always self-contained.
|
||||||
|
const defaults = {
|
||||||
|
source_config: {},
|
||||||
|
recording_codec: 'hevc_nvenc',
|
||||||
|
recording_resolution: 'native',
|
||||||
|
recording_audio_codec: 'pcm_s24le',
|
||||||
|
recording_audio_channels: 2,
|
||||||
|
recording_container: 'mov',
|
||||||
|
proxy_enabled: true,
|
||||||
|
proxy_codec: 'h264',
|
||||||
|
proxy_resolution: '1920x1080',
|
||||||
|
proxy_video_bitrate: '2M',
|
||||||
|
proxy_audio_codec: 'aac',
|
||||||
|
proxy_audio_bitrate: '128k',
|
||||||
|
proxy_audio_channels: 2,
|
||||||
|
proxy_container: 'mp4',
|
||||||
|
};
|
||||||
|
const row = { id: uuidv4(), status: 'stopped', ...defaults, ...fields };
|
||||||
|
|
||||||
|
// Build INSERT dynamically so adding columns later means one place to update.
|
||||||
|
const cols = Object.keys(row);
|
||||||
|
const placeholders = cols.map((_, i) => `$${i + 1}`).join(', ');
|
||||||
|
const values = cols.map(k => {
|
||||||
|
const v = row[k];
|
||||||
|
if (k === 'source_config') return v && typeof v === 'object' ? v : {};
|
||||||
|
return v;
|
||||||
|
});
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO recorders (
|
`INSERT INTO recorders (${cols.join(', ')}, created_at, updated_at)
|
||||||
id,
|
VALUES (${placeholders}, NOW(), NOW())
|
||||||
name,
|
RETURNING *`,
|
||||||
source_type,
|
values
|
||||||
source_config,
|
|
||||||
recording_codec,
|
|
||||||
recording_resolution,
|
|
||||||
proxy_enabled,
|
|
||||||
proxy_codec,
|
|
||||||
proxy_resolution,
|
|
||||||
project_id,
|
|
||||||
status,
|
|
||||||
created_at,
|
|
||||||
updated_at
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW())
|
|
||||||
RETURNING *`,
|
|
||||||
[
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
source_type,
|
|
||||||
source_config || {},
|
|
||||||
recording_codec || 'prores_hq',
|
|
||||||
recording_resolution || 'native',
|
|
||||||
proxy_enabled !== false,
|
|
||||||
proxy_codec || 'libx264',
|
|
||||||
proxy_resolution || '1920x1080',
|
|
||||||
project_id || null,
|
|
||||||
'stopped',
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
res.status(201).json(result.rows[0]);
|
res.status(201).json(result.rows[0]);
|
||||||
|
|
@ -164,12 +295,51 @@ router.get('/:id', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/start - Start recording
|
// PATCH /:id - Edit recorder settings
|
||||||
router.post('/:id/start', async (req, res, next) => {
|
// Blocked while recorder is actively recording to prevent config drift.
|
||||||
|
router.patch('/:id', requireRecorderEdit, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const recorderResult = await pool.query(
|
||||||
|
'SELECT * FROM recorders WHERE id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (recorderResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Recorder not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const recorder = recorderResult.rows[0];
|
||||||
|
if (recorder.status === 'recording') {
|
||||||
|
return res.status(409).json({ error: 'Cannot edit a recorder while it is recording — stop it first' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = pickRecorderFields(req.body);
|
||||||
|
const cols = Object.keys(fields);
|
||||||
|
if (cols.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'No fields to update' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const setClause = cols.map((k, i) => `${k} = $${i + 1}`).join(', ');
|
||||||
|
const params = cols.map(k => fields[k]);
|
||||||
|
params.push(id);
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE recorders SET ${setClause}, updated_at = NOW() WHERE id = $${params.length} RETURNING *`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /:id/start - Start recording
|
||||||
|
router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
// Get recorder config from DB
|
|
||||||
const recorderResult = await pool.query(
|
const recorderResult = await pool.query(
|
||||||
'SELECT * FROM recorders WHERE id = $1',
|
'SELECT * FROM recorders WHERE id = $1',
|
||||||
[id]
|
[id]
|
||||||
|
|
@ -185,27 +355,67 @@ router.post('/:id/start', async (req, res, next) => {
|
||||||
return res.status(400).json({ error: 'Recorder is already recording' });
|
return res.status(400).json({ error: 'Recorder is already recording' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get S3 config from environment
|
|
||||||
const s3Endpoint = process.env.S3_ENDPOINT;
|
const s3Endpoint = process.env.S3_ENDPOINT;
|
||||||
const s3Bucket = process.env.S3_BUCKET;
|
const s3Bucket = getS3Bucket(); // Use live config, not stale env snapshot (#61)
|
||||||
const s3AccessKey = process.env.S3_ACCESS_KEY;
|
const s3AccessKey = process.env.S3_ACCESS_KEY;
|
||||||
const s3SecretKey = process.env.S3_SECRET_KEY;
|
const s3SecretKey = process.env.S3_SECRET_KEY;
|
||||||
const mamApiUrl = process.env.MAM_API_URL || 'http://mam-api:3000';
|
const mamApiUrl = process.env.MAM_API_URL || 'http://mam-api:3000';
|
||||||
|
const externalMamApiUrl = `http://${process.env.NODE_IP || '172.18.91.200'}:${process.env.PORT_MAM_API || 47432}`;
|
||||||
const dockerNetwork = process.env.DOCKER_NETWORK || 'wild-dragon_wild-dragon';
|
const dockerNetwork = process.env.DOCKER_NETWORK || 'wild-dragon_wild-dragon';
|
||||||
|
|
||||||
// Generate clip name with timestamp
|
// Growing-files mode is a global setting (settings table). When on, the
|
||||||
const clipName = generateClipName(recorder.name);
|
// capture container writes the master to its /growing/ mount instead of
|
||||||
|
// streaming it to S3 — Premiere can mount the SMB share and edit it live.
|
||||||
|
const growingRow = await pool.query(
|
||||||
|
`SELECT value FROM settings WHERE key = 'growing_enabled'`
|
||||||
|
);
|
||||||
|
const growingEnabled =
|
||||||
|
growingRow.rows[0]?.value === 'true' || growingRow.rows[0]?.value === true;
|
||||||
|
|
||||||
|
// Operator-supplied clip name wins over the auto-timestamped fallback.
|
||||||
|
// The Recorders UI passes this on the start request when the user types
|
||||||
|
// something into the "Clip name" field; otherwise it's blank and we
|
||||||
|
// generate `<recorder>_<timestamp>` as before.
|
||||||
|
const customClipName = sanitizeClipName(req.body && req.body.clipName);
|
||||||
|
const clipName = customClipName || generateClipName(recorder.name);
|
||||||
|
|
||||||
|
// Per-take project override: the Recorders UI can pass projectId on the
|
||||||
|
// start request to send clips to a different project than the recorder's
|
||||||
|
// default. Falls back to the recorder's configured project_id.
|
||||||
|
const takeProjectId = (req.body && req.body.projectId && typeof req.body.projectId === 'string')
|
||||||
|
? req.body.projectId
|
||||||
|
: recorder.project_id;
|
||||||
|
|
||||||
|
// requireRecorderEdit only covered the recorder's own project. If this take
|
||||||
|
// is being routed into a DIFFERENT project, the caller must have edit there
|
||||||
|
// too — otherwise edit on recorder A's project would let them write live
|
||||||
|
// assets into any project B.
|
||||||
|
if (takeProjectId !== recorder.project_id) {
|
||||||
|
await assertProjectAccess(req.user, takeProjectId, 'edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
// live-asset: create the asset row right now (status='live') so the
|
||||||
|
// library shows the recording while it is happening.
|
||||||
|
const assetIdLive = uuidv4();
|
||||||
|
try {
|
||||||
|
const ext = recorder.recording_container || 'mov';
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO assets (
|
||||||
|
id, project_id, bin_id, filename, display_name, status, media_type,
|
||||||
|
original_s3_key, created_at, updated_at
|
||||||
|
) VALUES ($1, $2, NULL, $3, $3, 'live', 'video', $4, NOW(), NOW())`,
|
||||||
|
[assetIdLive, takeProjectId, clipName, `projects/${takeProjectId}/masters/${clipName}.${ext}`]
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[recorders] could not pre-create live asset:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
// Determine source config and whether this is a listener-mode recorder
|
|
||||||
const sourceConfig = recorder.source_config || {};
|
const sourceConfig = recorder.source_config || {};
|
||||||
const isListener = sourceConfig.mode === 'listener';
|
const isListener = sourceConfig.mode === 'listener';
|
||||||
const sourceType = recorder.source_type;
|
const sourceType = recorder.source_type;
|
||||||
|
const deviceIndex = recorder.device_index ?? sourceConfig.device ?? 0;
|
||||||
|
|
||||||
// Build port bindings for listener-mode SRT/RTMP containers
|
// Build container env — all codec controls flow through here.
|
||||||
const { portBindings, exposedPorts } = buildPortConfig(sourceType, sourceConfig);
|
|
||||||
|
|
||||||
// Build container environment — pass all source params so the capture
|
|
||||||
// service can auto-start recording on container startup
|
|
||||||
const env = [
|
const env = [
|
||||||
`S3_ENDPOINT=${s3Endpoint}`,
|
`S3_ENDPOINT=${s3Endpoint}`,
|
||||||
`S3_BUCKET=${s3Bucket}`,
|
`S3_BUCKET=${s3Bucket}`,
|
||||||
|
|
@ -216,16 +426,44 @@ router.post('/:id/start', async (req, res, next) => {
|
||||||
`RECORDER_ID=${id}`,
|
`RECORDER_ID=${id}`,
|
||||||
`SOURCE_TYPE=${sourceType}`,
|
`SOURCE_TYPE=${sourceType}`,
|
||||||
`SOURCE_CONFIG=${JSON.stringify(sourceConfig)}`,
|
`SOURCE_CONFIG=${JSON.stringify(sourceConfig)}`,
|
||||||
`RECORDING_CODEC=${recorder.recording_codec}`,
|
`DEVICE_INDEX=${deviceIndex}`,
|
||||||
`RECORDING_RESOLUTION=${recorder.recording_resolution}`,
|
|
||||||
`PROXY_ENABLED=${recorder.proxy_enabled}`,
|
// Recording codec controls
|
||||||
`PROXY_CODEC=${recorder.proxy_codec}`,
|
`RECORDING_CODEC=${recorder.recording_codec || 'prores_hq'}`,
|
||||||
`PROXY_RESOLUTION=${recorder.proxy_resolution}`,
|
`RECORDING_RESOLUTION=${recorder.recording_resolution || 'native'}`,
|
||||||
`PROJECT_ID=${recorder.project_id}`,
|
`RECORDING_VIDEO_BITRATE=${recorder.recording_video_bitrate || ''}`,
|
||||||
|
`RECORDING_FRAMERATE=${recorder.recording_framerate || ''}`,
|
||||||
|
`RECORDING_AUDIO_CODEC=${recorder.recording_audio_codec || 'pcm_s24le'}`,
|
||||||
|
`RECORDING_AUDIO_BITRATE=${recorder.recording_audio_bitrate || ''}`,
|
||||||
|
`RECORDING_AUDIO_CHANNELS=${recorder.recording_audio_channels ?? 2}`,
|
||||||
|
`RECORDING_CONTAINER=${recorder.recording_container || 'mov'}`,
|
||||||
|
|
||||||
|
// Proxy codec controls
|
||||||
|
`PROXY_ENABLED=${recorder.proxy_enabled !== false ? 'true' : 'false'}`,
|
||||||
|
`PROXY_CODEC=${recorder.proxy_codec || 'h264'}`,
|
||||||
|
`PROXY_RESOLUTION=${recorder.proxy_resolution || '1920x1080'}`,
|
||||||
|
`PROXY_VIDEO_BITRATE=${recorder.proxy_video_bitrate || '2M'}`,
|
||||||
|
`PROXY_FRAMERATE=${recorder.proxy_framerate || ''}`,
|
||||||
|
`PROXY_AUDIO_CODEC=${recorder.proxy_audio_codec || 'aac'}`,
|
||||||
|
`PROXY_AUDIO_BITRATE=${recorder.proxy_audio_bitrate || '128k'}`,
|
||||||
|
`PROXY_AUDIO_CHANNELS=${recorder.proxy_audio_channels ?? 2}`,
|
||||||
|
`PROXY_CONTAINER=${recorder.proxy_container || 'mp4'}`,
|
||||||
|
|
||||||
|
`PROJECT_ID=${takeProjectId}`,
|
||||||
`CLIP_NAME=${clipName}`,
|
`CLIP_NAME=${clipName}`,
|
||||||
|
`ASSET_ID=${assetIdLive}`,
|
||||||
|
`MAM_API_TOKEN=${process.env.CAPTURE_TOKEN || ''}`,
|
||||||
|
`GROWING_ENABLED=${growingEnabled ? 'true' : 'false'}`,
|
||||||
|
`GROWING_PATH=/growing`,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add source-specific env vars for SRT/RTMP
|
// Deltacast: pass port count so the capture container can enumerate
|
||||||
|
// test-card slots even without physical /dev/deltacast* nodes.
|
||||||
|
if (sourceType === 'deltacast') {
|
||||||
|
const dcCount = process.env.DELTACAST_PORT_COUNT || sourceConfig.port_count || '';
|
||||||
|
if (dcCount) env.push(`DELTACAST_PORT_COUNT=${dcCount}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (sourceType === 'srt' || sourceType === 'rtmp') {
|
if (sourceType === 'srt' || sourceType === 'rtmp') {
|
||||||
env.push(`LISTEN=${isListener ? '1' : '0'}`);
|
env.push(`LISTEN=${isListener ? '1' : '0'}`);
|
||||||
if (isListener) {
|
if (isListener) {
|
||||||
|
|
@ -238,49 +476,115 @@ router.post('/:id/start', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build container config
|
// GPU-accelerated codecs require the NVIDIA container runtime on the node.
|
||||||
const containerConfig = {
|
// hevc_nvenc / h264_nvenc are the only two we currently support; extend
|
||||||
Image: 'wild-dragon-capture:latest',
|
// this list if av1_nvenc or others are added later.
|
||||||
Env: env,
|
const GPU_CODECS = ['hevc_nvenc', 'h264_nvenc'];
|
||||||
ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined,
|
const useGpu = GPU_CODECS.includes(recorder.recording_codec);
|
||||||
HostConfig: {
|
|
||||||
|
// Determine whether to spawn locally or via a remote node-agent.
|
||||||
|
const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
|
||||||
|
// For remote sidecars, the capture container runs on the worker host network and cannot
|
||||||
|
// resolve the Docker-internal mam-api hostname — replace with the external URL.
|
||||||
|
if (isRemote) {
|
||||||
|
const idx = env.findIndex(e => e.startsWith('MAM_API_URL='));
|
||||||
|
if (idx !== -1) env[idx] = `MAM_API_URL=${externalMamApiUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let containerId;
|
||||||
|
|
||||||
|
if (isRemote) {
|
||||||
|
// Remote node: delegate container lifecycle to that node's agent.
|
||||||
|
const capturePort = SIDECAR_PORT_BASE + (deviceIndex || 0);
|
||||||
|
const sidecarRes = await fetch(`${targetNodeApiUrl}/sidecar/start`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ image: 'wild-dragon-capture:latest', env, capturePort, sourceType, useGpu }),
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
if (!sidecarRes.ok) {
|
||||||
|
// #105 — never proxy the remote node's raw response back to the
|
||||||
|
// browser; it could contain echoed env vars on bad-request paths.
|
||||||
|
const details = await sidecarRes.json().catch(() => ({}));
|
||||||
|
console.error('[recorders] remote sidecar start failed:', JSON.stringify(details));
|
||||||
|
return res.status(502).json({
|
||||||
|
error: 'Remote node failed to start sidecar',
|
||||||
|
details: (details && details.message) || 'see server logs',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const sidecarData = await sidecarRes.json();
|
||||||
|
containerId = sidecarData.containerId;
|
||||||
|
} else {
|
||||||
|
// Local spawn via Docker socket.
|
||||||
|
const { portBindings, exposedPorts } = buildPortConfig(sourceType, sourceConfig);
|
||||||
|
const alias = `recorder-${id}`;
|
||||||
|
|
||||||
|
const hostBinds = ['/mnt/NVME/MAM/wild-dragon-live:/live'];
|
||||||
|
if (sourceType === 'sdi') hostBinds.push('/dev/blackmagic:/dev/blackmagic');
|
||||||
|
if (sourceType === 'deltacast') {
|
||||||
|
// Bind each /dev/deltacast* device node the host has into the container.
|
||||||
|
// The capture service falls back to test-card if none are present.
|
||||||
|
try {
|
||||||
|
const { readdirSync } = await import('node:fs');
|
||||||
|
const dcEntries = readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n));
|
||||||
|
for (const d of dcEntries) hostBinds.push(`/dev/${d}:/dev/${d}`);
|
||||||
|
} catch (_) { /* no /dev/deltacast* nodes on this host */ }
|
||||||
|
}
|
||||||
|
if (growingEnabled) hostBinds.push('/mnt/NVME/MAM/wild-dragon-growing:/growing');
|
||||||
|
|
||||||
|
const localEnv = [...env];
|
||||||
|
if (useGpu) {
|
||||||
|
localEnv.push('NVIDIA_VISIBLE_DEVICES=all');
|
||||||
|
localEnv.push('NVIDIA_DRIVER_CAPABILITIES=video,compute,utility');
|
||||||
|
}
|
||||||
|
|
||||||
|
const localHostConfig = {
|
||||||
Privileged: true,
|
Privileged: true,
|
||||||
NetworkMode: dockerNetwork,
|
NetworkMode: dockerNetwork,
|
||||||
PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined,
|
PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined,
|
||||||
},
|
Binds: hostBinds,
|
||||||
Hostname: `recorder-${recorder.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}`,
|
...(useGpu && {
|
||||||
};
|
Runtime: 'nvidia',
|
||||||
|
DeviceRequests: [{ Driver: 'nvidia', Count: -1, Capabilities: [['gpu']] }],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
// Create container
|
const containerConfig = {
|
||||||
const createRes = await dockerApi('POST', '/containers/create', containerConfig);
|
Image: 'wild-dragon-capture:latest',
|
||||||
|
Env: localEnv,
|
||||||
|
ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined,
|
||||||
|
HostConfig: localHostConfig,
|
||||||
|
NetworkingConfig: {
|
||||||
|
EndpointsConfig: {
|
||||||
|
[dockerNetwork]: { Aliases: [alias] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Hostname: alias,
|
||||||
|
};
|
||||||
|
|
||||||
if (createRes.status !== 201) {
|
const createRes = await dockerApi('POST', '/containers/create', containerConfig);
|
||||||
return res.status(500).json({
|
if (createRes.status !== 201) {
|
||||||
error: 'Failed to create container',
|
// Issue #105 — log the full Docker error server-side, but never echo
|
||||||
details: createRes.data,
|
// the create payload (which contains S3_ACCESS_KEY / STREAM_KEY in
|
||||||
});
|
// Env) back to the client. Send a short, generic message.
|
||||||
}
|
console.error('[recorders] container create failed:', JSON.stringify(createRes.data));
|
||||||
|
return res.status(500).json({
|
||||||
const containerId = createRes.data.Id;
|
error: 'Failed to create container',
|
||||||
|
details: (createRes.data && createRes.data.message) || 'see server logs',
|
||||||
// Start container
|
});
|
||||||
const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
|
}
|
||||||
|
|
||||||
if (startRes.status !== 204) {
|
containerId = createRes.data.Id;
|
||||||
// Clean up the unstarted container so it doesn't accumulate as an orphan
|
const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
|
||||||
// (e.g. when the requested host port is already bound by another process).
|
if (startRes.status !== 204) {
|
||||||
try {
|
console.error('[recorders] container start failed:', JSON.stringify(startRes.data));
|
||||||
await dockerApi('DELETE', `/containers/${containerId}?force=true`);
|
return res.status(500).json({
|
||||||
} catch (cleanupErr) {
|
error: 'Failed to start container',
|
||||||
console.error('Failed to remove unstarted container:', cleanupErr.message);
|
details: (startRes.data && startRes.data.message) || 'see server logs',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return res.status(500).json({
|
|
||||||
error: 'Failed to start container',
|
|
||||||
details: startRes.data,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update recorder in DB
|
|
||||||
const updateResult = await pool.query(
|
const updateResult = await pool.query(
|
||||||
`UPDATE recorders
|
`UPDATE recorders
|
||||||
SET container_id = $1, status = $2, current_session_id = $3, updated_at = NOW()
|
SET container_id = $1, status = $2, current_session_id = $3, updated_at = NOW()
|
||||||
|
|
@ -296,11 +600,10 @@ router.post('/:id/start', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/stop - Stop recording
|
// POST /:id/stop - Stop recording
|
||||||
router.post('/:id/stop', async (req, res, next) => {
|
router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
// Get recorder from DB
|
|
||||||
const recorderResult = await pool.query(
|
const recorderResult = await pool.query(
|
||||||
'SELECT * FROM recorders WHERE id = $1',
|
'SELECT * FROM recorders WHERE id = $1',
|
||||||
[id]
|
[id]
|
||||||
|
|
@ -313,37 +616,55 @@ router.post('/:id/stop', async (req, res, next) => {
|
||||||
const recorder = recorderResult.rows[0];
|
const recorder = recorderResult.rows[0];
|
||||||
|
|
||||||
if (!recorder.container_id) {
|
if (!recorder.container_id) {
|
||||||
return res.status(400).json({ error: 'No container running' });
|
// No container tracked — reset stuck status gracefully.
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE recorders SET container_id = NULL, status = 'stopped', updated_at = NOW()
|
||||||
|
WHERE id = $1 RETURNING *`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return res.json(result.rows[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop container with 5-min grace so SRT/RTMP captures can flush S3 upload
|
const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
|
||||||
const stopRes = await dockerApi(
|
|
||||||
'POST',
|
|
||||||
`/containers/${recorder.container_id}/stop?t=300`
|
|
||||||
);
|
|
||||||
|
|
||||||
// 204 = stopped, 304 = already stopped — both are acceptable
|
if (isRemote) {
|
||||||
if (stopRes.status !== 204 && stopRes.status !== 304) {
|
const stopRes = await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, {
|
||||||
return res.status(500).json({
|
method: 'DELETE',
|
||||||
error: 'Failed to stop container',
|
signal: AbortSignal.timeout(15000),
|
||||||
details: stopRes.data,
|
|
||||||
});
|
});
|
||||||
|
if (!stopRes.ok && stopRes.status !== 404) {
|
||||||
|
return res.status(502).json({ error: 'Remote node failed to stop sidecar' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const stopRes = await dockerApi(
|
||||||
|
'POST',
|
||||||
|
`/containers/${recorder.container_id}/stop`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 204 = stopped, 304 = already stopped, 404 = container gone — all acceptable.
|
||||||
|
if (stopRes.status !== 204 && stopRes.status !== 304 && stopRes.status !== 404) {
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to stop container',
|
||||||
|
details: stopRes.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only attempt remove if the container existed (not 404).
|
||||||
|
if (stopRes.status !== 404) {
|
||||||
|
const removeRes = await dockerApi(
|
||||||
|
'DELETE',
|
||||||
|
`/containers/${recorder.container_id}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (removeRes.status !== 204 && removeRes.status !== 404) {
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to remove container',
|
||||||
|
details: removeRes.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove container — 204 = removed, 404 = already gone (both acceptable)
|
|
||||||
const removeRes = await dockerApi(
|
|
||||||
'DELETE',
|
|
||||||
`/containers/${recorder.container_id}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (removeRes.status !== 204 && removeRes.status !== 404) {
|
|
||||||
return res.status(500).json({
|
|
||||||
error: 'Failed to remove container',
|
|
||||||
details: removeRes.data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update recorder in DB
|
|
||||||
const updateResult = await pool.query(
|
const updateResult = await pool.query(
|
||||||
`UPDATE recorders
|
`UPDATE recorders
|
||||||
SET container_id = NULL, status = $1, updated_at = NOW()
|
SET container_id = NULL, status = $1, updated_at = NOW()
|
||||||
|
|
@ -363,7 +684,6 @@ router.get('/:id/status', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
// Get recorder from DB
|
|
||||||
const recorderResult = await pool.query(
|
const recorderResult = await pool.query(
|
||||||
'SELECT * FROM recorders WHERE id = $1',
|
'SELECT * FROM recorders WHERE id = $1',
|
||||||
[id]
|
[id]
|
||||||
|
|
@ -383,29 +703,67 @@ router.get('/:id/status', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query Docker API for container status
|
const deviceIndex = recorder.device_index ?? (recorder.source_config?.device ?? 0);
|
||||||
const inspectRes = await dockerApi(
|
const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
|
||||||
'GET',
|
|
||||||
`/containers/${recorder.container_id}/json`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (inspectRes.status !== 200) {
|
let isRunning = false;
|
||||||
return res.json({
|
let duration = 0;
|
||||||
status: 'unknown',
|
let signal = 'connecting';
|
||||||
duration: 0,
|
let signalKnown = false;
|
||||||
containerId: recorder.container_id,
|
let live = null;
|
||||||
});
|
|
||||||
|
if (isRemote) {
|
||||||
|
try {
|
||||||
|
const statusRes = await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}/status`, {
|
||||||
|
signal: AbortSignal.timeout(4000),
|
||||||
|
});
|
||||||
|
if (statusRes.ok) {
|
||||||
|
const data = await statusRes.json();
|
||||||
|
isRunning = data.running;
|
||||||
|
if (data.startedAt) {
|
||||||
|
duration = Math.floor((Date.now() - new Date(data.startedAt).getTime()) / 1000);
|
||||||
|
}
|
||||||
|
live = data.live;
|
||||||
|
}
|
||||||
|
} catch (_) { /* node unreachable */ }
|
||||||
|
} else {
|
||||||
|
const inspectRes = await dockerApi(
|
||||||
|
'GET',
|
||||||
|
`/containers/${recorder.container_id}/json`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (inspectRes.status !== 200) {
|
||||||
|
return res.json({
|
||||||
|
status: 'unknown',
|
||||||
|
duration: 0,
|
||||||
|
containerId: recorder.container_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = inspectRes.data;
|
||||||
|
isRunning = container.State.Running;
|
||||||
|
duration = Math.floor((Date.now() - new Date(container.State.StartedAt).getTime()) / 1000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const captureRes = await fetch(`http://recorder-${id}:3001/capture/status`, { signal: AbortSignal.timeout(2000) });
|
||||||
|
if (captureRes.ok) live = await captureRes.json();
|
||||||
|
} catch (_) { /* not ready yet */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = inspectRes.data;
|
if (isRunning) signal = 'receiving';
|
||||||
const startedAt = new Date(container.State.StartedAt).getTime();
|
if (!isRunning) signal = 'stopped';
|
||||||
const now = Date.now();
|
if (live && live.signal) { signal = live.signal; signalKnown = true; }
|
||||||
const duration = Math.floor((now - startedAt) / 1000);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
status: container.State.Running ? 'recording' : 'stopped',
|
status: isRunning ? 'recording' : 'stopped',
|
||||||
duration,
|
duration,
|
||||||
containerId: recorder.container_id,
|
containerId: recorder.container_id,
|
||||||
|
signal,
|
||||||
|
signalKnown,
|
||||||
|
framesReceived: live ? live.framesReceived : null,
|
||||||
|
currentFps: live ? live.currentFps : null,
|
||||||
|
lastFrameAt: live ? live.lastFrameAt : null,
|
||||||
|
lastError: live ? live.lastError : null,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
|
|
@ -413,11 +771,10 @@ router.get('/:id/status', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /:id - Delete recorder
|
// DELETE /:id - Delete recorder
|
||||||
router.delete('/:id', async (req, res, next) => {
|
router.delete('/:id', requireRecorderEdit, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
// Get recorder from DB
|
|
||||||
const recorderResult = await pool.query(
|
const recorderResult = await pool.query(
|
||||||
'SELECT * FROM recorders WHERE id = $1',
|
'SELECT * FROM recorders WHERE id = $1',
|
||||||
[id]
|
[id]
|
||||||
|
|
@ -429,17 +786,23 @@ router.delete('/:id', async (req, res, next) => {
|
||||||
|
|
||||||
const recorder = recorderResult.rows[0];
|
const recorder = recorderResult.rows[0];
|
||||||
|
|
||||||
// If recording, stop the container first
|
|
||||||
if (recorder.container_id) {
|
if (recorder.container_id) {
|
||||||
|
const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
|
||||||
try {
|
try {
|
||||||
await dockerApi('POST', `/containers/${recorder.container_id}/stop`);
|
if (isRemote) {
|
||||||
await dockerApi('DELETE', `/containers/${recorder.container_id}`);
|
await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await dockerApi('POST', `/containers/${recorder.container_id}/stop`);
|
||||||
|
await dockerApi('DELETE', `/containers/${recorder.container_id}`);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error stopping container during delete:', err);
|
console.error('Error stopping container during delete:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete from DB
|
|
||||||
const deleteResult = await pool.query(
|
const deleteResult = await pool.query(
|
||||||
'DELETE FROM recorders WHERE id = $1 RETURNING *',
|
'DELETE FROM recorders WHERE id = $1 RETURNING *',
|
||||||
[id]
|
[id]
|
||||||
|
|
@ -451,4 +814,161 @@ router.delete('/:id', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Issue #104 — limit probe targets so an authed user can't scan the cluster's
|
||||||
|
// internal services (Docker socket, DB, metadata endpoints).
|
||||||
|
const ALLOWED_PROBE_SCHEMES = new Set(['srt', 'rtmp', 'rtmps', 'rtsp', 'udp', 'rtp']);
|
||||||
|
const BLOCKED_PROBE_PORTS = new Set([22, 25, 53, 80, 443, 5432, 6379, 9000, 9100, 9229]);
|
||||||
|
|
||||||
|
function isPrivateOrLoopback(host) {
|
||||||
|
if (!host) return true;
|
||||||
|
const h = host.toLowerCase();
|
||||||
|
if (h === 'localhost' || h.endsWith('.local') || h.endsWith('.internal')) return true;
|
||||||
|
// Hostname lookups happen later by the socket; here we just bail on the
|
||||||
|
// obvious cases. IPv4 private ranges + IPv6 link-local + AWS metadata IP.
|
||||||
|
if (/^127\./.test(h)) return true;
|
||||||
|
if (/^10\./.test(h)) return true;
|
||||||
|
if (/^192\.168\./.test(h)) return true;
|
||||||
|
if (/^172\.(1[6-9]|2[0-9]|3[01])\./.test(h)) return true;
|
||||||
|
if (/^169\.254\./.test(h)) return true; // link-local / AWS metadata
|
||||||
|
if (/^100\.6[4-9]\./.test(h) || /^100\.[7-9]\d\./.test(h) || /^100\.1[0-1]\d\./.test(h) || /^100\.12[0-7]\./.test(h)) return true;
|
||||||
|
if (/^0\./.test(h) || /^::1$/.test(h) || /^fe80:/.test(h) || /^fc/.test(h) || /^fd/.test(h)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdmin(req) {
|
||||||
|
if (process.env.AUTH_ENABLED !== 'true') return true;
|
||||||
|
return req.user?.role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /probe - Probe a source URL for reachability.
|
||||||
|
// Tries the capture service first; falls back to basic TCP/UDP connectivity
|
||||||
|
// check when capture is not running.
|
||||||
|
router.post('/probe', async (req, res) => {
|
||||||
|
const { source_type, url } = req.body || {};
|
||||||
|
|
||||||
|
// Validate URL up-front so we don't even let the capture service see junk.
|
||||||
|
let parsed = null;
|
||||||
|
if (url) {
|
||||||
|
try { parsed = new URL(url); }
|
||||||
|
catch { return res.status(400).json({ error: 'Invalid URL' }); }
|
||||||
|
const proto = (parsed.protocol || '').replace(':', '').toLowerCase();
|
||||||
|
if (!ALLOWED_PROBE_SCHEMES.has(proto)) {
|
||||||
|
return res.status(400).json({ error: `Scheme "${proto}" is not permitted for probe (#104)` });
|
||||||
|
}
|
||||||
|
// Non-admin users can only probe public hostnames. Admins may probe LAN.
|
||||||
|
if (!isAdmin(req) && isPrivateOrLoopback(parsed.hostname)) {
|
||||||
|
return res.status(403).json({ error: 'Probe target must be a public host (#104)' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try the capture service first (5s timeout)
|
||||||
|
try {
|
||||||
|
const r = await fetch('http://capture:3001/capture/probe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(req.body || {}),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
const data = await r.json().catch(() => ({}));
|
||||||
|
return res.status(r.status).json(data);
|
||||||
|
} catch (_) {
|
||||||
|
// capture service not running — fall through to basic connectivity probe
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed) {
|
||||||
|
return res.json({
|
||||||
|
reachable: false,
|
||||||
|
mode: 'basic',
|
||||||
|
note: 'Capture service offline. Provide a URL for connectivity check.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = parsed.hostname;
|
||||||
|
const proto = (parsed.protocol || '').replace(':', '').toLowerCase();
|
||||||
|
const isUdp = proto === 'srt' || source_type === 'srt';
|
||||||
|
const port = parseInt(parsed.port, 10) || (isUdp ? 9000 : 1935);
|
||||||
|
|
||||||
|
if (BLOCKED_PROBE_PORTS.has(port) && !isAdmin(req)) {
|
||||||
|
return res.status(403).json({ error: `Port ${port} is not permitted for probe (#104)` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reachable = await (isUdp ? probeUdp(host, port) : probeTcp(host, port));
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
reachable,
|
||||||
|
mode: 'basic',
|
||||||
|
note: `Capture service offline · ${isUdp ? 'UDP' : 'TCP'} connectivity check only`,
|
||||||
|
...(reachable
|
||||||
|
? { source: `${host}:${port}` }
|
||||||
|
: { error: `${host}:${port} did not respond` }
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function probeTcp(host, port) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const sock = new net.Socket();
|
||||||
|
let done = false;
|
||||||
|
const finish = (ok) => { if (!done) { done = true; sock.destroy(); resolve(ok); } };
|
||||||
|
sock.setTimeout(4000);
|
||||||
|
sock.connect(port, host, () => finish(true));
|
||||||
|
sock.on('error', () => finish(false));
|
||||||
|
sock.on('timeout', () => finish(false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function probeUdp(host, port) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const sock = dgram.createSocket('udp4');
|
||||||
|
let done = false;
|
||||||
|
const finish = (ok) => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
try { sock.close(); } catch (_) {}
|
||||||
|
resolve(ok);
|
||||||
|
};
|
||||||
|
// ICMP port-unreachable will fire sock.on('error') within ~100ms if nothing is listening
|
||||||
|
sock.on('error', () => finish(false));
|
||||||
|
sock.send(Buffer.alloc(16, 0), 0, 16, port, host, (err) => {
|
||||||
|
if (err) return finish(false);
|
||||||
|
// No ICMP error after 2.5s → assume something is listening
|
||||||
|
setTimeout(() => finish(true), 2500);
|
||||||
|
});
|
||||||
|
setTimeout(() => finish(false), 5000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// GET /:id/live/* — reverse-proxy the live HLS preview from the recorder's node.
|
||||||
|
// Remote recorders: segments live on the worker node, served by its node-agent
|
||||||
|
// (/live/...). Local recorders: served from this host's /live mount. Browser
|
||||||
|
// media requests carry the session cookie (same-origin) so auth passes.
|
||||||
|
router.get('/:id/live/:rest(*)', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const rest = req.params.rest;
|
||||||
|
if (!rest || rest.includes('..')) return res.status(400).end();
|
||||||
|
const rec = await pool.query('SELECT node_id FROM recorders WHERE id = $1', [id]);
|
||||||
|
if (rec.rows.length === 0) return res.status(404).json({ error: 'Recorder not found' });
|
||||||
|
|
||||||
|
const ct = rest.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl'
|
||||||
|
: rest.endsWith('.ts') ? 'video/mp2t'
|
||||||
|
: 'application/octet-stream';
|
||||||
|
res.set('Cache-Control', 'no-cache');
|
||||||
|
res.set('Content-Type', ct);
|
||||||
|
|
||||||
|
const target = await resolveNodeTarget(rec.rows[0].node_id);
|
||||||
|
if (!target.remote) {
|
||||||
|
return fs.readFile('/live/' + rest, (err, data) => {
|
||||||
|
if (err) return res.status(404).end();
|
||||||
|
res.end(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const base = String(target.apiUrl).replace(/\/$/, '');
|
||||||
|
const upstream = await fetch(`${base}/live/${rest}`).catch(() => null);
|
||||||
|
if (!upstream || !upstream.ok) return res.status(upstream ? upstream.status : 502).end();
|
||||||
|
res.end(Buffer.from(await upstream.arrayBuffer()));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
157
services/mam-api/src/routes/schedules.js
Normal file
157
services/mam-api/src/routes/schedules.js
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
// Recorder scheduler — CRUD for upcoming + historic recording windows.
|
||||||
|
//
|
||||||
|
// The actual start/stop transitions happen in src/scheduler.js; this route
|
||||||
|
// just owns the recorder_schedules rows.
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import pool from '../db/pool.js';
|
||||||
|
import { validateUuid } from '../middleware/errors.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
|
||||||
|
|
||||||
|
const ALLOWED_RECURRENCE = new Set(['none', 'daily', 'weekly']);
|
||||||
|
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
||||||
|
|
||||||
|
function rowToJson(r) {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
recorder_id: r.recorder_id,
|
||||||
|
recorder_name: r.recorder_name || null,
|
||||||
|
start_at: r.start_at,
|
||||||
|
end_at: r.end_at,
|
||||||
|
recurrence: r.recurrence,
|
||||||
|
status: r.status,
|
||||||
|
last_asset_id: r.last_asset_id,
|
||||||
|
error_message: r.error_message,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_STATUS_FILTER = new Set(['all', 'upcoming', 'past']);
|
||||||
|
|
||||||
|
// GET /api/v1/schedules?status=upcoming|past|all
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const status = (req.query.status || 'all').toLowerCase();
|
||||||
|
if (!ALLOWED_STATUS_FILTER.has(status)) {
|
||||||
|
return res.status(400).json({ error: `status must be one of: ${[...ALLOWED_STATUS_FILTER].join(', ')}` });
|
||||||
|
}
|
||||||
|
let where = 'TRUE';
|
||||||
|
if (status === 'upcoming') where = `(s.status IN ('pending','running') OR s.end_at >= NOW() - INTERVAL '1 hour')`;
|
||||||
|
else if (status === 'past') where = `s.status IN ('completed','failed','cancelled') AND s.end_at < NOW()`;
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT s.*, r.name AS recorder_name
|
||||||
|
FROM recorder_schedules s
|
||||||
|
LEFT JOIN recorders r ON r.id = s.recorder_id
|
||||||
|
WHERE ${where}
|
||||||
|
ORDER BY s.start_at ASC
|
||||||
|
LIMIT 200`
|
||||||
|
);
|
||||||
|
res.json({ schedules: result.rows.map(rowToJson) });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/schedules
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, recorder_id, start_at, end_at, recurrence } = req.body || {};
|
||||||
|
if (!name || !recorder_id || !start_at || !end_at) {
|
||||||
|
return res.status(400).json({ error: 'name, recorder_id, start_at and end_at are required' });
|
||||||
|
}
|
||||||
|
const rec = (recurrence || 'none').toLowerCase();
|
||||||
|
if (!ALLOWED_RECURRENCE.has(rec)) {
|
||||||
|
return res.status(400).json({ error: `recurrence must be one of: ${[...ALLOWED_RECURRENCE].join(', ')}` });
|
||||||
|
}
|
||||||
|
if (new Date(end_at) <= new Date(start_at)) {
|
||||||
|
return res.status(400).json({ error: 'end_at must be after start_at' });
|
||||||
|
}
|
||||||
|
// Make sure the recorder exists before binding to it.
|
||||||
|
const rExists = await pool.query('SELECT id FROM recorders WHERE id = $1', [recorder_id]);
|
||||||
|
if (rExists.rows.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Unknown recorder_id' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ins = await pool.query(
|
||||||
|
`INSERT INTO recorder_schedules (name, recorder_id, start_at, end_at, recurrence, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'pending')
|
||||||
|
RETURNING *`,
|
||||||
|
[name.trim(), recorder_id, start_at, end_at, rec]
|
||||||
|
);
|
||||||
|
res.status(201).json(rowToJson(ins.rows[0]));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/v1/schedules/:id — edit a not-yet-started schedule
|
||||||
|
router.put('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const current = await pool.query('SELECT * FROM recorder_schedules WHERE id = $1', [id]);
|
||||||
|
if (current.rows.length === 0) return res.status(404).json({ error: 'Schedule not found' });
|
||||||
|
if (current.rows[0].status === 'running') {
|
||||||
|
return res.status(400).json({ error: 'Cannot edit a running schedule; cancel it first' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = ['name','start_at','end_at','recurrence'];
|
||||||
|
const updates = [];
|
||||||
|
const values = [];
|
||||||
|
let i = 1;
|
||||||
|
for (const f of fields) {
|
||||||
|
if (req.body[f] !== undefined) {
|
||||||
|
if (f === 'recurrence' && !ALLOWED_RECURRENCE.has(String(req.body[f]).toLowerCase())) {
|
||||||
|
return res.status(400).json({ error: 'invalid recurrence' });
|
||||||
|
}
|
||||||
|
updates.push(`${f} = $${i++}`);
|
||||||
|
values.push(req.body[f]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updates.length === 0) return res.json(rowToJson(current.rows[0]));
|
||||||
|
updates.push('updated_at = NOW()');
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE recorder_schedules SET ${updates.join(', ')} WHERE id = $${i} RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
res.json(rowToJson(result.rows[0]));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/schedules/:id/cancel — cancel a pending or running schedule
|
||||||
|
router.post('/:id/cancel', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const cur = await pool.query('SELECT * FROM recorder_schedules WHERE id = $1', [id]);
|
||||||
|
if (cur.rows.length === 0) return res.status(404).json({ error: 'Schedule not found' });
|
||||||
|
if (TERMINAL.has(cur.rows[0].status)) {
|
||||||
|
return res.status(400).json({ error: `Schedule is already ${cur.rows[0].status}` });
|
||||||
|
}
|
||||||
|
// Just mark as cancelled — the tick loop will stop the recorder if it's
|
||||||
|
// currently running and the schedule has just been cancelled.
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE recorder_schedules SET status = 'cancelled', updated_at = NOW()
|
||||||
|
WHERE id = $1 RETURNING *`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
res.json(rowToJson(result.rows[0]));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/v1/schedules/:id — hard delete (terminal schedules only)
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const cur = await pool.query('SELECT status FROM recorder_schedules WHERE id = $1', [id]);
|
||||||
|
if (cur.rows.length === 0) return res.status(404).json({ error: 'Schedule not found' });
|
||||||
|
if (!TERMINAL.has(cur.rows[0].status) && cur.rows[0].status !== 'pending') {
|
||||||
|
return res.status(400).json({ error: 'Cancel a running schedule before deleting' });
|
||||||
|
}
|
||||||
|
await pool.query('DELETE FROM recorder_schedules WHERE id = $1', [id]);
|
||||||
|
res.json({ message: 'Schedule deleted' });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
208
services/mam-api/src/routes/sdk.js
Normal file
208
services/mam-api/src/routes/sdk.js
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
// Capture SDK deployment — Blackmagic / AJA / Deltacast.
|
||||||
|
//
|
||||||
|
// Vendor SDKs are licensed and not redistributable, so they can't ship in
|
||||||
|
// the repo. This route lets the operator upload an SDK archive through the
|
||||||
|
// Settings UI; we extract it under /sdk/<vendor>/ (bind-mounted into mam-api)
|
||||||
|
// so the capture image build can pick the files up.
|
||||||
|
//
|
||||||
|
// Today the Dockerfile only wires Blackmagic into FFmpeg via patch_decklink.py;
|
||||||
|
// AJA and Deltacast files are staged for the next image revision but don't yet
|
||||||
|
// produce a working FFmpeg build — see the issue tracker.
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import { promises as fs, createWriteStream } from 'fs';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const SDK_ROOT = process.env.SDK_ROOT || '/sdk';
|
||||||
|
|
||||||
|
const VENDORS = {
|
||||||
|
blackmagic: {
|
||||||
|
name: 'Blackmagic DeckLink',
|
||||||
|
// Header that must be present once the archive is extracted
|
||||||
|
sentinel: 'DeckLinkAPI.h',
|
||||||
|
},
|
||||||
|
aja: {
|
||||||
|
name: 'AJA NTV2',
|
||||||
|
sentinel: 'ntv2card.h',
|
||||||
|
},
|
||||||
|
deltacast: {
|
||||||
|
name: 'Deltacast VideoMaster',
|
||||||
|
sentinel: 'VideoMasterHD_Core.h',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
// 512 MB ceiling — Blackmagic's full SDK is ~150 MB, plenty of headroom.
|
||||||
|
limits: { fileSize: 512 * 1024 * 1024 },
|
||||||
|
});
|
||||||
|
|
||||||
|
async function statusFor(vendor) {
|
||||||
|
const dir = path.join(SDK_ROOT, vendor);
|
||||||
|
try {
|
||||||
|
const entries = await listFilesRecursive(dir);
|
||||||
|
if (entries.length === 0) return { file_count: 0, uploaded_at: null };
|
||||||
|
const stat = await fs.stat(dir);
|
||||||
|
const sentinel = VENDORS[vendor].sentinel;
|
||||||
|
const hasSentinel = entries.some(p => p.endsWith('/' + sentinel) || p === sentinel);
|
||||||
|
return {
|
||||||
|
file_count: entries.length,
|
||||||
|
uploaded_at: stat.mtime.toISOString(),
|
||||||
|
sentinel_present: hasSentinel,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { file_count: 0, uploaded_at: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listFilesRecursive(dir, base = '') {
|
||||||
|
let out = [];
|
||||||
|
let entries;
|
||||||
|
try { entries = await fs.readdir(dir, { withFileTypes: true }); }
|
||||||
|
catch { return out; }
|
||||||
|
for (const e of entries) {
|
||||||
|
const full = path.join(dir, e.name);
|
||||||
|
const rel = base ? `${base}/${e.name}` : e.name;
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
out = out.concat(await listFilesRecursive(full, rel));
|
||||||
|
} else if (e.isFile()) {
|
||||||
|
out.push(rel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const out = {};
|
||||||
|
for (const vendor of Object.keys(VENDORS)) {
|
||||||
|
out[vendor] = await statusFor(vendor);
|
||||||
|
}
|
||||||
|
res.json(out);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Safe archive entry — only basic relative paths, no parent traversal, no symlinks.
|
||||||
|
function isUnsafeEntry(rel) {
|
||||||
|
if (!rel) return true;
|
||||||
|
if (path.isAbsolute(rel)) return true;
|
||||||
|
// Normalize without leaving the staging directory.
|
||||||
|
const normalized = path.posix.normalize(rel.replace(/\\/g, '/'));
|
||||||
|
if (normalized.startsWith('..') || normalized.includes('/../') || normalized === '..') return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/:vendor', upload.single('archive'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const vendor = req.params.vendor;
|
||||||
|
if (!VENDORS[vendor]) return res.status(400).json({ error: 'Unknown vendor: ' + vendor });
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'No archive uploaded (field "archive")' });
|
||||||
|
|
||||||
|
const dir = path.join(SDK_ROOT, vendor);
|
||||||
|
const dirReal = path.resolve(dir);
|
||||||
|
|
||||||
|
// Wipe any previous staging so partial uploads don't leave stale headers.
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
|
// Issue #118 — never trust the client-supplied filename. Sanitise to a
|
||||||
|
// basename with no path separators, drop nul bytes, and force into `dir`.
|
||||||
|
const safeName = path.basename((req.file.originalname || 'sdk.bin').replace(/\u0000/g, '')) || 'sdk.bin';
|
||||||
|
const archivePath = path.join(dir, safeName);
|
||||||
|
await fs.writeFile(archivePath, req.file.buffer);
|
||||||
|
|
||||||
|
// Pick an extractor based on extension. tar handles .tar / .tar.gz / .tgz;
|
||||||
|
// unzip handles .zip. The capture container will be built separately on
|
||||||
|
// the host with a DeckLink/AJA/Deltacast card; this route just stages.
|
||||||
|
const lower = safeName.toLowerCase();
|
||||||
|
let cmd, args, listCmd, listArgs;
|
||||||
|
if (lower.endsWith('.zip')) {
|
||||||
|
cmd = 'unzip'; args = ['-q', '-o', archivePath, '-d', dir];
|
||||||
|
listCmd = 'unzip'; listArgs = ['-Z1', archivePath];
|
||||||
|
} else if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz') || lower.endsWith('.tar')) {
|
||||||
|
// --absolute-names=no would be ideal, but isn't portable. Block via
|
||||||
|
// post-extract scan + reject any entry with a parent-traversal path.
|
||||||
|
cmd = 'tar'; args = ['-xf', archivePath, '-C', dir];
|
||||||
|
listCmd = 'tar'; listArgs = ['-tf', archivePath];
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: 'Unsupported archive format — use .zip, .tar.gz, .tgz, or .tar' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-flight: list entries and reject the upload if any escape the dir
|
||||||
|
// (zip-slip / tar-slip). Cheaper than extracting then deleting.
|
||||||
|
const entries = await new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(listCmd, listArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let stdout = '', stderr = '';
|
||||||
|
child.stdout.on('data', d => { stdout += d.toString(); });
|
||||||
|
child.stderr.on('data', d => { stderr += d.toString(); });
|
||||||
|
child.on('error', reject);
|
||||||
|
child.on('exit', code => {
|
||||||
|
if (code === 0) resolve(stdout.split('\n').map(s => s.trim()).filter(Boolean));
|
||||||
|
else reject(new Error(`${listCmd} listing exited ${code}: ${stderr.slice(0, 500)}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const bad = entries.find(isUnsafeEntry);
|
||||||
|
if (bad) {
|
||||||
|
await fs.unlink(archivePath).catch(() => {});
|
||||||
|
return res.status(400).json({ error: `Refusing archive with unsafe entry: ${bad}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let stderr = '';
|
||||||
|
child.stderr.on('data', d => { stderr += d.toString(); });
|
||||||
|
child.on('error', reject);
|
||||||
|
child.on('exit', code => {
|
||||||
|
if (code === 0) resolve();
|
||||||
|
else reject(new Error(`${cmd} exited ${code}: ${stderr.slice(0, 500)}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Defense-in-depth: walk the staged tree and remove anything that's not a
|
||||||
|
// regular file or directory (symlinks/device nodes can still escape).
|
||||||
|
async function walkAndSanitize(p) {
|
||||||
|
const entries = await fs.readdir(p, { withFileTypes: true });
|
||||||
|
for (const e of entries) {
|
||||||
|
const full = path.join(p, e.name);
|
||||||
|
const real = await fs.realpath(full).catch(() => null);
|
||||||
|
if (!real || !real.startsWith(dirReal + path.sep) && real !== dirReal) {
|
||||||
|
await fs.rm(full, { recursive: true, force: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (e.isSymbolicLink() || (!e.isFile() && !e.isDirectory())) {
|
||||||
|
await fs.rm(full, { recursive: true, force: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (e.isDirectory()) await walkAndSanitize(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await walkAndSanitize(dir);
|
||||||
|
|
||||||
|
// Best-effort: remove the archive after a successful extract so we only
|
||||||
|
// keep the unpacked headers/.so files on disk.
|
||||||
|
await fs.unlink(archivePath).catch(() => {});
|
||||||
|
|
||||||
|
const status = await statusFor(vendor);
|
||||||
|
res.json({ message: VENDORS[vendor].name + ' SDK staged.', status });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[sdk] upload failed:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:vendor', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const vendor = req.params.vendor;
|
||||||
|
if (!VENDORS[vendor]) return res.status(400).json({ error: 'Unknown vendor: ' + vendor });
|
||||||
|
const dir = path.join(SDK_ROOT, vendor);
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
res.json({ message: VENDORS[vendor].name + ' SDK cleared.' });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue