sky-plus-hd
Version:
Node.js module for controlling and monitoring Sky+ HD set top boxes
693 lines (643 loc) • 20.5 kB
JavaScript
var _ = require('underscore'),
cheerio = require('cheerio'),
dgram = require('dgram'),
http = require('http'),
entities = new (require('html-entities').AllHtmlEntities)(),
EventEmitter = require('events').EventEmitter,
os = require('os'),
util = require('util'),
winston = require('winston'),
xml = require('xml'),
xmlparser = require('xml2json');
/* ====== */
var SkyPlusHD = module.exports = function(options) {
/* ===== DEFAULT OPTIONS ===== */
var defaultOptions = {
host: null,
port: 49153,
monitorHost: null,
monitorPort: 55555,
log: 'info'
};
options = _.extend(defaultOptions,options||{});
/* ===== LOGGER ===== */
var logger = new winston.Logger({
transports: [
new winston.transports.Console({
colorize: true,
level: options.log,
silent: !(options.log)
})
]
});
/* ==== PRIVATE VARS ===== */
var that = this;
var channelsData;
var last = {
uri: undefined,
state: undefined,
speed: undefined
};
var subscriptionSid;
/* ==== INIT ===== */
var _init_ = function() {
if (!options.monitorHost) {
options.monitorHost = guessLocalIP();
}
if (!options.host) {
that.detect(function(data) {
options.host = data.address;
that.emit('ready');
});
} else {
that.emit('ready');
}
};
/* ==== PRIVATE ===== */
var changeChannelId = function(id,fnCallback) {
changeChannelHexId(id.toString(16),fnCallback);
};
var changeChannelHexId = function(hexId,fnCallback) {
var xml = generateRequestXML([
{'u:SetAVTransportURI':[
{'_attr':{
'xmlns:u': 'urn:schemas-nds-com:service:SkyPlay:2'
}},
{
'InstanceID': 0
},
{
'CurrentURI': 'xsi://'+hexId
},
{
'CurrentURIMetaData': 'NOT_IMPLEMENTED'
}
]}
]);
soapRequest("SkyPlay:2#SetAVTransportURI",xml,'/SkyPlay2',function(response) {
_.isFunction(fnCallback) && fnCallback(response);
});
};
var decodeEnclosedXmlToJson = function(rawXml) {
var xml = entities.decode(entities.decode(rawXml)).replace(/([^=])"([a-zA-Z])/g,'$1" $2');
return decodeXmlToJson(xml);
}
var decodeXmlToJson = function(xml) {
return JSON.parse(xmlparser.toJson(xml,{sanitize:false}));
};
var fetchChannelListingPart = function(channelId,date,part,fnCallback) {
var httpParams = {
host: 'tv.sky.com',
port: 80,
path: '/programme/channel/'+channelId+'/'+date+'/'+part+'.json'
};
var progs = [];
var req = http.request(httpParams,function(res) {
res.setEncoding('utf8');
var chunks = "";
res.on('data',function(chunk) { chunks = chunks+chunk; });
res.on('end',function() {
var parsed = JSON.parse(chunks);
for (var i in parsed.listings[channelId]) {
var prog = parsed.listings[channelId][i];
progs.push(parseProgram(prog));
}
fnCallback(progs);
});
});
req.end();
};
var fetchProgramDetails = function(channelId,eventId,fnCallback) {
var httpParams = {
host: 'tv.sky.com',
port: 80,
path: '/programme/detail/'+channelId+'/'+eventId
};
var req = http.request(httpParams,function(res) {
res.setEncoding('utf8');
var chunks = "";
res.on('data',function(chunk) { chunks = chunks+chunk; });
res.on('end',function() {
////
var $ = cheerio.load(chunks);
var programInformation = $('.programme-information');
var episodeTitle = $('.episode-title',programInformation).text();
var seasonInfo = $('.programme-metadata dl dt:contains(Season)',programInformation).next().text().split('/');
var episodeInfo = $('.programme-metadata dl dt:contains(Episode)',programInformation).next().text().split('/');
var genres = _.map($('.programme-metadata .genres',programInformation).text().split(','),function(str) { return str.trim(); });
////
var data = {
season: +seasonInfo[0] || undefined,
seasonTotal: +seasonInfo[1] || undefined ,
episode: +episodeInfo[0] || undefined,
episodeTotal: +episodeInfo[1] || undefined,
episodeTitle: episodeTitle || undefined,
genres: genres,
};
//
_.isFunction(fnCallback) && fnCallback(data);
});
});
req.end();
};
var generateRequestXML = function(content) {
var json = [
{'s:Envelope': [
{'_attr': {
's:encodingStyle': 'http://schemas.xmlsoap.org/soap/encoding/',
'xmlns:s': 'http://schemas.xmlsoap.org/soap/envelope/'
}},
{'s:Body':content}
]}
];
return '<?xml version="1.0" encoding="utf-8"?>'+xml(json);
};
var getChannel = function(number) {
return _.find(loadChannelList(),function(c) {
return c.channel === number;
});
};
var getChannelById = function(id) {
return _.find(loadChannelList(),function(c) {
return c.channelId === id;
});
};
var getChannelByHexId = function(hexId) {
return _.find(loadChannelList(),function(c) {
return c.channelHexId === hexId.toUpperCase();
});
};
var getURIInformation = function(uri) {
var info;
if (uri.match(/^xsi:\/\//)) {
var channelHexId = uri.replace(/^xsi:\/\//,'');
info = {
broadcast: true,
channel: getChannelByHexId(channelHexId)
};
} else if (uri.match(/^file:\/\/pvr\//)) {
var pvrHexId = uri.replace(/^file:\/\/pvr\//,'');
info = {
broadcast: false,
pvrHexId: pvrHexId,
pvrId: parseInt(pvrHexId,16)
};
}
return info;
};
var guessLocalIP = function() {
logger.info('Guessing local IP...');
var ifaces=os.networkInterfaces();
for (var dev in ifaces) {
for (var i in ifaces[dev]) {
var details = ifaces[dev][i];
if (details.family==='IPv4' && !details.internal) {
logger.info('Guessed local IP as being',details.address);
return details.address;
}
}
}
logger.warn('Could not guess local IP');
};
var loadChannelList = function () {
if (channelsData) {
return channelsData;
}
var filename='./channels.json';
var channelsDataRaw = require(filename);
channelsData = [];
_.each(channelsDataRaw.init.channels,function(channelDataRaw) {
channelsData.push(parseChannel(channelDataRaw));
});
return channelsData;
};
var notificationsSubscribe = function(host,port,fnCallback) {
var subscriptionId = "/sky/monitor/NOTIFICATION/"+(new Date().valueOf());
logger.info("Requesting subscribition to notifications...");
var httpParams = {
host: options.host,
port: options.port,
path: '/SkyPlay2',
method: 'SUBSCRIBE',
headers: {
callback: "<http://"+host+":"+port+subscriptionId+">",
nt: 'upnp:event'
}
};
var req = http.request(httpParams,function(res) {
res.setEncoding('utf8');
var chunks = "";
res.on('data',function(chunk) { chunks = chunks+chunk; });
res.on('end',function() {
_.isFunction(fnCallback) && fnCallback();
});
subscriptionSid = res.headers.sid || null;
//
logger.info("Subscribed to notifications");
logger.info(" - sid: ",subscriptionSid);
logger.info(" - subscriptionId: ",subscriptionId);
});
req.end();
return subscriptionId;
};
var notificationsUnsubscribe = function(host,port,fnCallback) {
if (!subscriptionSid) {
logger.info("Not subscribed to notifications");
return;
}
logger.info("Unsubscribing from notifications...");
var httpParams = {
host: options.host,
port: options.port,
path: '/SkyPlay2',
method: 'UNSUBSCRIBE',
headers: {
SID: subscriptionSid
}
};
subscriptionSid = null;
var req = http.request(httpParams,function(res) {
res.setEncoding('utf8');
var chunks = "";
res.on('data',function(chunk) { chunks = chunks+chunk; });
res.on('end',function() {});
logger.info("Unsubscribed from notifications");
logger.info(" - sid: ",subscriptionSid);
_.isFunction(fnCallback) && (fnCallback(res));
});
req.end();
};
var parseChannel = function (channelDataRaw) {
return {
name: channelDataRaw.t,
channel: channelDataRaw.c[1],
channelId: channelDataRaw.c[0],
channelHexId: channelDataRaw.c[0].toString(16).toUpperCase(),
isHD: channelDataRaw.c[3]?true:false
};
};
var parseDuration = function(durationString) {
if (!durationString) return false;
var duration = 0;
var matches = durationString.match(/P(\d+)D(\d+):(\d+):(\d+)/);
duration += +matches[1] * 60*60*24;
duration += +matches[2] * 60*60;
duration += +matches[3] * 60;
duration += +matches[4];
return duration;
}
var parsePlannerData = function(plannerData) {
if (!_.isArray(plannerData)) plannerData = [plannerData];
return _.map(plannerData,function(plannerDataItem) {
var val = parsePlannerDataItem(plannerDataItem);
return val;
});
}
var parsePlannerDataItem = function(plannerDataItem) {
return {
id: plannerDataItem['id'],
title: plannerDataItem['dc:title'],
size: plannerDataItem.res.size,
uri: plannerDataItem.res['$t'],
description: plannerDataItem['dc:description'],
channel: getChannel(plannerDataItem['upnp:channelNr']),
viewed: (plannerDataItem['vx:X_isViewed']===1),
start: new Date(plannerDataItem['upnp:recordedStartDateTime']),
duration: parseDuration(plannerDataItem['upnp:recordedDuration'])
}
}
var parseProgram = function(programDataRaw) {
return {
start: new Date(programDataRaw.s*1000),
end: new Date((programDataRaw.s+programDataRaw.m[1]-1)*1000),
title: programDataRaw.t,
description: programDataRaw.d,
duration: programDataRaw.m[1],
image: (programDataRaw.img) ? 'http://epgstatic.sky.com/epgdata/1.0/paimage/18/0/'+programDataRaw.img : undefined,
url: programDataRaw.url,
eventId: programDataRaw.m[0]
};
};
var processNotification = function(notificationJSON) {
var changed = {};
var current = {
uri: notificationJSON.CurrentTrackURI.val,
state: notificationJSON.TransportState.val,
speed: notificationJSON.TransportPlaySpeed.val
};
//
for (var i in current) {
changed[i] = false;
if (i=='state' && _.contains(['STOPPED','TRANSITIONING'],current[i])) {
// Ignore these two states
} else if (current[i] !== last[i]) {
changed[i] = true;
last[i] = current[i];
};
};
//
if (changed.uri && current.uri) {
var uriInformation = getURIInformation(current.uri);
that.whatsOn(uriInformation.channel.channelId,function(whatsOn) {
var ev = uriInformation;
ev.program = whatsOn;
that.emit('change',ev);
//
logger.info('Change notification received at '+(new Date()).toString());
logger.info(' - ('+ev.channel.channel+') '+ev.channel.name);
logger.info(' - '+ev.program.now.title);
logger.info(' - '+ev.program.now.start+' - '+ev.program.now.end);
logger.info(' - '+Math.round(ev.program.now.duration/60)+' mins');
if (ev.program.now.details.season) {
logger.info(' - Season '+ev.program.now.details.season+', Episode '+ev.program.now.details.episode);
};
});
};
//
if (changed.state || changed.speed) {
var ev = {
state: undefined,
};
switch (current.state) {
case 'PLAYING':
ev.state = 'play'; break;
case 'PAUSED_PLAYBACK':
ev.state = 'pause'; break;
default:
ev.state = 'play'; break;
};
if (changed.speed) {
if (+current.speed > 1) {
ev.state = 'fwd';
ev.speed = Math.abs(+current.speed);
} else if (+current.speed < 0) {
ev.state = 'rwd';
ev.speed = Math.abs(+current.speed);
};
};
that.emit('changeState',ev);
//
logger.info('Change state notification received at '+(new Date()).toString());
logger.info(' - '+ev.state+((ev.speed)?' x'+ev.speed:''));
};
}
var readPlannerChunk = function(options,fnCallback) {
var that = this;
if (_.isFunction(options)) {
fnCallback = options;
options = undefined;
};
//
options = _.extend({
offset: 0,
limit: 25,
recurse: true
},options||{});
options.limit = Math.min(options.limit,25); // Make sure this doesn't exceed 25, because it won't work
//
var xml = generateRequestXML([
{'u:Browse':[
{'_attr':{
'xmlns:u': 'urn:schemas-nds-com:service:SkyBrowse:2'
}},
{
'ObjectID':3
},
{
'BrowseFlag': 'BrowseDirectChildren'
},
{
'Filter': '*'
},
{
'StartingIndex': options.offset
},
{
'RequestedCount': options.limit
},
{
'SortCriteria': []
}
]}
]);
soapRequest("SkyBrowse:2#Browse",xml,"/SkyBrowse2",function(res) {
var plannerData = decodeEnclosedXmlToJson(res['u:BrowseResponse']['Result'])['DIDL-Lite']['item'];
var plannerItems = plannerData ? parsePlannerData(plannerData) : [];
if (options.recurse && plannerItems.length == options.limit) {
var recurseOptions = _.extend(options,{
offset: (options.offset)?options.offset+options.limit:options.limit,
_isRecursive: true
});
readPlannerChunk(recurseOptions,function(recursedPlannerItems) {
fnCallback(plannerItems.concat(recursedPlannerItems));
});
} else {
_.isFunction(fnCallback) && fnCallback(plannerItems);
};
});
};
var soapRequest = function (soapAction,body,path,fnCallback) {
var httpParams = {
hostname: options.host,
port: options.port,
path: path,
method: 'POST',
headers: {
'USER-AGENT': 'SKY_skyplus',
'SOAPACTION': '"urn:schemas-nds-com:service:'+soapAction+'"',
'CONTENT-TYPE': 'text/xml; charset="utf-8"'
}
};
var req = http.request(httpParams, function(res) {
res.setEncoding('utf8');
var chunks = "";
res.on('data',function(chunk) {
chunks = chunks+chunk;
});
res.on('end',function() {
fnCallback(JSON.parse(xmlparser.toJson(chunks))['s:Envelope']['s:Body']);
});
});
req.write(body);
req.end();
req.on('error',function(e) {
console.log("ERROR IN COMMS",e.message,e);
});
};
var transmitMSearch = function() {
logger.info("Requesting for any SKY+HD boxes on the LAN to make themselves known");
var message = new Buffer([
'M-SEARCH * HTTP/1.1',
'HOST: 239.255.255.250:1900',
'MAN: "ssdp:discover"',
'ST: ssdp:all',
'MX: 1',
'',''
].join("\r\n"));
var client = dgram.createSocket("udp4");
client.bind(1900); // So that we get a port so we can listen before sending
client.send(message, 0, message.length, 1900, "239.255.255.250",function(err,bytes) {
client.close();
});
};
/* ==== PUBLIC ===== */
this.changeChannel = function(num) {
var c = getChannel(num);
logger.info("Changing channel to: ("+c.channel+") "+c.name);
changeChannelHexId(c.channelHexId);
};
this.close = function() {
logger.info("Starting graceful shutdown");
notificationsUnsubscribe();
logger.info("Bye");
};
this.detect = function(fnCallback) {
logger.info("Trying to auto-detect SKY+HD box...");
var server = dgram.createSocket('udp4');
server.on('message',function(msg,rinfo) {
if (String(msg).indexOf('redsonic') > 1) {
logger.info("SKY+HD box found at",rinfo.address);
fnCallback({
address: rinfo.address
});
server.close();
}
});
server.bind(1900);
transmitMSearch();
};
this.getChannelListing = function (channelId,fnCallback) {
var now = new Date(),
year = now.getFullYear(),
month = now.getUTCMonth() + 1,
date = now.getUTCDate(),
dateStr = year +'-'+ (month>9?month:'0'+month) +'-'+ (date>9?date:'0'+date),
progs = [];
var runCallback = _.after(4,function() {
progs = progs.sort(function(a,b) {
if (a.start.valueOf() === b.start.valueOf()) return 0;
return (a.start.valueOf() > b.start.valueOf()) ? 1 : -1;
});
fnCallback(progs);
});
_.times(4,function(i) {
fetchChannelListingPart(channelId,dateStr,i,function(progs_i) {
progs = progs.concat(progs_i);
runCallback();
});
});
};
this.getMediaInfo = function(fnCallback) {
var xml = generateRequestXML([
{'u:GetMediaInfo':[
{'_attr':{
'xmlns:u': 'urn:schemas-nds-com:service:SkyPlay:2'
}},
{
'InstanceID':0
}
]}
]);
soapRequest("SkyPlay:2#GetMediaInfo",xml,'/SkyPlay2',function(response) {
var currentURI = response['u:GetMediaInfoResponse']['CurrentURI'];
fnCallback(getURIInformation(currentURI));
});
};
this.monitor = function() {
var that = this;
var subscriptionId = notificationsSubscribe(options.monitorHost,options.monitorPort);
//
http.createServer(function(req,res) {
if (req.url !== subscriptionId) {
res.writeHead(404,{'Content-Type':'text/plain'});
res.end();
return;
}
var chunks = "";
req.on('data',function(chunk) { chunks += chunk; });
req.on('end',function() {
var jsonData = decodeXmlToJson(chunks);
var notificationJSON = decodeEnclosedXmlToJson(jsonData['e:propertyset']['e:property']['LastChange']).Event.InstanceID;
processNotification(notificationJSON);
});
res.writeHead(200,{'Content-Type':'text/plain'});
res.end('OK');
}).listen(options.monitorPort);
};
this.pause = function() {
var xml = generateRequestXML([
{'u:Pause':[
{'_attr':{
'xmlns:u': 'urn:schemas-nds-com:service:SkyPlay:2'
}},
{
'InstanceID':0
}
]}
]);
logger.info("Sending command: PAUSE");
soapRequest("SkyPlay:2#Pause",xml,'/SkyPlay2',function(response) {
logger.info("Sent command: PAUSE");
});
};
this.play = function() {
var xml = generateRequestXML([
{'u:Play':[
{'_attr':{
'xmlns:u': 'urn:schemas-nds-com:service:SkyPlay:2'
}},
{
'InstanceID':0
},
{
'Speed': 1
}
]}
]);
logger.info("Sending command: PLAY");
soapRequest("SkyPlay:2#Play",xml,'/SkyPlay2',function(response) {
logger.info("Sent command: PLAY");
});
};
this.readPlanner = function(options,fnCallback) {
if (_.isFunction(options)) {
fnCallback = options;
options = undefined;
};
logger.info("Reading PVR data...");
readPlannerChunk(options,function(items) {
logger.info(" - Found "+items.length+" PVR items");
fnCallback(items);
});
};
this.whatsOn = function (channelId,fnCallback) {
var httpParams = {
host: 'epgservices.sky.com',
port: 80,
path: '/5.1.1/api/2.0/channel/json/'+channelId+'/now/nn/4'
};
var req = http.request(httpParams,function(res) {
res.setEncoding('utf8');
var chunks = "";
res.on('data',function(chunk) { chunks = chunks+chunk; });
res.on('end',function() {
var parsed = JSON.parse(chunks);
var data = {
now: parseProgram(parsed.listings[channelId][0]),
next: parseProgram(parsed.listings[channelId][1])
};
fetchProgramDetails(channelId,data.now.eventId,function(programDetailsNow) {
data.now.details = programDetailsNow;
fetchProgramDetails(channelId,data.next.eventId,function(programDetailsNext) {
data.next.details = programDetailsNext;
fnCallback(data);
});
});
});
});
req.end();
};
/* ===== */
_init_();
};
util.inherits(SkyPlusHD,EventEmitter);