initial commit
This commit is contained in:
parent
fa2027eb13
commit
ca392ebb76
26
README.md
26
README.md
|
@ -1,2 +1,28 @@
|
|||
# denon-vtuner
|
||||
Private proxy to access vtuner internet radio list
|
||||
|
||||
My Denon receiver supports browsing the internet radio catalog of vtuner.com. Initially this service was accessible without extra costs, now the service costs five euros a year. But the website is still accessible without account.
|
||||
|
||||
This repo contains a local node.js server that can load the html pages from the web site and return the categories and stations in the xml format that the Denon receiver understands.
|
||||
|
||||
## My setup
|
||||
I have a Raspberry Pi that runs Pi-hole DNS. Hostname denon.vtuner.com is linked to the internal IP address of the RPi.
|
||||
|
||||
The RPi runs nginx to host the denon.vtuner.com website:
|
||||
```
|
||||
server {
|
||||
listen 192.168.1.2:80;
|
||||
server_name denon.vtuner.com;
|
||||
index index.html;
|
||||
location / {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $host;
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
```
|
||||
On local port 3000, it runs the node.js server.
|
||||
|
||||
|
|
|
@ -0,0 +1,258 @@
|
|||
const http = require("http");
|
||||
var XMLHttpRequest = require("xmlhttprequest").XMLHttpRequest;
|
||||
var { DOMParser} = require("xmldom");
|
||||
const serverurl = 'https://vtuner.com/setupapp/guide/asp/';
|
||||
|
||||
const statics = [
|
||||
{prefix: "/setupapp/denon/asp/BrowseXML/loginXML.asp",response:"<EncryptedToken>123456789abcdef0</EncryptedToken>"},
|
||||
{prefix: "/setupapp/denon/asp/browsexm2/loginXML.asp",response:"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\" ?><ListOfItems><ItemCount>-1</ItemCount>"+
|
||||
"<Item><ItemType>Dir</ItemType><Title>Genre</Title>"+
|
||||
"<UrlDir>http://denon.vtuner.com/setupapp/BrowseStations/StartPage.asp?sBrowseType=Format</UrlDir></Item>"+
|
||||
"<Item><ItemType>Dir</ItemType><Title>Location</Title>"+
|
||||
"<UrlDir>http://denon.vtuner.com/setupapp/BrowseStations/StartPage.asp?sBrowseType=Location</UrlDir></Item>"+
|
||||
"<Item><ItemType>Dir</ItemType><Title>Language</Title>"+
|
||||
"<UrlDir>http://denon.vtuner.com/setupapp/BrowseStations/StartPage.asp?sBrowseType=Language</UrlDir></Item>"+
|
||||
"<Item><ItemType>Dir</ItemType><Title>Nederland</Title>"+
|
||||
"<UrlDir>http://denon.vtuner.com/setupapp/BrowseStations/BrowsePremiumStations.asp?sCategory=Netherlands&sBrowseType=Location</UrlDir></Item>"+
|
||||
"<Item><ItemType>Dir</ItemType><Title>Nederlands</Title>"+
|
||||
"<UrlDir>http://denon.vtuner.com/setupapp/BrowseStations/StartPage.asp?sCategory=dut&sBrowseType=Language&sNiceLang=Dutch</UrlDir></Item>i"+
|
||||
"</ListOfItems>"},
|
||||
{prefix: "/setupapp/BrowseStations/StartPage.asp", responsefunc:(url, res1) => { BrowseGenres(url, res1);}},
|
||||
{prefix: "/setupapp/BrowseStations/BrowsePremiumStations.asp", responsefunc:(url, res1) => { BrowseStations(url, res1);}},
|
||||
{prefix: "/setupapp/BrowseStations/dynampls.asp", responsefunc:(url, res1) => {GetStation(url, res1);}}
|
||||
];
|
||||
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
let url = new URL("http://localhost"+req.url);
|
||||
console.log("received "+url.pathname + " and query "+url.search);
|
||||
var match = statics.filter((element) => {console.log(element); console.log(element.prefix); return (url.pathname.indexOf(element.prefix) === 0);} );
|
||||
console.log("match "+match);
|
||||
console.log("match "+match.length);
|
||||
if (match.length > 0) {
|
||||
console.log("match found");
|
||||
if (match[0].response !== undefined) {
|
||||
console.log("Const response");
|
||||
res.end(match[0].response);
|
||||
} else {
|
||||
console.log( "function response");
|
||||
match[0].responsefunc(url, res);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(3000, "localhost", () => {
|
||||
console.log("Listening for request");
|
||||
});
|
||||
function getWebpage(url, callback) {
|
||||
var webpage;
|
||||
let httpreq =new XMLHttpRequest();
|
||||
httpreq.open("GET", url, false);
|
||||
httpreq.responseType = "document";
|
||||
httpreq.send();
|
||||
return httpreq.responseText;
|
||||
}
|
||||
|
||||
function fixHtmlErrors(text) {
|
||||
text = text.replace(/<HEAD/, '<head');
|
||||
text = text.replace(/<\/HEAD>/g, '</head>');
|
||||
text = text.replace(/<A /g, '<a ');
|
||||
text = text.replace(/<\/A>/g, '</a>');
|
||||
text = text.replace(/<TR /g, '<tr ');
|
||||
text = text.replace(/<\/TR>/g, '</tr>');
|
||||
text = text.replace(/<TD /g, '<td ');
|
||||
text = text.replace(/<\/TD>/g, '</td>');
|
||||
return text;
|
||||
}
|
||||
|
||||
function getGenre(queryparams) {
|
||||
const vtunerurl=serverurl + "BrowseStations/StartPage.asp"+ queryparams;
|
||||
|
||||
let response = getWebpage(vtunerurl);
|
||||
var result = [];
|
||||
response = fixHtmlErrors(response);
|
||||
const doc = new DOMParser().parseFromString(response,'text/html');
|
||||
const links = doc.getElementsByTagName('td');
|
||||
for (let i = 0; i < links.length; i++) {
|
||||
if (links[i].getAttribute('class') !== 'links') { continue; }
|
||||
const anchors = links[i].getElementsByTagName('a');
|
||||
for (let j=0; j<anchors.length; j++) {
|
||||
const link = anchors[j];
|
||||
result.push({link: link.getAttribute('href').replace(/\s/g, '+'), name:link.firstChild.data, type: 'Dir'});
|
||||
}
|
||||
}
|
||||
result = result.sort((a, b) => {
|
||||
const nameA = a.name.toUpperCase(); // ignore upper and lowercase
|
||||
const nameB = b.name.toUpperCase(); // ignore upper and lowercase
|
||||
if (nameA < nameB) {
|
||||
return -1;
|
||||
}
|
||||
if (nameA > nameB) {
|
||||
return 1;
|
||||
}
|
||||
// names must be equal
|
||||
return 0;
|
||||
});
|
||||
const pagelinks = doc.getElementsByTagName('a');
|
||||
for (let i=0; i< pagelinks.length; i++) {
|
||||
if (pagelinks[i].getAttribute('class') !== 'paging') { continue; }
|
||||
result.push({link: pagelinks.getAttribute('href'), name: "page "+link.firstChild.data, type: 'Dir'});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getStationList(vtunerurl) {
|
||||
let response = getWebpage(vtunerurl);
|
||||
var result = [];
|
||||
response = fixHtmlErrors(response);
|
||||
const doc = new DOMParser().parseFromString(response,'text/html');
|
||||
const table = doc.getElementById('table2');
|
||||
if (table === undefined || table == null) {
|
||||
console.log("Cannot find table2 in page "+vtunerurl);
|
||||
console.log(response);
|
||||
exit;
|
||||
}
|
||||
console.log("isolated table");
|
||||
const links = table.getElementsByTagName('td');
|
||||
console.log("selected "+links.length+" table cells");
|
||||
for (let i = 0; i < links.length; i++) {
|
||||
if (links[i].getAttribute('class') !== 'links') {
|
||||
console.log('table cell #'+i+" is not class 'links', skipping");
|
||||
continue;
|
||||
}
|
||||
const anchors = links[i].childNodes;
|
||||
|
||||
console.log("table cell #"+i+" of class links has "+anchors.length+" anchors inside");
|
||||
|
||||
for (let j=0; j<anchors.length; j++) {
|
||||
var link = anchors[j];
|
||||
if (link.tagName != 'a')
|
||||
continue;
|
||||
var hrefAttr = link.getAttribute('Href');
|
||||
var name = link.firstChild.data;
|
||||
if (hrefAttr == undefined || hrefAttr == '' || name == undefined || name == '' )
|
||||
continue;
|
||||
console.log(hrefAttr);
|
||||
if (hrefAttr.indexOf('BrowsePremiumStations.asp') >= 0) {
|
||||
continue;
|
||||
}
|
||||
result.push({link: hrefAttr, name: name, type: 'Station'});
|
||||
console.log(result[result.length-1]);
|
||||
}
|
||||
}
|
||||
const pagelinks = doc.getElementsByTagName('a');
|
||||
for (let i=0; i< pagelinks.length; i++) {
|
||||
var link = pagelinks[i];
|
||||
if (link.getAttribute('class') !== 'paging') { continue; }
|
||||
console.log(link.attributes);
|
||||
result.push({link:link.getAttribute('HREF'), name: "page "+link.firstChild.firstChild.data, type: 'Dir'});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getStationRedirect(vtunerurl) {
|
||||
let response = getWebpage(vtunerurl);
|
||||
response = fixHtmlErrors(response);
|
||||
const doc = new DOMParser().parseFromString(response,'text/html');
|
||||
const audioElements = doc.getElementsByTagName('audio');
|
||||
console.log("selected "+audioElements.length+" audio elements");
|
||||
for (let i = 0; i < audioElements.length; i++) {
|
||||
var srcAttr = link.getAttribute('src');
|
||||
if (srcAttr == undefined || srcAttr == '' )
|
||||
continue;
|
||||
console.log(srcAttr);
|
||||
return(srcAttr);
|
||||
}
|
||||
const scripts = doc.getElementsByTagName('script');
|
||||
console.log("selected "+scripts.length+" script elements");
|
||||
for (let i=0; i<scripts.length; i++) {
|
||||
console.log(scripts[i].firstChild);
|
||||
if (scripts[i].firstChild == null)
|
||||
{
|
||||
console.log ("no script found");
|
||||
continue;
|
||||
}
|
||||
var scr=scripts[i].firstChild.data;
|
||||
pos = scr.indexOf("mp3: ");
|
||||
console.log("found link at pos "+pos);
|
||||
var rest = scr.substr(pos+6);
|
||||
console.log("link: " + rest.substr(0,80));
|
||||
var urllen = rest.indexOf("\"");
|
||||
srcurl = rest.substr(0, urllen);
|
||||
console.log( "found url '" + srcurl+ "'");
|
||||
return (srcurl);
|
||||
}
|
||||
console.log ("Station at "+vtunerurl+" response "+response+" could not be parsed, returning error mp3");
|
||||
return 'http://sc2.1.fm:8030/';
|
||||
}
|
||||
|
||||
|
||||
function EntryToItem(i, listentry) {
|
||||
let response = "<Item>";
|
||||
if (listentry.link.substr(0, 3) == '../') {
|
||||
listentry.link = listentry.link.replace('../', 'http://denon.vtuner.com/setupapp/');
|
||||
}
|
||||
if (listentry.type == 'Dir') {
|
||||
response += '<ItemType>'+listentry.type + "</ItemType><Title>" + listentry.name+ "</Title>"+
|
||||
"<UrlDir>"+listentry.link+"</UrlDir></Item>";
|
||||
} else if (listentry.type == 'Station') {
|
||||
listentry.link = listentry.link.replace('../', serverurl);
|
||||
response += '<ItemType>Station</ItemType>'+
|
||||
"<StationId>" + i+ "</StationId>"+
|
||||
"<StationName>" + listentry.name+ "</StationName>"+
|
||||
"<StationUrl>"+listentry.link+"</StationUrl></Item>";
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function BrowseGenres(url, res2) {
|
||||
let list = getGenre(url.search);
|
||||
|
||||
const header = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\" ?><ListOfItems><ItemCount>-1</ItemCount>";
|
||||
var body = "";
|
||||
const footer = "</ListOfItems>";
|
||||
for (let i=0; i< list.length; i++) {
|
||||
var entry = EntryToItem(i, list[i]);
|
||||
console.log(entry);
|
||||
body += entry;
|
||||
}
|
||||
res2.end(header + body + footer);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function BrowseStations(url, res) {
|
||||
//example: /setupapp/BrowseStations/BrowsePremiumStations.asp and query ?sCategory=Alternative&sBrowseType=Format&sNiceLOFO=Alternative&fver=1.754333&dlang=dut&startitems=1&enditems=10
|
||||
|
||||
if (url.pathname.indexOf('/setupapp/') != 0) {
|
||||
console.log ('unexpected base');
|
||||
};
|
||||
var pathname = url.pathname.replace('/setupapp/', '/');
|
||||
var search= url.search.replace(/&mac=[\w\d]*&/, '&');
|
||||
console.log('search '+url.search+' w/o mac: '+ search);
|
||||
let list = getStationList(serverurl + pathname + search);
|
||||
|
||||
const header = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\" ?><ListOfItems><ItemCount>-1</ItemCount>";
|
||||
var body = "";
|
||||
const footer = "</ListOfItems>";
|
||||
for (let i=0; i< list.length; i++) {
|
||||
var entry = EntryToItem(i, list[i]);
|
||||
console.log(entry);
|
||||
body += entry;
|
||||
}
|
||||
res.end(header + body + footer);
|
||||
}
|
||||
|
||||
function GetStation(url, res) {
|
||||
//example: /setupapp/BrowseStations/dynampls.asp?id=45518&k=c104fb13bec928a3e3cd0905e363be8097586d4657ba8ff55e852d0ddb815f7f
|
||||
|
||||
if (url.pathname.indexOf('/setupapp/') != 0) {
|
||||
console.log ('unexpected base');
|
||||
};
|
||||
var pathname = url.pathname.replace('/setupapp/', '/');
|
||||
let redirect = getStationRedirect(serverurl + pathname + url.search);
|
||||
var pathname = url.pathname.replace('/setupapp/', '/');
|
||||
res.writeHead(302, { "location":redirect} );
|
||||
res.end();
|
||||
}
|
Loading…
Reference in New Issue