I think at some point, node-red allowed more finer control of the static hosting. It didn't work for me on my old v1 node-red, but just fine on v3. From what I understand, the path is referring to the file path on disk, while the root is how the url shows up when hosting and serving it via http. This can be set to whatever you need to make it work on your system, as you have.
In my settings.js, i have the following settings (it could be more than 1 in the array):
httpStatic: [
{ path: '/run/media/system/surveillance1/cctv/recordings/', root: '/recordings/' }
],
httpStaticRoot: '/static/',
This has my files being served via http at mydomain/static/recordings/thevideo. The root allows you to change the http url path so that it does not have to be the same as the file path, maybe.
I matched that setting in my function node to use those paths set in the settings.js
That is not something built-in anywhere. I was starting to tinker with a template node to play back the recordings, but never got very far. I was waiting for somebody else to make it
.
I will share my small effort and maybe you can expand on it. It makes use of the listing.txt file almost as a master playlist. The template fetches the list and loads the urls to the video sources. I think you can take this and make it better. Or you could make some flow that reads the listing file and then send an individual m3u8 playlist to ui_mp4rag or any other mp4/hlsjs nodes. Or maybe fetch the listing and use it to populate some html select element playlist picker and that will send the playlist to the ui node. I am not really sure how it should be. Nobody was using this, so there was no feedback that I could act upon. Also, there is always that lack of free time and now I am elbow deep in nginx trying to create the ultimate performance configuration for the cctv stuff.
[{"id":"a73b2f112ad47063","type":"http in","z":"e9f5eca971f24054","name":"","url":"/hlsjs/recordings/:i/:y/:m/:d","method":"get","upload":false,"swaggerDoc":"","x":230,"y":600,"wires":[["2944f4b7ec1f333c"]]},{"id":"2944f4b7ec1f333c","type":"template","z":"e9f5eca971f24054","name":"video","field":"payload","fieldType":"msg","format":"handlebars","syntax":"mustache","template":"<!doctype html>\n<html>\n <head>\n <title>{{req.params.i}} {{req.params.y}} {{req.params.m}} {{req.params.d}}</title>\n <style>\n html {\n height: 100%;\n overflow: hidden;\n }\n body {\n height: 100%;\n margin: 0;\n }\n video {\n width: 100%;\n height: 100%;\n object-fit: fill;\n }\n </style>\n <style>\n html {\n height: 100vh;\n overflow: hidden;\n }\n body {\n height: 100vh;\n margin: 0;\n }\n video {\n width: 100vw;\n height: 100vh;\n object-fit: fill;\n z-index: 0;\n }\n\n button#prev {\n opacity: 0.4;\n font-size: xxx-large;\n position: absolute;\n top: 50vh;\n left: 1vw;\n z-index: 1;\n }\n\n button#next {\n opacity: 0.4;\n font-size: xxx-large;\n position: absolute;\n top: 50vh;\n right: 1vw;\n z-index: 1;\n }\n\n </style>\n <style>\n a {\n text-decoration: none;\n display: inline-block;\n padding: 8px 16px;\n }\n\n a:hover {\n background-color: #ddd;\n color: black;\n }\n\n .previous {\n opacity: 0.3;\n background-color: #f1f1f1;\n color: black;\n position: absolute;\n top: 50vh;\n left: 1vw;\n z-index: 1;\n font-size: x-large;\n }\n\n .next {\n opacity: 0.3;\n background-color: #f1f1f1;\n color: black;\n position: absolute;\n top: 50vh;\n right: 1vw;\n z-index: 1;\n font-size: x-large;\n }\n\n .round {\n border-radius: 50%;\n }\n </style>\n <script src=\"//cdn.jsdelivr.net/npm/hls.js@latest\"></script>\n </head>\n <body>\n<div id=\"container\">\n<video id=\"hls\" controls muted playsinline></video>\n<a href=\"#\" id=\"prev\" class=\"previous round\">‹</a>\n<a href=\"#\" id=\"next\" class=\"next round\">›</a>\n</div>\n<script>\nconst dir = '/static/recordings/{{req.params.i}}/{{req.params.y}}/{{req.params.m}}/{{req.params.d}}';\nfetch(`${dir}/listing.txt`)\n .then(res => {\n return res.text()\n }).then(text => {\n console.log(text);\n const arr = text.split('\\n').filter(n => n);\n console.log(arr);\n const length = arr.length;\n const minIndex = 0;\n const maxIndex = length - 1;\n let currentIndex = maxIndex;\n const video = document.getElementById('hls');\n const prev = document.getElementById('prev');\n const next = document.getElementById('next');\n const videoSrc = arr[currentIndex];\n const hlsOptions = { startPosition: 0, liveDurationInfinity: false, progressive: false, maxMaxBufferLength: 16 * 60, maxBufferSize: 300 * 1000 * 1000, backBufferLength: 16 * 60 }; \n let hls;\n \n const loadHls = () => {\n if (Hls.isSupported()) {\n hls = new Hls(hlsOptions);\n hls.attachMedia(video);\n hls.loadSource(`${dir}/${arr[currentIndex]}`);\n hls.on(Hls.Events.MANIFEST_PARSED, async (event, data) => {\n console.log('manifest loaded, found ' + data.levels.length + ' quality level');\n try {\n await video.play();\n video.playbackRate = 5.0;\n } catch(e) {\n console.log(e);\n // noop\n }\n });\n } else if (video.canPlayType('application/vnd.apple.mpegurl')) {\n video.src = `${dir}/${arr[currentIndex]}`;\n video.play();\n }\n }\n\n next.addEventListener('click', () => {\n ++currentIndex;\n if (currentIndex > maxIndex) {\n currentIndex = maxIndex;\n return;\n }\n hls && hls.destroy();\n loadHls();\n });\n\n prev.addEventListener('click', () => {\n currentIndex--;\n if (currentIndex < minIndex) {\n currentIndex = minIndex;\n return;\n }\n hls && hls.destroy();\n loadHls();\n });\n\n loadHls();\n});\n</script>\n</body>\n</html>","output":"str","x":470,"y":600,"wires":[["78503259e2433081"]]},{"id":"78503259e2433081","type":"http response","z":"e9f5eca971f24054","name":"","statusCode":"","headers":{},"x":670,"y":600,"wires":[]}]
This allows me to view the recordings for a single camera for a single day by going to the url mywebsite/hlsjs/recordings/front_driveway_main/2023/02/24
Be warned, most of the values are hard coded and it will not work without modification.