Compare commits
683 commits
v0.phase1-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 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 54633 additions and 8977 deletions
50
.env.example
50
.env.example
|
|
@ -22,5 +22,51 @@ SESSION_SECRET=changeme
|
|||
# MAM API Configuration
|
||||
MAM_API_URL=http://mam-api:3000
|
||||
|
||||
# Auth (set to 'true' to require login; false for open/dev mode)
|
||||
AUTH_ENABLED=false
|
||||
# Auth — default to ON in production. Setting to 'false' is a dev-only escape
|
||||
# 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
|
||||
dist/
|
||||
build/
|
||||
# ...but the Premiere panel's packaging pipeline lives at build/ — keep it tracked.
|
||||
!services/premiere-plugin/build/
|
||||
!services/premiere-plugin/build/**
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
.env.swp
|
||||
.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 |
|
||||
|---------|------|-------------|
|
||||
| **web-ui** | 8080 | Browser-based MAM interface + capture controls |
|
||||
| **mam-api** | 3000 | REST API — assets, projects, bins, jobs |
|
||||
| **capture** | 3001 | SDI capture daemon (Blackmagic DeckLink + FFmpeg) |
|
||||
| **worker** | — | Async job processor (proxy gen, thumbnails, conform) |
|
||||
| **db** | 5432 | PostgreSQL 16 metadata store |
|
||||
| **queue** | 6379 | Redis 7 job queue (BullMQ) |
|
||||
## Home Dashboard
|
||||
|
||||
<img src="docs/screenshots/01-home.png" alt="Home Dashboard" width="800" />
|
||||
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Clone
|
||||
git clone https://forge.wilddragon.net/zgaetano/wild-dragon.git
|
||||
cd wild-dragon
|
||||
# Clone (repo renamed; old URL still redirects)
|
||||
git clone https://forge.wilddragon.net/zgaetano/dragonflight.git
|
||||
cd dragonflight
|
||||
|
||||
# Configure
|
||||
cp .env.example .env
|
||||
# Edit .env with your S3 credentials and secrets
|
||||
# Edit .env — S3 credentials + SESSION_SECRET at minimum
|
||||
|
||||
# Launch
|
||||
docker compose up -d
|
||||
|
||||
# Open
|
||||
open http://localhost:8080
|
||||
open http://localhost:47434
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
SDI Input (DeckLink) → capture service → dual FFmpeg streams
|
||||
├─ HiRes (ProRes) → S3
|
||||
└─ Proxy (H.264) → S3
|
||||
↓
|
||||
web-ui ← mam-api ← PostgreSQL ← worker (BullMQ)
|
||||
├─ proxy_gen
|
||||
├─ thumbnail
|
||||
└─ conform (EDL → FFmpeg → export)
|
||||
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
|
||||
|
||||
- **Backend:** Node.js / Express
|
||||
- **Frontend:** Vanilla HTML/CSS/JS
|
||||
- **Database:** PostgreSQL 16
|
||||
- **Queue:** Redis 7 + BullMQ
|
||||
- **Storage:** S3-compatible (RustFS)
|
||||
- **Media Processing:** FFmpeg
|
||||
- **Capture:** Blackmagic DeckLink SDK
|
||||
- **Deployment:** Docker Compose
|
||||
- **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
|
||||
|
||||
## 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
|
||||
|
||||
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_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
ports:
|
||||
- "${PORT_DB:-5432}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./services/mam-api/src/db/schema.sql:/docker-entrypoint-initdb.d/001-schema.sql:ro
|
||||
|
|
@ -18,6 +20,8 @@ services:
|
|||
|
||||
queue:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "${PORT_REDIS:-6379}:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
|
|
@ -34,6 +38,14 @@ services:
|
|||
- "${PORT_MAM_API:-7432}:3000"
|
||||
volumes:
|
||||
- /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:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
REDIS_URL: ${REDIS_URL}
|
||||
|
|
@ -44,7 +56,21 @@ services:
|
|||
S3_REGION: ${S3_REGION:-us-east-1}
|
||||
SESSION_SECRET: ${SESSION_SECRET}
|
||||
AUTH_ENABLED: ${AUTH_ENABLED:-false}
|
||||
TRUST_PROXY: ${TRUST_PROXY:-false}
|
||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-}
|
||||
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:
|
||||
- wild-dragon
|
||||
|
||||
|
|
@ -64,11 +90,23 @@ services:
|
|||
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||
S3_REGION: ${S3_REGION:-us-east-1}
|
||||
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:
|
||||
- wild-dragon
|
||||
|
||||
worker:
|
||||
build: ./services/worker
|
||||
# ── GPU worker pool (capability-routed) ──────────────────────────────
|
||||
# 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:
|
||||
- queue
|
||||
- db
|
||||
|
|
@ -80,6 +118,60 @@ services:
|
|||
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||
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:
|
||||
- wild-dragon
|
||||
|
||||
|
|
@ -87,9 +179,24 @@ services:
|
|||
build: ./services/web-ui
|
||||
ports:
|
||||
- "${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:
|
||||
- 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:
|
||||
postgres_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.
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
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
|
||||
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
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3001
|
||||
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 { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { createUploadStream } from './s3/client.js';
|
||||
|
||||
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 {
|
||||
constructor() {
|
||||
this.state = {
|
||||
|
|
@ -11,6 +114,10 @@ class CaptureManager {
|
|||
sessionId: null,
|
||||
processes: {},
|
||||
currentSession: {},
|
||||
framesReceived: 0,
|
||||
currentFps: 0,
|
||||
lastFrameAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -19,20 +126,19 @@ class CaptureManager {
|
|||
* Returns { inputArgs, isNetwork }
|
||||
* @private
|
||||
*/
|
||||
_buildInputArgs({ sourceType, device, sourceUrl, listen, listenPort, streamKey }) {
|
||||
async _buildInputArgs({ sourceType, device, sourceUrl, listen, listenPort, streamKey }) {
|
||||
if (sourceType === 'srt') {
|
||||
let url;
|
||||
if (listen) {
|
||||
const port = listenPort || 9000;
|
||||
url = `srt://0.0.0.0:${port}?mode=listener`;
|
||||
} else {
|
||||
// Caller mode — ensure mode=caller is appended if not already present
|
||||
url = sourceUrl;
|
||||
if (!url.includes('mode=')) {
|
||||
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') {
|
||||
|
|
@ -40,33 +146,104 @@ class CaptureManager {
|
|||
const port = listenPort || 1935;
|
||||
const key = streamKey || 'stream';
|
||||
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,
|
||||
};
|
||||
}
|
||||
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
|
||||
// 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 {
|
||||
inputArgs: ['-f', 'decklink', '-i', String(device)],
|
||||
inputArgs: ['-f', 'decklink', '-i', deckLinkName],
|
||||
isNetwork: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new capture session
|
||||
* @param {Object} params
|
||||
* - projectId, binId, clipName — always required
|
||||
* - device — DeckLink device index (SDI only)
|
||||
* - 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
|
||||
* Start a new capture session.
|
||||
*
|
||||
* Codec parameters all have sensible defaults so legacy callers (no codec
|
||||
* args) still produce ProRes HQ master + H.264 proxy.
|
||||
*/
|
||||
async start({
|
||||
assetId,
|
||||
projectId,
|
||||
binId,
|
||||
clipName,
|
||||
|
|
@ -76,96 +253,172 @@ class CaptureManager {
|
|||
listen = false,
|
||||
listenPort,
|
||||
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) {
|
||||
throw new Error('Capture already in progress');
|
||||
}
|
||||
|
||||
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.
|
||||
// proxyKey is null for SRT/RTMP — the BullMQ worker generates the proxy
|
||||
// after the recording stops (same pipeline used for uploaded files).
|
||||
const proxyKey = sourceType === 'sdi'
|
||||
? `projects/${projectId}/proxies/${clipName}.mp4`
|
||||
// Growing-files: write master to the local SMB share instead of streaming
|
||||
// to S3. Path is relative to the container's GROWING_PATH mount.
|
||||
const growingPath = GROWING_ENABLED
|
||||
? `${GROWING_PATH}/${projectId}/${clipName}.${hiresExt}`
|
||||
: 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 { inputArgs, isNetwork } = this._buildInputArgs({
|
||||
sourceType,
|
||||
device,
|
||||
sourceUrl,
|
||||
listen,
|
||||
listenPort,
|
||||
streamKey,
|
||||
const { inputArgs, isNetwork } = await this._buildInputArgs({
|
||||
sourceType, device, sourceUrl, listen, listenPort, streamKey,
|
||||
});
|
||||
|
||||
// ProRes hires — fragmented moov for pipe-safe output on network sources
|
||||
const hiresCodecArgs = isNetwork
|
||||
? [
|
||||
'-c:v', 'prores_ks',
|
||||
'-profile:v', '3',
|
||||
'-c:a', 'pcm_s24le',
|
||||
'-movflags', '+frag_keyframe+empty_moov',
|
||||
'-f', 'mov',
|
||||
]
|
||||
: [
|
||||
'-c:v', 'prores_ks',
|
||||
'-profile:v', '3',
|
||||
'-c:a', 'pcm_s24le',
|
||||
'-f', 'mov',
|
||||
];
|
||||
const hiresCodecArgs = buildEncodeArgs({
|
||||
codec: videoCodec, videoBitrate, framerate,
|
||||
audioCodec, audioBitrate, audioChannels,
|
||||
container,
|
||||
isNetwork,
|
||||
isProxy: false,
|
||||
});
|
||||
|
||||
// Spawn hires FFmpeg process
|
||||
const hiresProcess = spawn('ffmpeg', [
|
||||
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,
|
||||
'pipe:1',
|
||||
], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
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 hiresUpload = createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout);
|
||||
const hiresProcess = spawn('ffmpeg', hiresArgs, { stdio: hiresStdio });
|
||||
|
||||
const hiresUpload = growingPath
|
||||
? Promise.resolve({ growingPath })
|
||||
: createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout);
|
||||
|
||||
const processes = { hires: hiresProcess };
|
||||
const uploads = { hires: hiresUpload };
|
||||
|
||||
hiresProcess.stderr.on('data', (data) => {
|
||||
console.error(`[HIRES] ${data}`);
|
||||
});
|
||||
|
||||
// SDI only: spawn a second FFmpeg process for the proxy.
|
||||
// DeckLink cards can be opened simultaneously by multiple processes;
|
||||
// network streams cannot.
|
||||
if (!isNetwork) {
|
||||
const proxyProcess = spawn('ffmpeg', [
|
||||
// ── 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,
|
||||
'-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}`);
|
||||
});
|
||||
'-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) => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// Proxy is generated after stop by the BullMQ worker (same as SRT/RTMP).
|
||||
// DeckLink hardware does not support two concurrent readers on the same port.
|
||||
|
||||
this.state.recording = true;
|
||||
this.state.sessionId = sessionId;
|
||||
this.state.processes = processes;
|
||||
this.state.framesReceived = 0;
|
||||
this.state.currentFps = 0;
|
||||
this.state.lastFrameAt = null;
|
||||
this.state.lastError = null;
|
||||
this.state.currentSession = {
|
||||
sessionId,
|
||||
projectId,
|
||||
|
|
@ -176,19 +429,21 @@ class CaptureManager {
|
|||
sourceUrl,
|
||||
hiresKey,
|
||||
proxyKey,
|
||||
growingPath,
|
||||
startedAt,
|
||||
duration: 0,
|
||||
uploads,
|
||||
codecs: {
|
||||
videoCodec, videoBitrate, framerate,
|
||||
audioCodec, audioBitrate, audioChannels, container,
|
||||
proxyEnabled, proxyVideoCodec, proxyVideoBitrate,
|
||||
proxyAudioCodec, proxyAudioBitrate, proxyAudioChannels, proxyContainer,
|
||||
},
|
||||
};
|
||||
|
||||
return this._formatSessionResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the current capture session
|
||||
* @param {string} sessionId - Session ID to stop
|
||||
* @returns {Object} Completed session info
|
||||
*/
|
||||
async stop(sessionId) {
|
||||
if (!this.state.recording || this.state.sessionId !== sessionId) {
|
||||
throw new Error('No active capture session or session ID mismatch');
|
||||
|
|
@ -196,20 +451,13 @@ class CaptureManager {
|
|||
|
||||
const { processes, currentSession } = this.state;
|
||||
|
||||
// Gracefully terminate all FFmpeg processes
|
||||
if (processes.hires) {
|
||||
processes.hires.kill('SIGINT');
|
||||
}
|
||||
if (processes.proxy) {
|
||||
processes.proxy.kill('SIGINT');
|
||||
}
|
||||
if (processes.hires) processes.hires.kill('SIGINT');
|
||||
if (processes.proxy) processes.proxy.kill('SIGINT');
|
||||
if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} }
|
||||
|
||||
try {
|
||||
// Wait for all in-flight S3 uploads to complete
|
||||
const uploadPromises = [currentSession.uploads.hires];
|
||||
if (currentSession.uploads.proxy) {
|
||||
uploadPromises.push(currentSession.uploads.proxy);
|
||||
}
|
||||
if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy);
|
||||
await Promise.all(uploadPromises);
|
||||
} catch (error) {
|
||||
console.error('Error during upload completion:', error);
|
||||
|
|
@ -220,11 +468,15 @@ class CaptureManager {
|
|||
const stopTime = new Date(stoppedAt);
|
||||
const duration = Math.round((stopTime - startTime) / 1000);
|
||||
|
||||
// Reset state
|
||||
this.state.recording = false;
|
||||
this.state.sessionId = null;
|
||||
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 {
|
||||
sessionId,
|
||||
projectId: currentSession.projectId,
|
||||
|
|
@ -232,28 +484,31 @@ class CaptureManager {
|
|||
clipName: currentSession.clipName,
|
||||
sourceType: currentSession.sourceType,
|
||||
hiresKey: currentSession.hiresKey,
|
||||
proxyKey: currentSession.proxyKey, // null for SRT/RTMP
|
||||
proxyKey: currentSession.proxyKey,
|
||||
growingPath: currentSession.growingPath || null,
|
||||
startedAt: currentSession.startedAt,
|
||||
stoppedAt,
|
||||
duration,
|
||||
framesReceived,
|
||||
empty: framesReceived === 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current capture status
|
||||
* @returns {Object} Current state
|
||||
*/
|
||||
getStatus() {
|
||||
if (!this.state.recording) {
|
||||
return {
|
||||
recording: false,
|
||||
};
|
||||
}
|
||||
if (!this.state.recording) return { recording: false };
|
||||
|
||||
const startTime = new Date(this.state.currentSession.startedAt);
|
||||
const now = new Date();
|
||||
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 {
|
||||
recording: true,
|
||||
sessionId: this.state.sessionId,
|
||||
|
|
@ -264,13 +519,16 @@ class CaptureManager {
|
|||
binId: this.state.currentSession.binId,
|
||||
duration,
|
||||
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() {
|
||||
const { currentSession, sessionId } = this.state;
|
||||
return {
|
||||
|
|
@ -283,8 +541,10 @@ class CaptureManager {
|
|||
hiresKey: currentSession.hiresKey,
|
||||
proxyKey: currentSession.proxyKey,
|
||||
startedAt: currentSession.startedAt,
|
||||
codecs: currentSession.codecs,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptureManager();
|
||||
export { VIDEO_CODECS, AUDIO_CODECS, CONTAINER_FMT, CONTAINER_EXT };
|
||||
|
|
|
|||
|
|
@ -2,25 +2,193 @@ import express from 'express';
|
|||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import captureRoutes from './routes/capture.js';
|
||||
import captureManager from './capture-manager.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';
|
||||
const MAM_API_TOKEN = process.env.MAM_API_TOKEN || '';
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/capture', captureRoutes);
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
const server = app.listen(PORT, () => {
|
||||
console.log(`Wild Dragon Capture Service listening on port ${PORT}`);
|
||||
bootstrapAutoStart();
|
||||
});
|
||||
|
||||
// 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() {
|
||||
const recorderId = process.env.RECORDER_ID;
|
||||
const sourceType = process.env.SOURCE_TYPE;
|
||||
if (!recorderId || !sourceType) {
|
||||
console.log('[bootstrap] no RECORDER_ID/SOURCE_TYPE - on-demand sidecar');
|
||||
return;
|
||||
}
|
||||
|
||||
const projectId = process.env.PROJECT_ID;
|
||||
const clipName = process.env.CLIP_NAME;
|
||||
if (!projectId || !clipName) {
|
||||
console.error('[bootstrap] missing PROJECT_ID or CLIP_NAME - cannot start');
|
||||
return;
|
||||
}
|
||||
|
||||
const listen = process.env.LISTEN === '1' || process.env.LISTEN === 'true';
|
||||
const listenPort = envInt('LISTEN_PORT');
|
||||
const streamKey = envOpt('STREAM_KEY');
|
||||
const sourceUrl = envOpt('SOURCE_URL');
|
||||
const device = envInt('DEVICE_INDEX');
|
||||
|
||||
console.log(`[bootstrap] starting ${sourceType} ingest (listen=${listen} port=${listenPort || 'n/a'})...`);
|
||||
try {
|
||||
const session = await captureManager.start({
|
||||
assetId: envOpt('ASSET_ID') || null,
|
||||
projectId,
|
||||
binId: envOpt('BIN_ID') || null,
|
||||
clipName,
|
||||
device,
|
||||
sourceType,
|
||||
sourceUrl,
|
||||
listen,
|
||||
listenPort,
|
||||
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}`);
|
||||
} catch (err) {
|
||||
console.error('[bootstrap] failed to start capture:', err);
|
||||
}
|
||||
}
|
||||
|
||||
let shuttingDown = false;
|
||||
async function gracefulShutdown(signal) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
console.log(`[shutdown] ${signal} received`);
|
||||
|
||||
const status = captureManager.getStatus();
|
||||
|
||||
if (status.recording) {
|
||||
console.log(`[shutdown] stopping active session ${status.sessionId}...`);
|
||||
try {
|
||||
const completed = await captureManager.stop(status.sessionId);
|
||||
console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s frames=${completed.framesReceived}`);
|
||||
|
||||
const liveAssetId = process.env.ASSET_ID || null;
|
||||
|
||||
// No frames received → the source never connected (bad SRT URL, dead
|
||||
// SDI signal, RTMP stream key mismatch, etc.). The S3 upload at this
|
||||
// point is 0 bytes and would just clog the proxy queue with "moov
|
||||
// atom not found" failures. Mark the pre-created live asset as
|
||||
// 'error' and skip the POST /assets registration entirely.
|
||||
if (completed.empty) {
|
||||
console.warn('[shutdown] no frames received — marking asset as error and skipping registration');
|
||||
if (liveAssetId) {
|
||||
try {
|
||||
await fetch(`${MAM_API_URL}/api/v1/assets/${liveAssetId}/mark-empty`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[shutdown] failed to flag empty asset:', e.message);
|
||||
}
|
||||
}
|
||||
} 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 (err) {
|
||||
console.error('[shutdown] error during stop:', err);
|
||||
}
|
||||
}
|
||||
|
||||
server.close(() => {
|
||||
console.log('[shutdown] http server closed - exiting');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
setTimeout(() => process.exit(0), 5000).unref();
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
|
|
|
|||
|
|
@ -1,7 +1,79 @@
|
|||
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 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';
|
||||
|
|
@ -16,7 +88,7 @@ router.get('/devices', (req, res) => {
|
|||
let output = '';
|
||||
|
||||
try {
|
||||
output = execSync('ffmpeg -f decklink -list_devices 1 -i dummy 2>&1', {
|
||||
output = execSync('ffmpeg -sources decklink 2>&1', {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -24,13 +96,13 @@ router.get('/devices', (req, res) => {
|
|||
output = error.stderr ? error.stderr.toString() : error.toString();
|
||||
}
|
||||
|
||||
// Parse ffmpeg output for DeckLink device names
|
||||
// Format: [decklink @ ...] "DeckLink Quad 2" (input #0)
|
||||
// Parse ffmpeg output for DeckLink device names.
|
||||
// DeckLink source lines: " 81:76669a80:00000000 [DeckLink Duo (1)] (none)"
|
||||
const lines = output.split('\n');
|
||||
let deviceIndex = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\s*\[decklink[^\]]*\]\s+"([^"]+)"/);
|
||||
const match = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
|
||||
if (match) {
|
||||
devices.push({
|
||||
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 current capture status
|
||||
|
|
@ -60,6 +183,103 @@ router.get('/status', (req, res) => {
|
|||
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
|
||||
|
|
|
|||
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
|
||||
COPY package*.json ./
|
||||
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",
|
||||
"version": "0.1.0",
|
||||
"description": "Media Asset Management API for Wild Dragon",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"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": {
|
||||
"express": "^4.18.2",
|
||||
|
|
@ -20,7 +22,9 @@
|
|||
"bullmq": "^5.5.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"uuid": "^9.0.1",
|
||||
"dotenv": "^16.4.5"
|
||||
"dotenv": "^16.4.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"google-auth-library": "^9.14.0"
|
||||
},
|
||||
"engines": {
|
||||
"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,
|
||||
parent_id UUID REFERENCES bins ON DELETE SET NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Assets table
|
||||
|
|
@ -138,7 +139,7 @@ CREATE INDEX idx_bins_project_id ON bins(project_id);
|
|||
CREATE INDEX idx_sessions_expire ON sessions(expire);
|
||||
|
||||
-- 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
|
||||
-- NOTE: current_session_id is TEXT (stores a human-readable clip name, not a UUID)
|
||||
|
|
|
|||
|
|
@ -2,13 +2,19 @@ import 'dotenv/config';
|
|||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
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 { errorHandler } from './middleware/errors.js';
|
||||
import { requireAuth } from './middleware/auth.js';
|
||||
import { requireAuth, requireUiHeader, requireAdmin } from './middleware/auth.js';
|
||||
import { loadS3ConfigFromDb } from './s3/client.js';
|
||||
|
||||
// Routes
|
||||
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 projectsRouter from './routes/projects.js';
|
||||
import binsRouter from './routes/bins.js';
|
||||
|
|
@ -16,72 +22,273 @@ import jobsRouter from './routes/jobs.js';
|
|||
import captureRouter from './routes/capture.js';
|
||||
import uploadRouter from './routes/upload.js';
|
||||
import recordersRouter from './routes/recorders.js';
|
||||
import playoutRouter from './routes/playout.js';
|
||||
import settingsRouter from './routes/settings.js';
|
||||
import amppRouter from './routes/ampp.js';
|
||||
import usersRouter from './routes/users.js';
|
||||
import groupsRouter from './routes/groups.js';
|
||||
import tokensRouter from './routes/tokens.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 PORT = process.env.PORT || 3000;
|
||||
|
||||
// ── Middleware ─────────────────────────────────────────────────────────────────
|
||||
app.use(cors({ origin: true, credentials: true }));
|
||||
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
|
||||
.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' }));
|
||||
|
||||
const PgSession = ConnectPgSimple(session);
|
||||
if (process.env.TRUST_PROXY === 'true') app.set('trust proxy', 1);
|
||||
|
||||
if (process.env.AUTH_ENABLED === 'true') {
|
||||
app.use((req, res, next) => {
|
||||
if (req.secure) res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.AUTH_ENABLED === 'true' && !process.env.SESSION_SECRET) {
|
||||
console.error('[fatal] SESSION_SECRET is required when AUTH_ENABLED=true');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
app.use(session({
|
||||
store: new PgSession({
|
||||
pool,
|
||||
tableName: 'sessions',
|
||||
pruneSessionInterval: 3600,
|
||||
}),
|
||||
secret: process.env.SESSION_SECRET || 'change-me-in-production',
|
||||
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,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
httpOnly: true,
|
||||
maxAge: 1000 * 60 * 60 * 24, // 24 h
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Health (no auth) ──────────────────────────────────────────────────────────
|
||||
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
||||
|
||||
// ── Auth routes (always open) ─────────────────────────────────────────────────
|
||||
const UNAUTH_PATHS = new Set([
|
||||
'/auth/login', '/auth/login/totp', '/auth/setup', '/auth/setup-required',
|
||||
'/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);
|
||||
});
|
||||
|
||||
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/projects', projectsRouter);
|
||||
app.use('/api/v1/bins', binsRouter);
|
||||
app.use('/api/v1/jobs', jobsRouter);
|
||||
app.use('/api/v1/capture', captureRouter);
|
||||
app.use('/api/v1/upload', uploadRouter);
|
||||
app.use('/api/v1/recorders', recordersRouter);
|
||||
app.use('/api/v1/playout', playoutRouter);
|
||||
app.use('/api/v1/settings', settingsRouter);
|
||||
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);
|
||||
|
||||
// ── Protected routes (requireAuth is a no-op unless AUTH_ENABLED=true) ────────
|
||||
app.use('/api/v1/assets', requireAuth, assetsRouter);
|
||||
app.use('/api/v1/projects', requireAuth, projectsRouter);
|
||||
app.use('/api/v1/bins', requireAuth, binsRouter);
|
||||
app.use('/api/v1/jobs', requireAuth, jobsRouter);
|
||||
app.use('/api/v1/capture', requireAuth, captureRouter);
|
||||
app.use('/api/v1/upload', requireAuth, uploadRouter);
|
||||
app.use('/api/v1/recorders', requireAuth, recordersRouter);
|
||||
app.use('/api/v1/settings', requireAuth, settingsRouter);
|
||||
app.use('/api/v1/ampp', requireAuth, amppRouter);
|
||||
|
||||
// ── Admin routes (requireAuth + requireAdmin applied inside each router) ───────
|
||||
app.use('/api/v1/users', usersRouter);
|
||||
app.use('/api/v1/groups', groupsRouter);
|
||||
|
||||
// ── Personal token management ─────────────────────────────────────────────────
|
||||
app.use('/api/v1/tokens', requireAuth, tokensRouter);
|
||||
|
||||
// ── Sequences ─────────────────────────────────────────────────────────────────
|
||||
app.use('/api/v1/sequences', requireAuth, sequencesRouter);
|
||||
|
||||
// ── Error handler ─────────────────────────────────────────────────────────────
|
||||
app.use(errorHandler);
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
app.listen(PORT, () => {
|
||||
const authMode = process.env.AUTH_ENABLED === 'true'
|
||||
? 'ENABLED'
|
||||
: 'DISABLED (set AUTH_ENABLED=true to require login)';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
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(`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,63 +1,134 @@
|
|||
/**
|
||||
* Authentication middleware.
|
||||
*
|
||||
* When AUTH_ENABLED=true in the environment, every protected route requires
|
||||
* either:
|
||||
* - An active session (set by POST /api/v1/auth/login), or
|
||||
* - A valid Bearer token in Authorization header (set by POST /api/v1/tokens)
|
||||
*
|
||||
* When AUTH_ENABLED is unset or any other value, all middleware is a no-op so
|
||||
* the stack can be run without user accounts during development.
|
||||
*/
|
||||
import crypto from 'crypto';
|
||||
import pool from '../db/pool.js';
|
||||
import { parseBearer, hashToken } from '../auth/tokens.js';
|
||||
|
||||
export const requireAuth = async (req, res, next) => {
|
||||
if (process.env.AUTH_ENABLED !== 'true') return next();
|
||||
// In-process service token for the scheduler's loopback self-calls
|
||||
// (scheduler.js -> /recorders|/playout). The scheduler runs in THIS process, so
|
||||
// a per-boot random constant needs no env/compose config and is never exposed:
|
||||
// it only travels over the loopback fetch inside the same process. Multi-replica
|
||||
// is safe because each replica's scheduler only ever calls 127.0.0.1 (itself),
|
||||
// matching that replica's token. Requests bearing it are treated as the seeded
|
||||
// admin (DEV_USER) so RBAC + FK-bearing routes work.
|
||||
export const INTERNAL_TOKEN = crypto.randomBytes(32).toString('hex');
|
||||
const INTERNAL_HEADER = 'x-internal-token';
|
||||
|
||||
// ── Session-based auth ────────────────────────────────────────
|
||||
if (req.session?.userId) {
|
||||
req.user = {
|
||||
id: req.session.userId,
|
||||
username: req.session.username,
|
||||
role: req.session.role,
|
||||
};
|
||||
return next();
|
||||
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));
|
||||
}
|
||||
|
||||
// ── Bearer token auth ─────────────────────────────────────────
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const raw = authHeader.slice(7).trim();
|
||||
const hash = crypto.createHash('sha256').update(raw).digest('hex');
|
||||
try {
|
||||
// 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()));
|
||||
}
|
||||
return res.status(401).json({ error: 'unauthorized' });
|
||||
}
|
||||
|
||||
async function loadUser(id) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT t.user_id AS id, u.username, u.role
|
||||
FROM api_tokens t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
WHERE t.token_hash = $1
|
||||
AND (t.expires_at IS NULL OR t.expires_at > NOW())`,
|
||||
[hash]
|
||||
);
|
||||
if (rows.length > 0) {
|
||||
req.user = rows[0];
|
||||
// Fire-and-forget last_used_at update
|
||||
pool.query(
|
||||
'UPDATE api_tokens SET last_used_at = NOW() WHERE token_hash = $1',
|
||||
[hash]
|
||||
).catch(() => {});
|
||||
`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();
|
||||
}
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
};
|
||||
// 3. Nothing matched
|
||||
return res.status(401).json({ error: 'unauthorized' });
|
||||
}
|
||||
|
||||
export const requireAdmin = (req, res, next) => {
|
||||
if (process.env.AUTH_ENABLED !== 'true') return next();
|
||||
if (req.user?.role === 'admin') return next();
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
};
|
||||
// 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) => {
|
||||
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 message = err.message || 'Internal Server Error';
|
||||
|
||||
res.status(status).json({
|
||||
error: message,
|
||||
// 5xx — never let a raw Error.message escape; clients get a stable shape.
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,142 +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 bcrypt from 'bcrypt';
|
||||
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();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /login
|
||||
// ---------------------------------------------------------------------------
|
||||
router.post('/login', async (req, res, next) => {
|
||||
// Real users = anyone except the seeded dev row.
|
||||
async function realUserCount() {
|
||||
const { rows } = await pool.query(
|
||||
`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 {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM users WHERE username = $1',
|
||||
[username.trim().toLowerCase()]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
// Timing-safe: still run compare on a dummy hash so response time is constant
|
||||
await bcrypt.compare(password, '$2b$12$invalidhashpadding000000000000000000000000000000000000');
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
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) {
|
||||
next(err);
|
||||
}
|
||||
res.json({ required: (await realUserCount()) === 0 });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /logout
|
||||
// ---------------------------------------------------------------------------
|
||||
router.post('/logout', (req, res, next) => {
|
||||
req.session.destroy((err) => {
|
||||
if (err) return next(err);
|
||||
res.clearCookie('connect.sid');
|
||||
res.json({ message: 'Logged out' });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /me
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/me', async (req, res) => {
|
||||
// When auth is disabled return a synthetic guest/admin user so the frontend
|
||||
// auth-guard never receives a 401 and never redirects to login.html.
|
||||
if (process.env.AUTH_ENABLED !== 'true') {
|
||||
return res.json({ id: null, username: 'admin', display_name: 'Admin', role: 'admin' });
|
||||
}
|
||||
const MIN_PASSWORD_LEN = 12;
|
||||
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
try {
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
function badRequest(res, msg) { return res.status(400).json({ error: msg }); }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /setup — one-time first-admin bootstrap
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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, display_name } = req.body;
|
||||
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 (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
if (password.length < 8) {
|
||||
return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||||
if ((await realUserCount()) > 0) {
|
||||
return res.status(409).json({ error: 'setup already complete' });
|
||||
}
|
||||
|
||||
// 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(
|
||||
const hash = await hashPassword(password);
|
||||
const { rows } = 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]
|
||||
VALUES ($1, $2, $1, 'admin')
|
||||
RETURNING id, username, display_name`,
|
||||
[username.trim(), hash]
|
||||
);
|
||||
const user = rows[0];
|
||||
|
||||
res.status(201).json(result.rows[0]);
|
||||
// Immediately log them in.
|
||||
req.session.user_id = user.id;
|
||||
req.session.first_seen_at = Date.now();
|
||||
req.session.last_seen_at = Date.now();
|
||||
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
|
||||
|
||||
res.json({ user });
|
||||
} catch (err) {
|
||||
if (err.code === '23505') return res.status(409).json({ error: 'username already exists' });
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v1/auth/login — authenticate an existing user by username + password.
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
const delay = ipBackoff.delayMs(ip);
|
||||
if (delay > 0) await new Promise(r => setTimeout(r, delay));
|
||||
|
||||
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
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
res.json({
|
||||
id: req.user.id,
|
||||
username: req.user.username,
|
||||
display_name: req.user.display_name,
|
||||
role: req.user.role,
|
||||
totp_enabled: !!req.user.totp_enabled,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/v1/auth/password { current_password, new_password }
|
||||
router.post('/password', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
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');
|
||||
|
||||
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(current_password, rows[0].password_hash))) {
|
||||
return badRequest(res, 'current password is incorrect');
|
||||
}
|
||||
const newHash = await hashPassword(new_password);
|
||||
await pool.query(
|
||||
`UPDATE users SET password_hash = $1, password_updated_at = NOW() WHERE id = $2`,
|
||||
[newHash, req.user.id]
|
||||
);
|
||||
res.status(204).end();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── 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) {
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
// 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 { realUserCount, resolveGoogleUser, consumeRecoveryCode };
|
||||
|
|
|
|||
|
|
@ -1,33 +1,76 @@
|
|||
import express from 'express';
|
||||
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';
|
||||
|
||||
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) => {
|
||||
try {
|
||||
const { project_id } = req.query;
|
||||
|
||||
if (!project_id) {
|
||||
return res.status(400).json({ error: 'project_id is required' });
|
||||
}
|
||||
|
||||
if (project_id) {
|
||||
await assertProjectAccess(req.user, project_id, 'view');
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM bins WHERE project_id = $1 ORDER BY created_at DESC`,
|
||||
`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(
|
||||
`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}
|
||||
ORDER BY b.created_at DESC`,
|
||||
params
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST / - Create bin
|
||||
// POST / - Create bin (requires edit on the target project).
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const { project_id, name, parent_id } = req.body;
|
||||
|
|
@ -35,6 +78,7 @@ router.post('/', async (req, res, next) => {
|
|||
if (!project_id || !name) {
|
||||
return res.status(400).json({ error: 'project_id and name are required' });
|
||||
}
|
||||
await assertProjectAccess(req.user, project_id, 'edit');
|
||||
|
||||
const id = uuidv4();
|
||||
|
||||
|
|
@ -52,7 +96,7 @@ router.post('/', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// PATCH /:id - Update bin
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
router.patch('/:id', requireBinEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, parent_id } = req.body;
|
||||
|
|
@ -98,7 +142,7 @@ router.patch('/:id', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// DELETE /:id - Delete bin
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
router.delete('/:id', requireBinEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
|
|
@ -117,8 +161,8 @@ router.delete('/:id', async (req, res, next) => {
|
|||
}
|
||||
});
|
||||
|
||||
// POST /:id/assets - Add asset to bin
|
||||
router.post('/:id/assets', async (req, res, next) => {
|
||||
// POST /:id/assets - Add asset to bin (requires edit on the bin's project).
|
||||
router.post('/:id/assets', requireBinEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
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' });
|
||||
}
|
||||
|
||||
// Verify bin exists
|
||||
const binCheck = await pool.query('SELECT id FROM bins WHERE id = $1', [id]);
|
||||
if (binCheck.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Bin not found' });
|
||||
// Asset must live in the bin's own project. Without this, an editor in
|
||||
// project A (where the bin lives) could pull an asset from project B (no
|
||||
// grant) into A's bin tree, exposing it in A's views.
|
||||
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
|
||||
|
|
@ -149,8 +196,8 @@ router.post('/:id/assets', async (req, res, next) => {
|
|||
}
|
||||
});
|
||||
|
||||
// DELETE /:id/assets/:assetId - Remove asset from bin
|
||||
router.delete('/:id/assets/:assetId', async (req, res, next) => {
|
||||
// DELETE /:id/assets/:assetId - Remove asset from bin (requires edit).
|
||||
router.delete('/:id/assets/:assetId', requireBinEdit, async (req, res, next) => {
|
||||
try {
|
||||
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 { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
const CAPTURE_URL = process.env.CAPTURE_URL || 'http://capture:3001';
|
||||
|
||||
// Helper to proxy requests
|
||||
const proxyRequest = async (method, path, body = null) => {
|
||||
async function proxyRequest(method, path, body = null) {
|
||||
const options = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
};
|
||||
if (body) options.body = JSON.stringify(body);
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${CAPTURE_URL}${path}`, options);
|
||||
const data = await response.json();
|
||||
return { status: response.status, data };
|
||||
} catch (err) {
|
||||
console.error('Capture service error:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
const text = await response.text();
|
||||
|
||||
// POST /start - Forward start request
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
// Capture service returned non-JSON (HTML error page, plain text, etc.)
|
||||
data = { message: text.slice(0, 300) || '(empty response)' };
|
||||
}
|
||||
|
||||
return { status: response.status, data };
|
||||
}
|
||||
|
||||
// POST /start
|
||||
router.post('/start', async (req, res, next) => {
|
||||
try {
|
||||
const { status, data } = await proxyRequest('POST', '/start', req.body);
|
||||
res.status(status).json(data);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /stop - Forward stop request
|
||||
// POST /stop
|
||||
router.post('/stop', async (req, res, next) => {
|
||||
try {
|
||||
const { status, data } = await proxyRequest('POST', '/stop', req.body);
|
||||
res.status(status).json(data);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /status - Forward status request
|
||||
// GET /status
|
||||
router.get('/status', async (req, res, next) => {
|
||||
try {
|
||||
const { status, data } = await proxyRequest('GET', '/status');
|
||||
res.status(status).json(data);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /devices - Forward devices request
|
||||
// GET /devices
|
||||
router.get('/devices', async (req, res, next) => {
|
||||
try {
|
||||
const { status, data } = await proxyRequest('GET', '/devices');
|
||||
res.status(status).json(data);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
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;
|
||||
|
|
@ -11,10 +11,8 @@
|
|||
*/
|
||||
import express from 'express';
|
||||
import pool from '../db/pool.js';
|
||||
import { requireAuth, requireAdmin } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth, requireAdmin);
|
||||
|
||||
// ── List ──────────────────────────────────────────────────────
|
||||
router.get('/', async (_req, res, next) => {
|
||||
|
|
|
|||
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 pool from '../db/pool.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { Queue } from 'bullmq';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { assertProjectAccess } from '../auth/authz.js';
|
||||
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
const parseRedisUrl = (url) => {
|
||||
|
|
@ -21,12 +24,18 @@ const redisConn = parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379');
|
|||
|
||||
const proxyQueue = new Queue('proxy', { connection: redisConn });
|
||||
const thumbnailQueue = new Queue('thumbnail', { 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 = [
|
||||
{ queue: proxyQueue, type: 'proxy' },
|
||||
{ queue: thumbnailQueue, type: 'thumbnail' },
|
||||
{ queue: filmstripQueue, type: 'filmstrip' },
|
||||
{ queue: conformQueue, type: 'conform' },
|
||||
{ queue: importQueue, type: 'import' },
|
||||
{ queue: trimQueue, type: 'trim' },
|
||||
];
|
||||
|
||||
// BullMQ state → API status mapping
|
||||
|
|
@ -39,6 +48,9 @@ const STATE_MAP = {
|
|||
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) {
|
||||
const isCompleted = apiStatus === 'completed';
|
||||
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() {
|
||||
const results = [];
|
||||
for (const { queue, type } of QUEUES) {
|
||||
for (const [bullState, apiStatus] of Object.entries(STATE_MAP)) {
|
||||
for (const bucket of STATE_BUCKETS) {
|
||||
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) {
|
||||
results.push(normalizeJob(job, type, apiStatus));
|
||||
}
|
||||
} catch {
|
||||
// queue may be empty or unavailable for this state – skip
|
||||
// queue or bucket unavailable — skip
|
||||
}
|
||||
}
|
||||
}
|
||||
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) => {
|
||||
try {
|
||||
const { type, status, asset_id } = req.query;
|
||||
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 (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) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
// id format: "type:bullId" e.g. "proxy:1"
|
||||
const colonIdx = id.indexOf(':');
|
||||
const qType = colonIdx > -1 ? id.slice(0, colonIdx) : null;
|
||||
const bullId = colonIdx > -1 ? id.slice(colonIdx + 1) : id;
|
||||
|
|
@ -108,7 +220,9 @@ router.get('/:id', async (req, res, next) => {
|
|||
if (job) {
|
||||
const state = await job.getState();
|
||||
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 */ }
|
||||
}
|
||||
|
|
@ -118,8 +232,8 @@ router.get('/:id', async (req, res, next) => {
|
|||
}
|
||||
});
|
||||
|
||||
// ── DELETE /:id - Remove a job ────────────────────────────────────────────────
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
// ── POST /:id/retry - Retry a failed job ──────────────────────────────────────
|
||||
router.post('/:id/retry', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const colonIdx = id.indexOf(':');
|
||||
|
|
@ -131,8 +245,8 @@ router.delete('/:id', async (req, res, next) => {
|
|||
try {
|
||||
const job = await queue.getJob(bullId);
|
||||
if (job) {
|
||||
await job.remove();
|
||||
return res.json({ success: true });
|
||||
await job.retry();
|
||||
return res.json({ id, status: 'queued' });
|
||||
}
|
||||
} 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) => {
|
||||
try {
|
||||
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(
|
||||
`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,
|
||||
const bullJob = await conformQueue.add('conform-task', {
|
||||
edl,
|
||||
projectId: project_id,
|
||||
outputFormat: output_format,
|
||||
});
|
||||
|
||||
res.status(201).json(job);
|
||||
res.status(202).json({ id: `conform:${bullJob.id}`, status: 'queued' });
|
||||
} catch (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 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';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
|
||||
|
||||
// Helper function to slugify
|
||||
const slugify = (str) => {
|
||||
|
|
@ -17,18 +18,29 @@ const slugify = (str) => {
|
|||
.replace(/-+/g, '-');
|
||||
};
|
||||
|
||||
// GET / - List all projects
|
||||
// GET / - List projects the caller can access (admins see all).
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
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);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST / - Create project
|
||||
router.post('/', async (req, res, next) => {
|
||||
// POST / - Create project (admin only; new projects have no grants, so a
|
||||
// scoped user could never reach one they just made).
|
||||
router.post('/', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
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) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await assertProjectAccess(req.user, id, 'view');
|
||||
|
||||
const result = await pool.query(
|
||||
`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) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await assertProjectAccess(req.user, id, 'edit');
|
||||
const { name, description } = req.body;
|
||||
|
||||
const updates = [];
|
||||
|
|
@ -123,8 +137,9 @@ router.patch('/:id', async (req, res, next) => {
|
|||
}
|
||||
});
|
||||
|
||||
// DELETE /:id - Delete project and cascade
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
// DELETE /:id - Delete project and cascade (admin only — destructive, wipes
|
||||
// every asset/bin/recorder under it).
|
||||
router.delete('/:id', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,43 @@
|
|||
import express from 'express';
|
||||
import http from 'http';
|
||||
import fs from 'fs';
|
||||
import net from 'net';
|
||||
import dgram from 'dgram';
|
||||
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';
|
||||
|
||||
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
|
||||
function dockerApi(method, path, body = null) {
|
||||
|
|
@ -29,11 +60,31 @@ function dockerApi(method, path, body = null) {
|
|||
});
|
||||
});
|
||||
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));
|
||||
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
|
||||
function generateClipName(recorderName) {
|
||||
const now = new Date();
|
||||
|
|
@ -43,12 +94,31 @@ function generateClipName(recorderName) {
|
|||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).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.
|
||||
* Returns { portBindings, exposedPorts } — both empty objects for non-listener sources.
|
||||
*/
|
||||
function buildPortConfig(sourceType, sourceConfig) {
|
||||
const portBindings = {};
|
||||
|
|
@ -71,13 +141,79 @@ function buildPortConfig(sourceType, sourceConfig) {
|
|||
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
|
||||
//
|
||||
// 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) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM recorders ORDER BY created_at DESC'
|
||||
);
|
||||
res.json(result.rows);
|
||||
// Scope to recorders in projects the caller can access (admins unfiltered).
|
||||
// Recorders with a NULL project are admin-only and never appear for scoped
|
||||
// users (accessibleProjectIds never yields a null id).
|
||||
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) {
|
||||
next(err);
|
||||
}
|
||||
|
|
@ -86,57 +222,51 @@ router.get('/', async (req, res, next) => {
|
|||
// POST / - Create a new recorder
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
source_type,
|
||||
source_config,
|
||||
recording_codec,
|
||||
recording_resolution,
|
||||
proxy_enabled,
|
||||
proxy_codec,
|
||||
proxy_resolution,
|
||||
project_id,
|
||||
} = req.body;
|
||||
const fields = pickRecorderFields(req.body);
|
||||
|
||||
if (!name || !source_type) {
|
||||
if (!fields.name || !fields.source_type) {
|
||||
return res
|
||||
.status(400)
|
||||
.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(
|
||||
`INSERT INTO recorders (
|
||||
id,
|
||||
name,
|
||||
source_type,
|
||||
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())
|
||||
`INSERT INTO recorders (${cols.join(', ')}, created_at, updated_at)
|
||||
VALUES (${placeholders}, 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',
|
||||
]
|
||||
values
|
||||
);
|
||||
|
||||
res.status(201).json(result.rows[0]);
|
||||
|
|
@ -165,12 +295,51 @@ router.get('/:id', async (req, res, next) => {
|
|||
}
|
||||
});
|
||||
|
||||
// POST /:id/start - Start recording
|
||||
router.post('/:id/start', async (req, res, next) => {
|
||||
// PATCH /:id - Edit recorder settings
|
||||
// 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 {
|
||||
const { id } = req.params;
|
||||
|
||||
// Get recorder config from DB
|
||||
const recorderResult = await pool.query(
|
||||
'SELECT * FROM recorders WHERE id = $1',
|
||||
[id]
|
||||
|
|
@ -186,27 +355,67 @@ router.post('/:id/start', async (req, res, next) => {
|
|||
return res.status(400).json({ error: 'Recorder is already recording' });
|
||||
}
|
||||
|
||||
// Get S3 config from environment
|
||||
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 s3SecretKey = process.env.S3_SECRET_KEY;
|
||||
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';
|
||||
|
||||
// Generate clip name with timestamp
|
||||
const clipName = generateClipName(recorder.name);
|
||||
// Growing-files mode is a global setting (settings table). When on, the
|
||||
// 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 isListener = sourceConfig.mode === 'listener';
|
||||
const sourceType = recorder.source_type;
|
||||
const deviceIndex = recorder.device_index ?? sourceConfig.device ?? 0;
|
||||
|
||||
// Build port bindings for listener-mode SRT/RTMP containers
|
||||
const { portBindings, exposedPorts } = buildPortConfig(sourceType, sourceConfig);
|
||||
|
||||
// Build container environment — pass all source params so the capture
|
||||
// service can auto-start recording on container startup
|
||||
// Build container env — all codec controls flow through here.
|
||||
const env = [
|
||||
`S3_ENDPOINT=${s3Endpoint}`,
|
||||
`S3_BUCKET=${s3Bucket}`,
|
||||
|
|
@ -217,16 +426,44 @@ router.post('/:id/start', async (req, res, next) => {
|
|||
`RECORDER_ID=${id}`,
|
||||
`SOURCE_TYPE=${sourceType}`,
|
||||
`SOURCE_CONFIG=${JSON.stringify(sourceConfig)}`,
|
||||
`RECORDING_CODEC=${recorder.recording_codec}`,
|
||||
`RECORDING_RESOLUTION=${recorder.recording_resolution}`,
|
||||
`PROXY_ENABLED=${recorder.proxy_enabled}`,
|
||||
`PROXY_CODEC=${recorder.proxy_codec}`,
|
||||
`PROXY_RESOLUTION=${recorder.proxy_resolution}`,
|
||||
`PROJECT_ID=${recorder.project_id}`,
|
||||
`DEVICE_INDEX=${deviceIndex}`,
|
||||
|
||||
// Recording codec controls
|
||||
`RECORDING_CODEC=${recorder.recording_codec || 'prores_hq'}`,
|
||||
`RECORDING_RESOLUTION=${recorder.recording_resolution || 'native'}`,
|
||||
`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}`,
|
||||
`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') {
|
||||
env.push(`LISTEN=${isListener ? '1' : '0'}`);
|
||||
if (isListener) {
|
||||
|
|
@ -239,42 +476,115 @@ router.post('/:id/start', async (req, res, next) => {
|
|||
}
|
||||
}
|
||||
|
||||
// Build container config
|
||||
const containerConfig = {
|
||||
Image: 'wild-dragon-capture:latest',
|
||||
Env: env,
|
||||
ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined,
|
||||
HostConfig: {
|
||||
// GPU-accelerated codecs require the NVIDIA container runtime on the node.
|
||||
// hevc_nvenc / h264_nvenc are the only two we currently support; extend
|
||||
// this list if av1_nvenc or others are added later.
|
||||
const GPU_CODECS = ['hevc_nvenc', 'h264_nvenc'];
|
||||
const useGpu = GPU_CODECS.includes(recorder.recording_codec);
|
||||
|
||||
// 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,
|
||||
NetworkMode: dockerNetwork,
|
||||
PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined,
|
||||
},
|
||||
Hostname: `recorder-${recorder.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}`,
|
||||
Binds: hostBinds,
|
||||
...(useGpu && {
|
||||
Runtime: 'nvidia',
|
||||
DeviceRequests: [{ Driver: 'nvidia', Count: -1, Capabilities: [['gpu']] }],
|
||||
}),
|
||||
};
|
||||
|
||||
// Create container
|
||||
const createRes = await dockerApi('POST', '/containers/create', containerConfig);
|
||||
const 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,
|
||||
};
|
||||
|
||||
const createRes = await dockerApi('POST', '/containers/create', containerConfig);
|
||||
if (createRes.status !== 201) {
|
||||
// Issue #105 — log the full Docker error server-side, but never echo
|
||||
// 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({
|
||||
error: 'Failed to create container',
|
||||
details: createRes.data,
|
||||
details: (createRes.data && createRes.data.message) || 'see server logs',
|
||||
});
|
||||
}
|
||||
|
||||
const containerId = createRes.data.Id;
|
||||
|
||||
// Start container
|
||||
containerId = createRes.data.Id;
|
||||
const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
|
||||
|
||||
if (startRes.status !== 204) {
|
||||
console.error('[recorders] container start failed:', JSON.stringify(startRes.data));
|
||||
return res.status(500).json({
|
||||
error: 'Failed to start container',
|
||||
details: startRes.data,
|
||||
details: (startRes.data && startRes.data.message) || 'see server logs',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update recorder in DB
|
||||
const updateResult = await pool.query(
|
||||
`UPDATE recorders
|
||||
SET container_id = $1, status = $2, current_session_id = $3, updated_at = NOW()
|
||||
|
|
@ -290,11 +600,10 @@ router.post('/:id/start', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// POST /:id/stop - Stop recording
|
||||
router.post('/:id/stop', async (req, res, next) => {
|
||||
router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Get recorder from DB
|
||||
const recorderResult = await pool.query(
|
||||
'SELECT * FROM recorders WHERE id = $1',
|
||||
[id]
|
||||
|
|
@ -307,24 +616,41 @@ router.post('/:id/stop', async (req, res, next) => {
|
|||
const recorder = recorderResult.rows[0];
|
||||
|
||||
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
|
||||
const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
|
||||
|
||||
if (isRemote) {
|
||||
const stopRes = await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, {
|
||||
method: 'DELETE',
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
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 — both are acceptable
|
||||
if (stopRes.status !== 204 && stopRes.status !== 304) {
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
// Remove container — 204 = removed, 404 = already gone (both acceptable)
|
||||
// Only attempt remove if the container existed (not 404).
|
||||
if (stopRes.status !== 404) {
|
||||
const removeRes = await dockerApi(
|
||||
'DELETE',
|
||||
`/containers/${recorder.container_id}`
|
||||
|
|
@ -336,8 +662,9 @@ router.post('/:id/stop', async (req, res, next) => {
|
|||
details: removeRes.data,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update recorder in DB
|
||||
const updateResult = await pool.query(
|
||||
`UPDATE recorders
|
||||
SET container_id = NULL, status = $1, updated_at = NOW()
|
||||
|
|
@ -357,7 +684,6 @@ router.get('/:id/status', async (req, res, next) => {
|
|||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Get recorder from DB
|
||||
const recorderResult = await pool.query(
|
||||
'SELECT * FROM recorders WHERE id = $1',
|
||||
[id]
|
||||
|
|
@ -377,7 +703,30 @@ 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 { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
|
||||
|
||||
let isRunning = false;
|
||||
let duration = 0;
|
||||
let signal = 'connecting';
|
||||
let signalKnown = false;
|
||||
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`
|
||||
|
|
@ -392,14 +741,29 @@ router.get('/:id/status', async (req, res, next) => {
|
|||
}
|
||||
|
||||
const container = inspectRes.data;
|
||||
const startedAt = new Date(container.State.StartedAt).getTime();
|
||||
const now = Date.now();
|
||||
const duration = Math.floor((now - startedAt) / 1000);
|
||||
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 */ }
|
||||
}
|
||||
|
||||
if (isRunning) signal = 'receiving';
|
||||
if (!isRunning) signal = 'stopped';
|
||||
if (live && live.signal) { signal = live.signal; signalKnown = true; }
|
||||
|
||||
res.json({
|
||||
status: container.State.Running ? 'recording' : 'stopped',
|
||||
status: isRunning ? 'recording' : 'stopped',
|
||||
duration,
|
||||
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) {
|
||||
next(err);
|
||||
|
|
@ -407,11 +771,10 @@ router.get('/:id/status', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// DELETE /:id - Delete recorder
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
router.delete('/:id', requireRecorderEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Get recorder from DB
|
||||
const recorderResult = await pool.query(
|
||||
'SELECT * FROM recorders WHERE id = $1',
|
||||
[id]
|
||||
|
|
@ -423,17 +786,23 @@ router.delete('/:id', async (req, res, next) => {
|
|||
|
||||
const recorder = recorderResult.rows[0];
|
||||
|
||||
// If recording, stop the container first
|
||||
if (recorder.container_id) {
|
||||
const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
|
||||
try {
|
||||
if (isRemote) {
|
||||
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) {
|
||||
console.error('Error stopping container during delete:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from DB
|
||||
const deleteResult = await pool.query(
|
||||
'DELETE FROM recorders WHERE id = $1 RETURNING *',
|
||||
[id]
|
||||
|
|
@ -445,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;
|
||||
|
|
|
|||
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;
|
||||
|
|
@ -2,38 +2,126 @@
|
|||
import express from 'express';
|
||||
import pool from '../db/pool.js';
|
||||
import { getSignedUrlForObject } from '../s3/client.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { validateUuid } from '../middleware/errors.js';
|
||||
import { assertProjectAccess } from '../auth/authz.js';
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
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 conformQueue = new Queue('conform', {
|
||||
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
|
||||
});
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// ── 59.94 DF timecode helpers (for EDL export) ────────────────────────────────
|
||||
const NOM = 60; // nominal integer fps
|
||||
const DROP = 4; // frames dropped per minute (except every 10th)
|
||||
const FRAMES_PER_MIN = NOM * 60 - DROP; // 3596
|
||||
const FRAMES_PER_10MIN = FRAMES_PER_MIN * 10 + DROP; // 35964
|
||||
const FRAMES_PER_HOUR = FRAMES_PER_10MIN * 6; // 215784
|
||||
// Scope every /:id sequence route to its project: validate the UUID, resolve
|
||||
// project_id, assert the 'view' baseline; mutators escalate via requireSequenceEdit.
|
||||
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 sequences WHERE id = $1', [req.params.id]);
|
||||
if (rows.length === 0) return res.status(404).json({ error: 'Sequence not found' });
|
||||
req.sequenceProjectId = rows[0].project_id;
|
||||
await assertProjectAccess(req.user, req.sequenceProjectId, 'view');
|
||||
next();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
async function requireSequenceEdit(req, res, next) {
|
||||
try {
|
||||
await assertProjectAccess(req.user, req.sequenceProjectId, 'edit');
|
||||
next();
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
// ── Row mapper ────────────────────────────────────────────────────────────────
|
||||
// node-postgres returns NUMERIC columns as strings. Coerce frame_rate to a
|
||||
// JS float before sending any sequence object to clients.
|
||||
|
||||
function mapSeq(row) {
|
||||
if (!row) return row;
|
||||
return { ...row, frame_rate: parseFloat(row.frame_rate) || 59.94 };
|
||||
}
|
||||
|
||||
// ── Timecode helpers ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// generateEDL emits CMX3600 timecode using the sequence's frame_rate.
|
||||
//
|
||||
// 29.97 fps → NTSC drop-frame (30 NOM, drop 2/min except every 10th) → ";"
|
||||
// 59.94 fps → double-NTSC DF (60 NOM, drop 4/min except every 10th) → ";"
|
||||
// all others → non-drop integer (24/25/30/50/60 …) → ":"
|
||||
//
|
||||
|
||||
function pad2(n) { return String(Math.floor(n)).padStart(2, '0'); }
|
||||
|
||||
function framesToTC(totalFrames) {
|
||||
function framesToTC(totalFrames, fps) {
|
||||
fps = parseFloat(fps) || 59.94;
|
||||
const fc = Math.max(0, Math.round(totalFrames));
|
||||
const h = Math.floor(fc / FRAMES_PER_HOUR);
|
||||
let rem = fc % FRAMES_PER_HOUR;
|
||||
const tm = Math.floor(rem / FRAMES_PER_10MIN);
|
||||
rem = rem % FRAMES_PER_10MIN;
|
||||
let m = 0;
|
||||
if (rem >= DROP) {
|
||||
m = Math.floor((rem - DROP) / FRAMES_PER_MIN) + 1;
|
||||
rem = (rem - DROP) % FRAMES_PER_MIN;
|
||||
|
||||
// 29.97 DF ─ drop 2 frames per minute except every 10th
|
||||
if (Math.abs(fps - 29.97) < 0.02) {
|
||||
const NOM = 30, DROP = 2;
|
||||
const FPM = NOM * 60 - DROP; // 1798
|
||||
const FP10M = FPM * 10 + DROP; // 17982
|
||||
const FPH = FP10M * 6; // 107892
|
||||
const h = Math.floor(fc / FPH);
|
||||
let rem = fc % FPH;
|
||||
const tm = Math.floor(rem / FP10M);
|
||||
rem = rem % FP10M;
|
||||
let m, ss, ff;
|
||||
if (rem < NOM * 60) {
|
||||
m = 0; ss = Math.floor(rem / NOM); ff = rem % NOM;
|
||||
} else {
|
||||
rem -= NOM * 60;
|
||||
m = Math.floor(rem / FPM) + 1;
|
||||
const adj = (rem % FPM) + DROP;
|
||||
ss = Math.floor(adj / NOM); ff = adj % NOM;
|
||||
}
|
||||
const M = tm * 10 + m;
|
||||
const s = Math.floor(rem / NOM);
|
||||
const ff = rem % NOM;
|
||||
return `${pad2(h)}:${pad2(M)}:${pad2(s)};${pad2(ff)}`;
|
||||
return `${pad2(h)}:${pad2(tm * 10 + m)}:${pad2(ss)};${pad2(ff)}`;
|
||||
}
|
||||
|
||||
function generateEDL(seqName, clips) {
|
||||
// 59.94 DF ─ drop 4 frames per minute except every 10th
|
||||
if (Math.abs(fps - 59.94) < 0.02) {
|
||||
const NOM = 60, DROP = 4;
|
||||
const FPM = NOM * 60 - DROP; // 3596
|
||||
const FP10M = FPM * 10 + DROP; // 35964
|
||||
const FPH = FP10M * 6; // 215784
|
||||
const h = Math.floor(fc / FPH);
|
||||
let rem = fc % FPH;
|
||||
const tm = Math.floor(rem / FP10M);
|
||||
rem = rem % FP10M;
|
||||
let m, ss, ff;
|
||||
if (rem < NOM * 60) {
|
||||
m = 0; ss = Math.floor(rem / NOM); ff = rem % NOM;
|
||||
} else {
|
||||
rem -= NOM * 60;
|
||||
m = Math.floor(rem / FPM) + 1;
|
||||
const adj = (rem % FPM) + DROP;
|
||||
ss = Math.floor(adj / NOM); ff = adj % NOM;
|
||||
}
|
||||
return `${pad2(h)}:${pad2(tm * 10 + m)}:${pad2(ss)};${pad2(ff)}`;
|
||||
}
|
||||
|
||||
// Non-drop frame (24, 23.976→24, 25, 30, 50, 60 …) ─ colon separator
|
||||
const nomFps = Math.round(fps);
|
||||
const ff = fc % nomFps;
|
||||
const totalSec = Math.floor(fc / nomFps);
|
||||
const ss = totalSec % 60;
|
||||
const mm = Math.floor(totalSec / 60) % 60;
|
||||
const hh = Math.floor(totalSec / 3600);
|
||||
return `${pad2(hh)}:${pad2(mm)}:${pad2(ss)}:${pad2(ff)}`;
|
||||
}
|
||||
|
||||
function generateEDL(seqName, clips, fps) {
|
||||
fps = parseFloat(fps) || 59.94;
|
||||
const lines = [`TITLE: ${seqName}`, ''];
|
||||
clips.forEach((c, i) => {
|
||||
const num = String(i + 1).padStart(3, '0');
|
||||
|
|
@ -43,10 +131,10 @@ function generateEDL(seqName, clips) {
|
|||
.toUpperCase()
|
||||
.substring(0, 32)
|
||||
.padEnd(8);
|
||||
const srcIn = framesToTC(c.source_in_frames);
|
||||
const srcOut = framesToTC(c.source_out_frames);
|
||||
const recIn = framesToTC(c.timeline_in_frames);
|
||||
const recOut = framesToTC(c.timeline_out_frames);
|
||||
const srcIn = framesToTC(c.source_in_frames, fps);
|
||||
const srcOut = framesToTC(c.source_out_frames, fps);
|
||||
const recIn = framesToTC(c.timeline_in_frames, fps);
|
||||
const recOut = framesToTC(c.timeline_out_frames, fps);
|
||||
lines.push(`${num} ${reel} V C ${srcIn} ${srcOut} ${recIn} ${recOut}`);
|
||||
});
|
||||
return lines.join('\n');
|
||||
|
|
@ -57,11 +145,12 @@ router.get('/', async (req, res, next) => {
|
|||
try {
|
||||
const { project_id } = req.query;
|
||||
if (!project_id) return res.status(400).json({ error: 'project_id is required' });
|
||||
await assertProjectAccess(req.user, project_id, 'view');
|
||||
const r = await pool.query(
|
||||
`SELECT * FROM sequences WHERE project_id = $1 ORDER BY updated_at DESC`,
|
||||
[project_id]
|
||||
);
|
||||
res.json(r.rows);
|
||||
res.json(r.rows.map(mapSeq));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
|
|
@ -76,12 +165,13 @@ router.post('/', async (req, res, next) => {
|
|||
height = 1080,
|
||||
} = req.body;
|
||||
if (!project_id) return res.status(400).json({ error: 'project_id is required' });
|
||||
await assertProjectAccess(req.user, project_id, 'edit');
|
||||
const r = await pool.query(
|
||||
`INSERT INTO sequences (project_id, name, frame_rate, width, height)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[project_id, name, frame_rate, width, height]
|
||||
);
|
||||
res.status(201).json(r.rows[0]);
|
||||
res.status(201).json(mapSeq(r.rows[0]));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
|
|
@ -116,12 +206,12 @@ router.get('/:id', async (req, res, next) => {
|
|||
})
|
||||
);
|
||||
|
||||
res.json({ ...seqR.rows[0], clips });
|
||||
res.json({ ...mapSeq(seqR.rows[0]), clips });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── PUT /:id – update sequence metadata ──────────────────────────────────────
|
||||
router.put('/:id', async (req, res, next) => {
|
||||
router.put('/:id', requireSequenceEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { name, frame_rate, width, height } = req.body;
|
||||
const updates = [];
|
||||
|
|
@ -139,12 +229,12 @@ router.put('/:id', async (req, res, next) => {
|
|||
params
|
||||
);
|
||||
if (!r.rows.length) return res.status(404).json({ error: 'Sequence not found' });
|
||||
res.json(r.rows[0]);
|
||||
res.json(mapSeq(r.rows[0]));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── DELETE /:id ───────────────────────────────────────────────────────────────
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
router.delete('/:id', requireSequenceEdit, async (req, res, next) => {
|
||||
try {
|
||||
const r = await pool.query(`DELETE FROM sequences WHERE id = $1`, [req.params.id]);
|
||||
if (!r.rowCount) return res.status(404).json({ error: 'Sequence not found' });
|
||||
|
|
@ -153,12 +243,14 @@ router.delete('/:id', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// ── PUT /:id/clips – full replace of clip array (single transaction) ──────────
|
||||
router.put('/:id/clips', async (req, res, next) => {
|
||||
// Verify sequence exists first (before acquiring transaction client)
|
||||
router.put('/:id/clips', requireSequenceEdit, async (req, res, next) => {
|
||||
const clips = Array.isArray(req.body) ? req.body : [];
|
||||
let client;
|
||||
try {
|
||||
// Verify sequence exists first (before acquiring transaction client).
|
||||
const seqCheck = await pool.query(`SELECT id FROM sequences WHERE id = $1`, [req.params.id]);
|
||||
if (!seqCheck.rows.length) return res.status(404).json({ error: 'Sequence not found' });
|
||||
|
||||
const clips = Array.isArray(req.body) ? req.body : [];
|
||||
for (const c of clips) {
|
||||
if (!c.asset_id) return res.status(400).json({ error: 'Each clip must have asset_id' });
|
||||
if (!Number.isFinite(Number(c.timeline_in_frames)) || !Number.isFinite(Number(c.timeline_out_frames)) ||
|
||||
|
|
@ -170,8 +262,22 @@ router.put('/:id/clips', async (req, res, next) => {
|
|||
}
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
// Every referenced asset must belong to THIS sequence's project. Without this,
|
||||
// a user with edit on the sequence could splice in assets from a project they
|
||||
// can't access — and GET /:id would then hand back those assets' names and
|
||||
// signed proxy URLs (cross-project leak).
|
||||
const assetIds = [...new Set(clips.map(c => c.asset_id))];
|
||||
if (assetIds.length) {
|
||||
const owning = await pool.query(
|
||||
`SELECT id FROM assets WHERE id = ANY($1::uuid[]) AND project_id = $2`,
|
||||
[assetIds, req.sequenceProjectId]
|
||||
);
|
||||
if (owning.rows.length !== assetIds.length) {
|
||||
return res.status(400).json({ error: 'All clip assets must belong to the sequence\'s project' });
|
||||
}
|
||||
}
|
||||
|
||||
client = await pool.connect();
|
||||
await client.query('BEGIN');
|
||||
await client.query(
|
||||
`DELETE FROM sequence_clips WHERE sequence_id = $1`,
|
||||
|
|
@ -198,10 +304,12 @@ router.put('/:id/clips', async (req, res, next) => {
|
|||
await client.query('COMMIT');
|
||||
res.json({ ok: true, count: clips.length });
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
// client is only set once we've connected; a failure in the pre-transaction
|
||||
// queries (existence/validation/ownership) has no transaction to roll back.
|
||||
if (client) await client.query('ROLLBACK').catch(() => {});
|
||||
next(e);
|
||||
} finally {
|
||||
client.release();
|
||||
if (client) client.release();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -210,7 +318,7 @@ router.post('/:id/export/edl', async (req, res, next) => {
|
|||
try {
|
||||
const seqR = await pool.query(`SELECT * FROM sequences WHERE id = $1`, [req.params.id]);
|
||||
if (!seqR.rows.length) return res.status(404).json({ error: 'Sequence not found' });
|
||||
const seq = seqR.rows[0];
|
||||
const seq = mapSeq(seqR.rows[0]);
|
||||
|
||||
// Export V1 clips only (primary video track) sorted by position
|
||||
const clipsR = await pool.query(
|
||||
|
|
@ -222,7 +330,7 @@ router.post('/:id/export/edl', async (req, res, next) => {
|
|||
[req.params.id]
|
||||
);
|
||||
|
||||
const edl = generateEDL(seq.name, clipsR.rows);
|
||||
const edl = generateEDL(seq.name, clipsR.rows, seq.frame_rate);
|
||||
const filename = `${seq.name.replace(/[^a-z0-9]/gi, '_')}.edl`;
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
|
|
@ -230,4 +338,44 @@ router.post('/:id/export/edl', async (req, res, next) => {
|
|||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── POST /:id/conform – conform sequence via FCP XML ─────────────────────────
|
||||
// Accepts FCP XML content and encode settings from the Premiere plugin,
|
||||
// queues a conform job in BullMQ, and returns the job ID for polling.
|
||||
router.post('/:id/conform', requireSequenceEdit, async (req, res, next) => {
|
||||
try {
|
||||
const seqR = await pool.query(`SELECT * FROM sequences WHERE id = $1`, [req.params.id]);
|
||||
if (!seqR.rows.length) return res.status(404).json({ error: 'Sequence not found' });
|
||||
const seq = mapSeq(seqR.rows[0]);
|
||||
|
||||
const { fcp_xml, codec = 'h264', quality = 'high', resolution = 'match', audio = 'include' } = req.body;
|
||||
|
||||
if (!fcp_xml) {
|
||||
return res.status(400).json({ error: 'fcp_xml is required' });
|
||||
}
|
||||
|
||||
const bullJob = await conformQueue.add('conform-task', {
|
||||
fcpXml: fcp_xml,
|
||||
sequenceId: req.params.id,
|
||||
// The worker INSERTs the rendered output into the `assets` table at the
|
||||
// end of the pipeline; project_id is NOT NULL on that table, so without
|
||||
// this the conform finished successfully but failed at the very last
|
||||
// step. Sequences live under projects, so the natural target for the
|
||||
// rendered output is the sequence's own project.
|
||||
projectId: seq.project_id,
|
||||
sequenceName: seq.name,
|
||||
frameRate: seq.frame_rate,
|
||||
width: seq.width,
|
||||
height: seq.height,
|
||||
codec,
|
||||
quality,
|
||||
resolution,
|
||||
audio,
|
||||
});
|
||||
|
||||
res.status(202).json({ id: `conform:${bullJob.id}`, status: 'queued' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,127 @@
|
|||
import express from 'express';
|
||||
import pool from '../db/pool.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { getAmppConfig } from '../ampp/client.js';
|
||||
import { rebuildS3Client, buildTestClient, testS3Connection, getS3Bucket } from '../s3/client.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// GET /api/v1/settings/ampp — Return current AMPP config (token value masked)
|
||||
// ── S3 / Object Storage ───────────────────────────────────────────────────────
|
||||
|
||||
router.get('/s3', async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT key, value FROM settings
|
||||
WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')`
|
||||
);
|
||||
// Env defaults — surface what's actually wired so the UI doesn't show
|
||||
// "not configured" when the container was started with S3_* env vars.
|
||||
const envEndpoint = process.env.S3_ENDPOINT || '';
|
||||
const envBucket = process.env.S3_BUCKET || getS3Bucket();
|
||||
const envAccessKey = process.env.S3_ACCESS_KEY || '';
|
||||
const envSecretKey = process.env.S3_SECRET_KEY || '';
|
||||
const envRegion = process.env.S3_REGION || 'us-east-1';
|
||||
|
||||
const out = {
|
||||
s3_endpoint: envEndpoint,
|
||||
s3_bucket: envBucket,
|
||||
s3_access_key: envAccessKey,
|
||||
s3_secret_key_exists: !!envSecretKey,
|
||||
s3_region: envRegion,
|
||||
source: envEndpoint ? 'env' : 'unset',
|
||||
};
|
||||
for (const { key, value } of result.rows) {
|
||||
if (key === 's3_secret_key') {
|
||||
if (value) { out.s3_secret_key_exists = true; out.source = 'db'; }
|
||||
} else if (value) {
|
||||
out[key] = value;
|
||||
out.source = 'db';
|
||||
}
|
||||
}
|
||||
res.json(out);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/s3', async (req, res, next) => {
|
||||
try {
|
||||
const { s3_endpoint, s3_bucket, s3_access_key, s3_secret_key, s3_region } = req.body;
|
||||
|
||||
if (!s3_endpoint) return res.status(400).json({ error: 's3_endpoint is required' });
|
||||
if (!s3_bucket) return res.status(400).json({ error: 's3_bucket is required' });
|
||||
|
||||
const keys = { s3_endpoint, s3_bucket, s3_region: s3_region || 'us-east-1' };
|
||||
if (s3_access_key) keys.s3_access_key = s3_access_key;
|
||||
if (s3_secret_key) keys.s3_secret_key = s3_secret_key;
|
||||
|
||||
for (const [key, value] of Object.entries(keys)) {
|
||||
await pool.query(
|
||||
`INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
|
||||
[key, value.trim()]
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch the full current config (including any previously saved secret)
|
||||
const saved = await pool.query(
|
||||
`SELECT key, value FROM settings
|
||||
WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')
|
||||
AND value IS NOT NULL AND value <> ''`
|
||||
);
|
||||
const cfg = {};
|
||||
for (const { key, value } of saved.rows) {
|
||||
switch (key) {
|
||||
case 's3_endpoint': cfg.endpoint = value; break;
|
||||
case 's3_bucket': cfg.bucket = value; break;
|
||||
case 's3_access_key': cfg.accessKey = value; break;
|
||||
case 's3_secret_key': cfg.secretKey = value; break;
|
||||
case 's3_region': cfg.region = value; break;
|
||||
}
|
||||
}
|
||||
rebuildS3Client(cfg);
|
||||
|
||||
res.json({ message: 'S3 settings saved and applied' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Test with the values the browser just typed (before saving), or with saved
|
||||
// creds if the fields are left blank. Secret is sent only if user typed it.
|
||||
router.post('/s3/test', async (req, res, next) => {
|
||||
try {
|
||||
const { s3_endpoint, s3_bucket, s3_access_key, s3_secret_key, s3_region } = req.body;
|
||||
|
||||
// Merge submitted values with anything already saved in the DB
|
||||
const saved = await pool.query(
|
||||
`SELECT key, value FROM settings
|
||||
WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')
|
||||
AND value IS NOT NULL AND value <> ''`
|
||||
);
|
||||
const fromDb = {};
|
||||
for (const { key, value } of saved.rows) fromDb[key] = value;
|
||||
|
||||
const cfg = {
|
||||
endpoint: (s3_endpoint || fromDb.s3_endpoint || '').trim(),
|
||||
bucket: (s3_bucket || fromDb.s3_bucket || getS3Bucket()).trim(),
|
||||
accessKey: (s3_access_key || fromDb.s3_access_key || '').trim(),
|
||||
secretKey: (s3_secret_key || fromDb.s3_secret_key || '').trim(),
|
||||
region: (s3_region || fromDb.s3_region || 'us-east-1').trim(),
|
||||
};
|
||||
|
||||
if (!cfg.endpoint) return res.status(400).json({ error: 'S3 endpoint is required' });
|
||||
if (!cfg.bucket) return res.status(400).json({ error: 'S3 bucket is required' });
|
||||
|
||||
const client = buildTestClient(cfg);
|
||||
const result = await testS3Connection(client, cfg.bucket);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(400).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── AMPP integration ───────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/ampp', async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
|
|
@ -15,7 +130,7 @@ router.get('/ampp', async (req, res, next) => {
|
|||
const out = {};
|
||||
for (const row of result.rows) {
|
||||
if (row.key === 'ampp_token') {
|
||||
out.ampp_token_exists = true; // Never return the raw token
|
||||
out.ampp_token_exists = true;
|
||||
} else {
|
||||
out[row.key] = row.value;
|
||||
}
|
||||
|
|
@ -26,62 +141,186 @@ router.get('/ampp', async (req, res, next) => {
|
|||
}
|
||||
});
|
||||
|
||||
// PUT /api/v1/settings/ampp — Save AMPP credentials
|
||||
router.put('/ampp', async (req, res, next) => {
|
||||
try {
|
||||
const { ampp_base_url, ampp_token } = req.body;
|
||||
if (!ampp_base_url) {
|
||||
return res.status(400).json({ error: 'ampp_base_url is required' });
|
||||
}
|
||||
|
||||
if (!ampp_base_url) return res.status(400).json({ error: 'ampp_base_url is required' });
|
||||
const baseUrl = ampp_base_url.trim().replace(/\/$/, '');
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO settings (key, value, updated_at)
|
||||
VALUES ('ampp_base_url', $1, NOW())
|
||||
`INSERT INTO settings (key, value, updated_at) VALUES ('ampp_base_url', $1, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
|
||||
[baseUrl]
|
||||
);
|
||||
|
||||
if (ampp_token) {
|
||||
await pool.query(
|
||||
`INSERT INTO settings (key, value, updated_at)
|
||||
VALUES ('ampp_token', $1, NOW())
|
||||
`INSERT INTO settings (key, value, updated_at) VALUES ('ampp_token', $1, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
|
||||
[ampp_token.trim()]
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ message: 'AMPP settings saved' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v1/settings/ampp/test — Verify AMPP connectivity
|
||||
router.post('/ampp/test', async (req, res, next) => {
|
||||
try {
|
||||
const config = await getAmppConfig();
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'AMPP credentials not configured' });
|
||||
}
|
||||
|
||||
if (!config) return res.status(400).json({ error: 'AMPP credentials not configured' });
|
||||
const testUrl = `${config.ampp_base_url}/api/v1/store/folder/folders?limit=1`;
|
||||
const testRes = await fetch(testUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.ampp_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: { Authorization: `Bearer ${config.ampp_token}`, 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
if (!testRes.ok) {
|
||||
return res.status(400).json({ error: `AMPP returned HTTP ${testRes.status}` });
|
||||
}
|
||||
|
||||
if (!testRes.ok) return res.status(400).json({ error: `AMPP returned HTTP ${testRes.status}` });
|
||||
res.json({ message: 'AMPP connection successful' });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: `Connection failed: ${err.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Hardware inventory ────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/hardware', async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT id, hostname, role, ip_address, capabilities,
|
||||
EXTRACT(EPOCH FROM (NOW() - last_seen))::int AS stale_seconds
|
||||
FROM cluster_nodes
|
||||
ORDER BY role, hostname`
|
||||
);
|
||||
const nodes = result.rows.map(n => ({
|
||||
id: n.id,
|
||||
hostname: n.hostname,
|
||||
role: n.role,
|
||||
ip_address: n.ip_address,
|
||||
online: n.stale_seconds < 120,
|
||||
capabilities: n.capabilities || {},
|
||||
}));
|
||||
res.json({ nodes });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ── GPU / Transcoding ─────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/transcoding', async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT key, value FROM settings
|
||||
WHERE key IN ('gpu_transcode_enabled','gpu_codec','gpu_preset','gpu_bitrate_mbps','gpu_node',
|
||||
'gpu_extension','gpu_framerate','gpu_rc_mode','gpu_audio_codec','gpu_audio_bitrate_kbps')`
|
||||
);
|
||||
const out = {
|
||||
gpu_transcode_enabled: 'false',
|
||||
gpu_codec: 'h264_nvenc',
|
||||
gpu_preset: 'p4',
|
||||
gpu_bitrate_mbps: '8',
|
||||
gpu_node: '',
|
||||
gpu_extension: 'mp4',
|
||||
gpu_framerate: 'passthrough',
|
||||
gpu_rc_mode: 'cbr',
|
||||
gpu_audio_codec: 'aac',
|
||||
gpu_audio_bitrate_kbps: '192',
|
||||
};
|
||||
for (const { key, value } of result.rows) out[key] = value;
|
||||
res.json(out);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/transcoding', async (req, res, next) => {
|
||||
try {
|
||||
const allowed = [
|
||||
'gpu_transcode_enabled', 'gpu_codec', 'gpu_preset', 'gpu_bitrate_mbps', 'gpu_node',
|
||||
'gpu_extension', 'gpu_framerate', 'gpu_rc_mode', 'gpu_audio_codec', 'gpu_audio_bitrate_kbps',
|
||||
];
|
||||
for (const key of allowed) {
|
||||
if (req.body[key] !== undefined) {
|
||||
await pool.query(
|
||||
`INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
|
||||
[key, String(req.body[key])]
|
||||
);
|
||||
}
|
||||
}
|
||||
res.json({ message: 'Transcoding settings saved' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Growing files (SMB landing zone) ─────────────────────────────────────────
|
||||
// Lets capture write its master output to a fast local SMB share instead of
|
||||
// streaming directly to S3. Premiere can mount the share and edit the file
|
||||
// while it's still being written; the promotion worker later moves the
|
||||
// finalized file to S3 and flips the asset to status='ready'.
|
||||
|
||||
const GROWING_KEYS = ['growing_enabled', 'growing_path', 'growing_smb_url', 'growing_promote_after_seconds'];
|
||||
|
||||
router.get('/growing', async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT key, value FROM settings WHERE key = ANY($1)`,
|
||||
[GROWING_KEYS]
|
||||
);
|
||||
const out = {
|
||||
growing_enabled: 'false',
|
||||
growing_path: '/growing',
|
||||
growing_smb_url: '',
|
||||
growing_promote_after_seconds: '8',
|
||||
};
|
||||
for (const { key, value } of result.rows) out[key] = value;
|
||||
res.json(out);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/growing', async (req, res, next) => {
|
||||
try {
|
||||
for (const key of GROWING_KEYS) {
|
||||
if (req.body[key] !== undefined) {
|
||||
await pool.query(
|
||||
`INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
|
||||
[key, String(req.body[key])]
|
||||
);
|
||||
}
|
||||
}
|
||||
res.json({ message: 'Growing-files settings saved' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Capture service routing ───────────────────────────────────────────────────
|
||||
|
||||
router.get('/capture-service', async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
"SELECT value FROM settings WHERE key = 'capture_service_url'"
|
||||
);
|
||||
res.json({ capture_service_url: result.rows[0]?.value || '' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/capture-service', async (req, res, next) => {
|
||||
try {
|
||||
const url = (req.body.capture_service_url || '').trim().replace(/\/$/, '');
|
||||
await pool.query(
|
||||
`INSERT INTO settings (key, value, updated_at) VALUES ('capture_service_url', $1, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
|
||||
[url]
|
||||
);
|
||||
res.json({ message: 'Capture service URL saved' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
142
services/mam-api/src/routes/storage.js
Normal file
142
services/mam-api/src/routes/storage.js
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
// Storage admin endpoints — unified diagnostics for the growing-files mount
|
||||
// and the S3 object-storage bucket. Read-only; the actual settings editors
|
||||
// continue to live under /settings/s3 and /settings/growing.
|
||||
|
||||
import express from 'express';
|
||||
import fs from 'node:fs';
|
||||
import { promisify } from 'node:util';
|
||||
import { exec as execCb } from 'node:child_process';
|
||||
import { HeadBucketCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
|
||||
import pool from '../db/pool.js';
|
||||
import { s3Client, getS3Bucket } from '../s3/client.js';
|
||||
|
||||
const exec = promisify(execCb);
|
||||
const router = express.Router();
|
||||
|
||||
// Defaults mirrored from settings.js so the overview never returns nulls.
|
||||
const GROWING_DEFAULTS = {
|
||||
growing_enabled: 'false',
|
||||
growing_path: '/growing',
|
||||
growing_smb_url: '',
|
||||
growing_promote_after_seconds: '8',
|
||||
};
|
||||
|
||||
async function readSettings(keys) {
|
||||
const result = await pool.query(
|
||||
`SELECT key, value FROM settings WHERE key = ANY($1)`,
|
||||
[keys]
|
||||
);
|
||||
const out = {};
|
||||
for (const { key, value } of result.rows) out[key] = value;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Probe a filesystem path: does it exist, is it writable, how much free space.
|
||||
// All checks are best-effort — any failure becomes { ok: false, error }.
|
||||
async function probeGrowingPath(path) {
|
||||
const result = { path, exists: false, writable: false, free_bytes: null, total_bytes: null, error: null };
|
||||
if (!path) { result.error = 'no path configured'; return result; }
|
||||
|
||||
try {
|
||||
const stat = fs.statSync(path);
|
||||
result.exists = stat.isDirectory();
|
||||
if (!result.exists) { result.error = 'path is not a directory'; return result; }
|
||||
} catch (err) {
|
||||
result.error = err.code === 'ENOENT' ? 'path does not exist' : err.message;
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.accessSync(path, fs.constants.W_OK);
|
||||
result.writable = true;
|
||||
} catch (err) {
|
||||
result.error = 'not writable: ' + err.message;
|
||||
}
|
||||
|
||||
// df -P returns POSIX-portable output: "Filesystem 1024-blocks Used Available Capacity Mounted-on"
|
||||
try {
|
||||
const { stdout } = await exec(`df -PB1 ${JSON.stringify(path)}`, { timeout: 3000 });
|
||||
const lines = stdout.trim().split('\n');
|
||||
if (lines.length >= 2) {
|
||||
const cols = lines[1].split(/\s+/);
|
||||
// cols: [fs, total, used, available, capacity, mountpoint]
|
||||
result.total_bytes = parseInt(cols[1], 10) || null;
|
||||
result.free_bytes = parseInt(cols[3], 10) || null;
|
||||
}
|
||||
} catch (_err) {
|
||||
// df not available or path inaccessible — leave free_bytes null.
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function probeS3Bucket() {
|
||||
const bucket = getS3Bucket();
|
||||
const out = { bucket, reachable: false, head_latency_ms: null, method: null, error: null };
|
||||
if (!bucket) { out.error = 'no bucket configured'; return out; }
|
||||
|
||||
const started = Date.now();
|
||||
try {
|
||||
await s3Client.send(new HeadBucketCommand({ Bucket: bucket }));
|
||||
out.reachable = true;
|
||||
out.method = 'HeadBucket';
|
||||
} catch (headErr) {
|
||||
// Fall back to a 0-key list for stores that don't expose HeadBucket.
|
||||
try {
|
||||
await s3Client.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 0 }));
|
||||
out.reachable = true;
|
||||
out.method = 'ListObjectsV2';
|
||||
} catch (listErr) {
|
||||
out.error = listErr.message || headErr.message;
|
||||
}
|
||||
}
|
||||
out.head_latency_ms = Date.now() - started;
|
||||
return out;
|
||||
}
|
||||
|
||||
// GET /api/v1/storage/overview
|
||||
// Consolidated read-only view of the storage subsystem for the admin UI.
|
||||
router.get('/overview', async (req, res, next) => {
|
||||
try {
|
||||
// Growing files — merge defaults with whatever's in `settings`.
|
||||
const growingRaw = { ...GROWING_DEFAULTS, ...(await readSettings(Object.keys(GROWING_DEFAULTS))) };
|
||||
const growingEnabled = growingRaw.growing_enabled === 'true' || growingRaw.growing_enabled === true;
|
||||
const containerPath = growingRaw.growing_path || '/growing';
|
||||
const mount = await probeGrowingPath(containerPath);
|
||||
|
||||
// S3 — bucket name comes from the live client (env or DB-loaded), not
|
||||
// a fresh DB read, so we report exactly what the running client uses.
|
||||
const s3 = await probeS3Bucket();
|
||||
const s3SettingsRaw = await readSettings(['s3_endpoint', 's3_region']);
|
||||
|
||||
res.json({
|
||||
growing: {
|
||||
enabled: growingEnabled,
|
||||
container_path: containerPath,
|
||||
// host_path isn't authoritatively known to the API container, but the
|
||||
// existing deploy uses this symlink — surface it for operator context.
|
||||
host_path: '/mnt/NVME/MAM/wild-dragon-growing',
|
||||
smb_url: growingRaw.growing_smb_url || '',
|
||||
promote_after_seconds: parseInt(growingRaw.growing_promote_after_seconds, 10) || 8,
|
||||
exists: mount.exists,
|
||||
writable: mount.writable,
|
||||
free_bytes: mount.free_bytes,
|
||||
total_bytes: mount.total_bytes,
|
||||
error: mount.error,
|
||||
},
|
||||
s3: {
|
||||
endpoint: s3SettingsRaw.s3_endpoint || process.env.S3_ENDPOINT || '',
|
||||
bucket: s3.bucket,
|
||||
region: s3SettingsRaw.s3_region || process.env.S3_REGION || 'us-east-1',
|
||||
reachable: s3.reachable,
|
||||
head_latency_ms: s3.head_latency_ms,
|
||||
probe_method: s3.method,
|
||||
error: s3.error,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
99
services/mam-api/src/routes/system.js
Normal file
99
services/mam-api/src/routes/system.js
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import express from 'express';
|
||||
import http from 'node:http';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const DOCKER_SOCKET = '/var/run/docker.sock';
|
||||
const COMPOSE_PROJECT = (process.env.DOCKER_NETWORK || 'wild-dragon_wild-dragon').split('_')[0];
|
||||
|
||||
function dockerGet(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{ socketPath: DOCKER_SOCKET, path, method: 'GET' },
|
||||
(res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
try { resolve({ status: res.statusCode, body: JSON.parse(data) }); }
|
||||
catch { resolve({ status: res.statusCode, body: data }); }
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function dockerPost(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{ socketPath: DOCKER_SOCKET, path, method: 'POST', headers: { 'Content-Length': '0' } },
|
||||
(res) => {
|
||||
res.resume();
|
||||
res.on('end', () => resolve({ status: res.statusCode }));
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// GET /containers – list containers for this compose project
|
||||
router.get('/containers', async (req, res, next) => {
|
||||
try {
|
||||
const r = await dockerGet('/containers/json?all=1');
|
||||
if (r.status !== 200) return res.status(502).json({ error: 'Docker API error', code: r.status });
|
||||
|
||||
const containers = r.body
|
||||
.filter(c => {
|
||||
const labels = c.Labels || {};
|
||||
// Match by compose project label; fall back to network name
|
||||
if (labels['com.docker.compose.project'] === COMPOSE_PROJECT) return true;
|
||||
return Object.keys(c.NetworkSettings?.Networks || {}).some(n => n.includes(COMPOSE_PROJECT));
|
||||
})
|
||||
.map(c => ({
|
||||
id: c.Id.slice(0, 12),
|
||||
name: (c.Names[0] || '').replace(/^\//, ''),
|
||||
image: c.Image,
|
||||
state: c.State,
|
||||
status: c.Status,
|
||||
created: c.Created,
|
||||
service: (c.Labels || {})['com.docker.compose.service'] || '',
|
||||
ports: (c.Ports || [])
|
||||
.filter(p => p.PublicPort)
|
||||
.map(p => `${p.PublicPort}:${p.PrivatePort}/${p.Type}`)
|
||||
.join(', '),
|
||||
}));
|
||||
|
||||
res.json(containers);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /containers/:id/restart
|
||||
router.post('/containers/:id/restart', async (req, res, next) => {
|
||||
try {
|
||||
const r = await dockerPost(`/containers/${req.params.id}/restart`);
|
||||
if (r.status !== 204) return res.status(502).json({ error: 'Failed to restart', code: r.status });
|
||||
res.json({ ok: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /containers/:id/stop
|
||||
router.post('/containers/:id/stop', async (req, res, next) => {
|
||||
try {
|
||||
const r = await dockerPost(`/containers/${req.params.id}/stop`);
|
||||
if (r.status !== 204 && r.status !== 304) return res.status(502).json({ error: 'Failed to stop', code: r.status });
|
||||
res.json({ ok: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /containers/:id/start
|
||||
router.post('/containers/:id/start', async (req, res, next) => {
|
||||
try {
|
||||
const r = await dockerPost(`/containers/${req.params.id}/start`);
|
||||
if (r.status !== 204 && r.status !== 304) return res.status(502).json({ error: 'Failed to start', code: r.status });
|
||||
res.json({ ok: true });
|
||||
} 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