initial commit

This commit is contained in:
AvD 2023-01-27 21:32:55 +01:00
parent fa2027eb13
commit ca392ebb76
2 changed files with 284 additions and 0 deletions

View File

@ -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.

258
server.js Normal file
View File

@ -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&amp;sBrowseType=Location</UrlDir></Item>"+
"<Item><ItemType>Dir</ItemType><Title>Nederlands</Title>"+
"<UrlDir>http://denon.vtuner.com/setupapp/BrowseStations/StartPage.asp?sCategory=dut&amp;sBrowseType=Language&amp;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();
}