Node-Red TVHeadEnd EPG Dashboard
This flow provides two Node-RED dashboard tabs:
EPG Search:
- Search the TVHeadEnd EPG by program channel (optional) and title (required)
- Add a single recording
- Add a series recording
- Delete a single recording
Upcoming Recordings:
- Search the list of upcoming recordings by channel and title (both optional)
- Delete a single recording
- Delete a series recording
Warning: TVHeadEnd provides a generic API endpoint for deleting entities by unique id. When a user clicks the Cancel
or Cancel Serie
button to delete the corresponding (auto-)recording, the unique id for the recording to be deleted is being sent from the browser to the Node-RED back-end. This potentially allows users to send malicious requests to delete any TVHeadEnd entity. For example, a malicious user could potentially delete entities like DVR Inputs, Channels, Recording Profiles, ... As such, the credentials used to connect to TVHeadEnd (see below) should only be given permissions to read the EPG, and create and delete (auto-)recordings.
To use this flow:
- Install node-red-dashboard
- Import flow into Node-RED
- In the
TVH Request
sub-flow, update theTVH Request
node to match your TVHeadEnd installation:- Update
URL
with the correct host, port, and sub-path as necessary. TheURL
must be a properly formatted URL that ends with{{{tvhPath}}}
, for examplehttp://tvh.lan:9981/{{{tvhPath}}}
,http://192.168.1.1:9981/{{{tvhPath}}}
orhttps://traefik.lan/tvh/{{{tvhPath}}}
- Add the credentials for your TVHeadEnd installation
- If necessary, enable
Enable secure (SSL/TLS) connection
- If necessary, enable
Use proxy
- Update
- In the
TVH Record Action
sub-flow, update themsg=[TVH Action]
function node to specify the DVR profile UUID's for single and series recordings - Deploy flow
[{"id":"fd417ffc.54838","type":"subflow","name":"TVH Record Action","info":"","category":"","in":[{"x":59,"y":80,"wires":[{"id":"7478467c.102208"}]}],"out":[{"x":845.5,"y":80,"wires":[{"id":"7248f550.069b5c","port":0}]}],"env":[]},{"id":"7478467c.102208","type":"function","z":"fd417ffc.54838","name":"msg=[TVH Action]","func":"// Define the DVR profile uuid's for single\n// and serie recordings; leave blank to use\n// default profile\nvar dvrProfileUuidForSingleRecording = '';\nvar dvrProfileUuidForSerieRecording = '70b0beeecc99706062b5d1247d7916a8';\n\nvar newMsg = {};\nnewMsg.method='POST';\nnewMsg.headers = {};\nnewMsg.headers['content-type'] = 'application/x-www-form-urlencoded';\n\nswitch (msg.action) {\n case 'addRecording':\n newMsg.tvhPath='/api/dvr/entry/create_by_event';\n newMsg.payload={'event_id':msg.payload,'config_uuid':dvrProfileUuidForSingleRecording};\n break;\n case 'deleteRecording':\n newMsg.tvhPath='/api/idnode/delete';\n newMsg.payload={'uuid':[msg.payload]}\n break;\n case 'addSerieRecording':\n newMsg.tvhPath='/api/dvr/autorec/create';\n newMsg.payload={\n 'conf': util.format('%j',{\n \"name\":msg.payload.title,\n \"title\":msg.payload.title,\n \"fulltext\":false,\n \"channel\":msg.payload.channelId,\n \"start\":\"Any\",\n \"start_window\":\"Any\",\n \"weekdays\":[1,2,3,4,5,6,7],\n \"comment\":\"\",\n \"record\":0,\n \"tag\":\"\",\n \"btype\":0,\n \"content_type\":0,\n \"config_name\":dvrProfileUuidForSerieRecording,\n \"pri\":6,\n \"cat1\":\"\",\n \"cat2\":\"\",\n \"cat3\":\"\",\n \"minduration\":0,\n \"maxduration\":0,\n \"minyear\":0,\n \"maxyear\":0,\n \"minseason\":0,\n \"maxseason\":0,\n \"star_rating\":0,\n \"directory\":\"\"\n })\n };\n break;\n default:\n throw \"Unknown action: \"+msg.action;\n}\n \nreturn newMsg;","outputs":1,"noerr":0,"x":200,"y":80,"wires":[["4d4a3fc.b9ae0c"]]},{"id":"4d4a3fc.b9ae0c","type":"subflow:eef30c6b.d0707","z":"fd417ffc.54838","name":"TVH Request","env":[],"x":406,"y":80,"wires":[["7248f550.069b5c","b488a9f5.af25d8"]]},{"id":"7248f550.069b5c","type":"function","z":"fd417ffc.54838","name":"msg.action=refreshSearchResults","func":"return {'action': 'refreshSearchResults'};","outputs":1,"noerr":0,"x":654,"y":80,"wires":[[]]},{"id":"b488a9f5.af25d8","type":"debug","z":"fd417ffc.54838","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":567,"y":136,"wires":[]},{"id":"6db9fe1d.7c58c","type":"subflow","name":"TVH Search","info":"","category":"","in":[{"x":266,"y":80,"wires":[{"id":"66dc2f5a.a1489"}]}],"out":[{"x":510,"y":74,"wires":[{"id":"66dc2f5a.a1489","port":0}]},{"x":597,"y":242,"wires":[{"id":"8a2f5a89.9b9558","port":0}]}],"env":[],"inputLabels":["search form input"],"outputLabels":["search","loadChannels"]},{"id":"66dc2f5a.a1489","type":"switch","z":"6db9fe1d.7c58c","name":"","property":"action","propertyType":"msg","rules":[{"t":"eq","v":"search","vt":"str"},{"t":"eq","v":"loadChannels","vt":"str"}],"checkall":"false","repair":false,"outputs":2,"x":377,"y":80,"wires":[[],["18e8ca3e.844d06"]]},{"id":"18e8ca3e.844d06","type":"function","z":"6db9fe1d.7c58c","name":"msg=[TVH Channels Query]","func":"var newMsg = {};\nnewMsg.action=msg.action; // Copy action\nnewMsg.method='GET';\nnewMsg.tvhPath='/api/channel/list?numbers=0&sources=0';\nreturn newMsg;","outputs":1,"noerr":0,"x":489.5,"y":139,"wires":[["8a2f5a89.9b9558"]]},{"id":"8a2f5a89.9b9558","type":"subflow:eef30c6b.d0707","z":"6db9fe1d.7c58c","name":"","x":447.5,"y":183,"wires":[["3afd9912.6d2866"]]},{"id":"3afd9912.6d2866","type":"debug","z":"6db9fe1d.7c58c","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":642,"y":183,"wires":[]},{"id":"eef30c6b.d0707","type":"subflow","name":"TVH Request","info":"","category":"","in":[{"x":20,"y":80,"wires":[{"id":"3e882a6d.f26856"}]}],"out":[{"x":534,"y":80,"wires":[{"id":"52986506.1bd1dc","port":0}]}],"env":[]},{"id":"52986506.1bd1dc","type":"http request","z":"eef30c6b.d0707","name":"TVH Request","method":"use","ret":"obj","paytoqs":false,"url":"http://192.168.98.10:9981/{{{tvhPath}}}","tls":"","proxy":"","authType":"basic","x":394,"y":80,"wires":[[]]},{"id":"3e882a6d.f26856","type":"function","z":"eef30c6b.d0707","name":"normalize msg.tvhPath","func":"if (!msg.tvhPath) {\n node.error(\"msg.tvhPath not defined\");\n return null;\n} else {\n msg.tvhPath = msg.tvhPath.replace(/^\\/+/, '');\n return msg;\n}","outputs":1,"noerr":0,"x":176,"y":80,"wires":[["52986506.1bd1dc"]]},{"id":"e0f6ef54.8daf1","type":"ui_template","z":"efebee1e.aecd3","group":"9432df1f.80f73","name":"Search Results","order":3,"width":"8","height":"12","format":"<div ng-repeat=\"e in msg.payload.entries\" nf-if=\"msg.payload.entries\">\n <p>{{e.channelName}} {{e.start*1000 | date:'EEEE, MMMM dd HH:mm'}}</p>\n <p>{{e.title}} {{e.subtitle}}</p>\n <div ng-if=\"!e.dvrUuid\">\n <md-button ng-click=\"clickRecord(e)\" style=\"margin: 0;\">Record</md-button>\n <md-button ng-click=\"clickRecordSerie(e)\" style=\"margin: 0;\">Record Serie</md-button>\n </div>\n <div ng-if=\"e.dvrUuid\">\n <md-button ng-click=\"clickCancel(e)\">Cancel</md-button>\n </div>\n <hr/>\n</div>\n\n<script>\n scope.clickRecordSerie = function(e) {\n this.send({'action':'addSerieRecording', 'payload':{'title':e.title, 'channelId':e.channelUuid}});\n }.bind(scope);\n\n scope.clickRecord = function(e) {\n this.send({'action':'addRecording', 'payload':e.eventId});\n }.bind(scope);\n \n scope.clickCancel = function(e) {\n this.send({'action':'deleteRecording', 'payload':e.dvrUuid});\n }.bind(scope);\n</script>","storeOutMessages":true,"fwdInMessages":false,"templateScope":"local","x":494.5,"y":173,"wires":[["61721837.c23fa8"]]},{"id":"2722373b.21ed18","type":"debug","z":"efebee1e.aecd3","name":"","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":833,"y":100,"wires":[]},{"id":"520e009.7a979","type":"function","z":"efebee1e.aecd3","name":"msg=[TVH EPG Query]","func":"var newMsg = {};\nnewMsg.method='POST';\nnewMsg.tvhPath='/api/epg/events/grid';\nnewMsg.headers = {};\nnewMsg.headers['content-type'] = 'application/x-www-form-urlencoded';\nnewMsg.payload = {\n \"start\":0,\n \"limit\":300,\n \"title\":msg.payload.title,\n \"channel\":msg.payload.channel\n}\nreturn newMsg;","outputs":1,"noerr":0,"x":404,"y":101,"wires":[["8d0cab91.85e068"]]},{"id":"8d0cab91.85e068","type":"subflow:eef30c6b.d0707","z":"efebee1e.aecd3","name":"","x":620,"y":101,"wires":[["e0f6ef54.8daf1","2722373b.21ed18"]]},{"id":"a1eb7fd4.a2a78","type":"subflow:6db9fe1d.7c58c","z":"efebee1e.aecd3","x":252.25,"y":277.5,"wires":[["ee999206.ca8a4"],["aff685a.1d36678"]]},{"id":"ee999206.ca8a4","type":"switch","z":"efebee1e.aecd3","name":"","property":"payload.title","propertyType":"msg","rules":[{"t":"nempty"},{"t":"empty"}],"checkall":"true","repair":false,"outputs":2,"x":289,"y":167,"wires":[["520e009.7a979"],["e0f6ef54.8daf1"]]},{"id":"61721837.c23fa8","type":"subflow:fd417ffc.54838","z":"efebee1e.aecd3","x":714,"y":172.5,"wires":[["aff685a.1d36678"]]},{"id":"aff685a.1d36678","type":"ui_template","z":"efebee1e.aecd3","group":"9432df1f.80f73","name":"EPG Search Form","order":1,"width":"0","height":"0","format":"<!--\n Note that this search form is very similar,\n but not the same, as 'Upcoming Recordings\n Search Form'; this form uses channel id's\n instead of names as channel option values.\n-->\n<md-input-container flex layout=\"row\" style=\"margin-top: 5px; margin-bottom: 5px;\">\n\t<md-select placeholder=\"Select Channel\" ng-model=\"frm.channel\" flex=\"100\" ng-change=\"search()\">\n\t <md-option ng-value=\"''\">Any Channel</md-option>\n\t\t<md-option ng-value=\"opt.key\" ng-repeat=\"opt in channels\">{{ opt.val }}</md-option>\n\t</md-select>\n</md-input-container>\n<md-input-container flex layout=\"row\" style=\"margin-top: 0px; margin-bottom: 5px;\">\n <input ng-model=\"frm.title\"\n ng-change=\"search()\"\n ng-model-options=\"{debounce:500}\"\n aria-label=\"Search\"\n type=\"text\"\n style=\"z-index:1\"/>\n</md-input-container>\n<md-input-container flex layout=\"row\" style=\"margin-top: 0px; margin-bottom: 5px;\">\n <md-button ng-click=\"search()\">Refresh</md-button>\n</md-input-container>\n\n<script>\n(function() {\n scope.search = function() {\n this.send({'action': 'search', 'payload': this.frm});\n }.bind(scope);\n \n (function(scope) {\n scope.channels=[];\n scope.frm = {'channel': '', 'title': ''};\n scope.send({action: \"loadChannels\"});\n scope.$watch('msg', function(msg) {\n if (msg && msg.action==='loadChannels') {\n scope.channels = msg.payload.entries;\n scope.search();\n }\n if (msg && msg.action==='refreshSearchResults') {\n scope.search();\n }\n });\n })(scope);\n})();\n</script>\n","storeOutMessages":false,"fwdInMessages":false,"templateScope":"local","x":504,"y":231,"wires":[["a1eb7fd4.a2a78"]]},{"id":"5cf15117.494e6","type":"debug","z":"efebee1e.aecd3","name":"","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":840.5,"y":357,"wires":[]},{"id":"e733b03e.3cd15","type":"subflow:eef30c6b.d0707","z":"efebee1e.aecd3","name":"","x":627.5,"y":358,"wires":[["5cf15117.494e6","57e380ee.90064"]]},{"id":"42328204.60e63c","type":"subflow:6db9fe1d.7c58c","z":"efebee1e.aecd3","x":259.75,"y":516.5,"wires":[["a85b7574.928cd8"],["979200c1.84ccf"]]},{"id":"e1d6416a.7c22a","type":"subflow:fd417ffc.54838","z":"efebee1e.aecd3","x":721.5,"y":425.5,"wires":[["979200c1.84ccf"]]},{"id":"979200c1.84ccf","type":"ui_template","z":"efebee1e.aecd3","group":"cd4fc12b.22c4e","name":"Upcoming Recordings Search Form","order":1,"width":"0","height":"0","format":"<!--\n Note that this search form is very similar,\n but not the same, as 'EPG Search Form'; \n this form uses channel names instead of \n id's as channel option values.\n-->\n<md-input-container flex layout=\"row\" style=\"margin-top: 5px; margin-bottom: 5px;\">\n\t<md-select placeholder=\"Select Channel\" ng-model=\"frm.channel\" flex=\"100\" ng-change=\"search()\">\n\t <md-option ng-value=\"''\">Any Channel</md-option>\n\t\t<md-option ng-value=\"opt.val\" ng-repeat=\"opt in channels\">{{ opt.val }}</md-option>\n\t</md-select>\n</md-input-container>\n<md-input-container flex layout=\"row\" style=\"margin-top: 0px; margin-bottom: 5px;\">\n <input ng-model=\"frm.title\"\n ng-change=\"search()\"\n ng-model-options=\"{debounce:500}\"\n aria-label=\"Search\"\n type=\"text\"\n style=\"z-index:1\"/>\n</md-input-container>\n<md-input-container flex layout=\"row\" style=\"margin-top: 0px; margin-bottom: 5px;\">\n <md-button ng-click=\"search()\">Refresh</md-button>\n</md-input-container>\n\n<script>\n(function() {\n scope.search = function() {\n this.send({'action': 'search', 'payload': this.frm});\n }.bind(scope);\n \n (function(scope) {\n scope.channels=[];\n scope.frm = {'channel': '', 'title': ''};\n scope.send({action: \"loadChannels\"});\n scope.$watch('msg', function(msg) {\n if (msg && msg.action==='loadChannels') {\n scope.channels = msg.payload.entries;\n scope.search();\n }\n if (msg && msg.action==='refreshSearchResults') {\n scope.search();\n }\n });\n })(scope);\n})();\n</script>\n","storeOutMessages":false,"fwdInMessages":false,"templateScope":"local","x":568.5,"y":472,"wires":[["42328204.60e63c"]]},{"id":"a85b7574.928cd8","type":"function","z":"efebee1e.aecd3","name":"msg=[TVH Upcoming Recordings Query]","func":"var newMsg = {};\nvar filter = [];\nnewMsg.method='POST';\nnewMsg.tvhPath='/api/dvr/entry/grid_upcoming';\nnewMsg.headers = {};\nnewMsg.headers['content-type'] = 'application/x-www-form-urlencoded';\nnewMsg.payload = {\n \"start\": 0,\n \"limit\": 999999999,\n \"sort\": \"start_real\",\n \"dir\": \"ASC\",\n \"duplicates\": 0,\n}\nif (msg.payload.title) {\n filter.push({\"type\":\"string\",\"value\":msg.payload.title,\"field\":\"disp_title\"});\n}\nif (msg.payload.channel) {\n filter.push({\"type\":\"string\",\"value\":msg.payload.channel,\"field\":\"channel\"});\n}\nif (filter.length>0) {\n newMsg.payload.filter = util.format('%j',filter);\n}\nreturn newMsg;","outputs":1,"noerr":0,"x":360,"y":358,"wires":[["e733b03e.3cd15"]]},{"id":"57e380ee.90064","type":"ui_template","z":"efebee1e.aecd3","group":"cd4fc12b.22c4e","name":"Search Results","order":3,"width":"8","height":"12","format":"<div ng-repeat=\"e in msg.payload.entries\" nf-if=\"msg.payload.entries\">\n <p>{{e.channelname}} {{e.start*1000 | date:'EEEE, MMMM dd HH:mm'}}</p>\n <p>{{e.disp_title}} {{e.disp_subtitle}}</p>\n <div>\n <md-button ng-click=\"clickCancel(e)\" style=\"margin: 0;\" ng-if=\"e.uuid\">Cancel</md-button>\n <md-button ng-click=\"clickCancelSerie(e)\" style=\"margin: 0;\" ng-if=\"e.autorec\">Cancel Serie</md-button>\n </div>\n <hr/>\n</div>\n\n<script>\n scope.clickCancel = function(e) {\n this.send({'action':'deleteRecording', 'payload':e.uuid});\n }.bind(scope);\n \n scope.clickCancelSerie = function(e) {\n this.send({'action':'deleteRecording', 'payload':e.autorec});\n }.bind(scope);\n</script>","storeOutMessages":true,"fwdInMessages":false,"templateScope":"local","x":509,"y":426,"wires":[["e1d6416a.7c22a"]]},{"id":"9432df1f.80f73","type":"ui_group","z":"","name":"EPG Search","tab":"9edea04c.3c959","disp":true,"width":"8","collapse":false},{"id":"cd4fc12b.22c4e","type":"ui_group","z":"","name":"Upcoming Recordings","tab":"30679b66.5e1084","disp":true,"width":"8","collapse":false},{"id":"9edea04c.3c959","type":"ui_tab","z":"","name":"EPG Search","icon":"dashboard","disabled":false,"hidden":false},{"id":"30679b66.5e1084","type":"ui_tab","z":"","name":"Upcoming Recordings","icon":"dashboard","disabled":false,"hidden":false}]