Skip to content

Instantly share code, notes, and snippets.

@rsenden
Last active June 6, 2019 20:25
Show Gist options
  • Save rsenden/1cb5eced9e4f22bb2be1e86e33dbb25a to your computer and use it in GitHub Desktop.
Save rsenden/1cb5eced9e4f22bb2be1e86e33dbb25a to your computer and use it in GitHub Desktop.
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 the TVH Request node to match your TVHeadEnd installation:
    • Update URL with the correct host, port, and sub-path as necessary. The URL must be a properly formatted URL that ends with {{{tvhPath}}}, for example http://tvh.lan:9981/{{{tvhPath}}}, http://192.168.1.1:9981/{{{tvhPath}}} or https://traefik.lan/tvh/{{{tvhPath}}}
    • Add the credentials for your TVHeadEnd installation
    • If necessary, enable Enable secure (SSL/TLS) connection
    • If necessary, enable Use proxy
  • In the TVH Record Action sub-flow, update the msg=[TVH Action] function node to specify the DVR profile UUID's for single and series recordings
  • Deploy flow

Flow

EPG Search Upcoming Recordings

[{"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}]
@rsenden
Copy link
Author

rsenden commented Jun 6, 2019

Flow

EPG Search
Upcoming Recordings

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment