- Channel redirects will now render in the TV Guide.

- Programs shorter than 5 minutes will not appear in the TV Guide and will be treated the same way Flex time is treated normally.
- The Web UI now has its own TV Guide viewer so that you don't have to rely on clients to preview your channels' line ups.
- API: New endpoint to get what shows are being scheduled for a given time frame.
- Fix bug where some bad luck could cause two different XMLTVs to get written at once, generating a corrupt xmltv file.
This commit is contained in:
vexorian 2020-09-04 21:48:30 -04:00
parent a24ce52f41
commit 9bd3e9063b
12 changed files with 1188 additions and 140 deletions

View File

@ -15,6 +15,7 @@ const Plex = require('./src/plex');
const channelCache = require('./src/channel-cache');
const constants = require('./src/constants')
const ChannelDB = require("./src/dao/channel-db");
const TVGuideService = require("./src/tv-guide-service");
console.log(
` \\
@ -58,42 +59,64 @@ db.connect(process.env.DATABASE, ['channels', 'plex-servers', 'ffmpeg-settings',
initDB(db, channelDB)
const guideService = new TVGuideService(xmltv);
let xmltvInterval = {
interval: null,
lastRefresh: null,
updateXML: async () => {
let channels = await channelDB.getAllChannels()
channels.forEach( (channel) => {
// if we are going to go through the trouble of loading the whole channel db, we might
// as well take that opportunity to reduce stream loading times...
channelCache.saveChannelConfig( channel.number, channel );
});
channels.sort((a, b) => { return a.number < b.number ? -1 : 1 })
let xmltvSettings = db['xmltv-settings'].find()[0]
xmltv.WriteXMLTV(channels, xmltvSettings).then(async () => { // Update XML
try {
let channelNumbers = await channelDB.getAllChannelNumbers();
await Promise.all( channelNumbers.map( async (x) => {
return await channelCache.getChannelConfig(channelDB, x);
}) );
await guideService.refresh( await channelDB.getAllChannels(), 12*60*60*1000 );
xmltvInterval.lastRefresh = new Date()
console.log('XMLTV Updated at ', xmltvInterval.lastRefresh.toLocaleString())
let plexServers = db['plex-servers'].find()
for (let i = 0, l = plexServers.length; i < l; i++) { // Foreach plex server
var plex = new Plex(plexServers[i])
await plex.GetDVRS().then(async (dvrs) => { // Refresh guide and channel mappings
if (plexServers[i].arGuide)
plex.RefreshGuide(dvrs).then(() => { }, (err) => { console.error(err, i) })
if (plexServers[i].arChannels && channels.length !== 0)
plex.RefreshChannels(channels, dvrs).then(() => { }, (err) => { console.error(err, i) })
}).catch( (err) => {
console.log("Couldn't tell Plex to refresh channels for some reason.");
});
console.log('XMLTV Updated at ', xmltvInterval.lastRefresh.toLocaleString());
} catch (err) {
console.error("Unable to update TV guide?", err);
}
let plexServers = db['plex-servers'].find()
for (let i = 0, l = plexServers.length; i < l; i++) { // Foreach plex server
let plex = new Plex(plexServers[i])
let dvrs;
if ( !plexServers[i].arGuide && !plexServers[i].arChannels) {
continue;
}
}, (err) => {
console.error("Failed to write the xmltv.xml file. Something went wrong. Check your output directory via the web UI and verify file permissions?", err)
})
try {
dvrs = await plex.GetDVRS() // Refresh guide and channel mappings
} catch(err) {
console.error(`Couldn't get DVRS list from ${plexServers[i].name}. This error will prevent 'refresh guide' or 'refresh channels' from working for this Plex server. But it is NOT related to playback issues.` , err );
continue;
}
if (plexServers[i].arGuide) {
try {
await plex.RefreshGuide(dvrs);
} catch(err) {
console.error(`Couldn't tell Plex ${plexServers[i].name} to refresh guide for some reason. This error will prevent 'refresh guide' from working for this Plex server. But it is NOT related to playback issues.` , err);
}
}
if (plexServers[i].arChannels && channels.length !== 0) {
try {
await plex.RefreshChannels(channels, dvrs);
} catch(err) {
console.error(`Couldn't tell Plex ${plexServers[i].name} to refresh channels for some reason. This error will prevent 'refresh channels' from working for this Plex server. But it is NOT related to playback issues.` , err);
}
}
}
},
startInterval: () => {
let xmltvSettings = db['xmltv-settings'].find()[0]
if (xmltvSettings.refresh !== 0) {
xmltvInterval.interval = setInterval( async () => {
await xmltvInterval.updateXML()
try {
await xmltvInterval.updateXML()
} catch(err) {
console.error("update XMLTV error", err);
}
}, xmltvSettings.refresh * 60 * 60 * 1000)
}
},
@ -130,7 +153,7 @@ app.get('/version.js', (req, res) => {
app.use('/images', express.static(path.join(process.env.DATABASE, 'images')))
app.use(express.static(path.join(__dirname, 'web/public')))
app.use('/images', express.static(path.join(process.env.DATABASE, 'images')))
app.use(api.router(db, channelDB, xmltvInterval))
app.use(api.router(db, channelDB, xmltvInterval, guideService ))
app.use(video.router( channelDB, db))
app.use(hdhr.router)
app.listen(process.env.PORT, () => {

View File

@ -9,7 +9,7 @@ const PlexServerDB = require('./dao/plex-server-db');
const Plex = require("./plex.js");
module.exports = { router: api }
function api(db, channelDB, xmltvInterval) {
function api(db, channelDB, xmltvInterval, guideService ) {
let router = express.Router()
let plexServerDB = new PlexServerDB(channelDB, channelCache, db);
@ -351,9 +351,46 @@ function api(db, channelDB, xmltvInterval) {
console.error(err);
res.status(500).send("error");
}
})
router.get('/api/guide/status', async (req, res) => {
try {
let s = await guideService.getStatus();
res.send(s);
} catch(err) {
console.error(err);
res.status(500).send("error");
}
});
router.get('/api/guide/debug', async (req, res) => {
try {
let s = await guideService.get();
res.send(s);
} catch(err) {
console.error(err);
res.status(500).send("error");
}
});
router.get('/api/guide/channels/:number', async (req, res) => {
try {
let dateFrom = new Date(req.query.dateFrom);
let dateTo = new Date(req.query.dateTo);
let lineup = await guideService.getChannelLineup( req.params.number , dateFrom, dateTo );
if (lineup == null) {
console.log(`GET /api/guide/channels/${req.params.number} : 404 Not Found`);
res.status(404).send("Channel not found in TV guide");
} else {
res.send( lineup );
}
} catch (err) {
console.error(err);
res.status(500).send("error");
}
});
//HDHR SETTINGS
router.get('/api/hdhr-settings', (req, res) => {
@ -430,7 +467,6 @@ function api(db, channelDB, xmltvInterval) {
res.status(500).send("error");
}
})
// hls.m3u Download is not really working correctly right now

View File

@ -1,6 +1,8 @@
module.exports = {
SLACK: 9999,
TVGUIDE_MAXIMUM_PADDING_LENGTH_MS: 30*60*1000,
STEALTH_DURATION: 5 * 60* 1000,
TVGUIDE_MAXIMUM_FLEX_DURATION : 6 * 60 * 60 * 1000,
VERSION_NAME: "0.0.66-prerelease"
}

View File

@ -48,8 +48,23 @@ class Plex {
})
})
}
Get(path, optionalHeaders = {}) {
var req = {
doRequest(req) {
return new Promise( (resolve, reject) => {
request( req, (err, res) => {
if (err) {
reject(err);
} else if ((res.statusCode < 200) || (res.statusCode >= 300) ) {
reject( Error(`Request returned status code ${res.statusCode}`) );
} else {
resolve(res);
}
});
});
}
async Get(path, optionalHeaders = {}) {
let req = {
method: 'get',
url: `${this.URL}${path}`,
headers: this._headers,
@ -57,19 +72,14 @@ class Plex {
}
Object.assign(req, optionalHeaders)
req.headers['X-Plex-Token'] = this._accessToken
return new Promise((resolve, reject) => {
if (this._accessToken === '')
reject("No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.")
else
request(req, (err, res) => {
if (err || res.statusCode !== 200)
reject(`Plex 'Get' request failed. URL: ${this.URL}${path}`)
else
resolve(JSON.parse(res.body).MediaContainer)
})
})
if (this._accessToken === '') {
throw Error("No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.");
} else {
let res = await this.doRequest(req);
return JSON.parse(res.body).MediaContainer;
}
}
Put(path, query = {}, optionalHeaders = {}) {
async Put(path, query = {}, optionalHeaders = {}) {
var req = {
method: 'put',
url: `${this.URL}${path}`,
@ -79,7 +89,7 @@ class Plex {
}
Object.assign(req, optionalHeaders)
req.headers['X-Plex-Token'] = this._accessToken
return new Promise((resolve, reject) => {
await new Promise((resolve, reject) => {
if (this._accessToken === '')
reject("No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.")
else
@ -123,30 +133,42 @@ class Plex {
}
}
async GetDVRS() {
var result = await this.Get('/livetv/dvrs')
var dvrs = result.Dvr
dvrs = typeof dvrs === 'undefined' ? [] : dvrs
return dvrs
try {
var result = await this.Get('/livetv/dvrs')
var dvrs = result.Dvr
dvrs = typeof dvrs === 'undefined' ? [] : dvrs
return dvrs
} catch (err) {
throw Error( "GET /livetv/drs failed: " + err.message);
}
}
async RefreshGuide(_dvrs) {
var dvrs = typeof _dvrs !== 'undefined' ? _dvrs : await this.GetDVRS()
for (var i = 0; i < dvrs.length; i++)
this.Post(`/livetv/dvrs/${dvrs[i].key}/reloadGuide`).then(() => { }, (err) => { console.log(err) })
try {
var dvrs = typeof _dvrs !== 'undefined' ? _dvrs : await this.GetDVRS()
for (var i = 0; i < dvrs.length; i++) {
await this.Post(`/livetv/dvrs/${dvrs[i].key}/reloadGuide`);
}
} catch (err) {
throw Error("Zort", err);
}
}
async RefreshChannels(channels, _dvrs) {
var dvrs = typeof _dvrs !== 'undefined' ? _dvrs : await this.GetDVRS()
var _channels = []
let qs = {}
for (var i = 0; i < channels.length; i++)
for (var i = 0; i < channels.length; i++) {
_channels.push(channels[i].number)
}
qs.channelsEnabled = _channels.join(',')
for (var i = 0; i < _channels.length; i++) {
qs[`channelMapping[${_channels[i]}]`] = _channels[i]
qs[`channelMappingByKey[${_channels[i]}]`] = _channels[i]
}
for (var i = 0; i < dvrs.length; i++)
for (var y = 0; y < dvrs[i].Device.length; y++)
this.Put(`/media/grabbers/devices/${dvrs[i].Device[y].key}/channelmap`, qs).then(() => { }, (err) => { console.log(err) })
for (var i = 0; i < dvrs.length; i++) {
for (var y = 0; y < dvrs[i].Device.length; y++) {
await this.Put(`/media/grabbers/devices/${dvrs[i].Device[y].key}/channelmap`, qs);
}
}
}
}

441
src/tv-guide-service.js Normal file
View File

@ -0,0 +1,441 @@
const constants = require("./constants");
const FALLBACK_ICON = "https://raw.githubusercontent.com/vexorain/dizquetv/main/resources/dizquetv.png";
class TVGuideService
{
/****
*
**/
constructor(xmltv) {
this.cached = null;
this.lastUpdate = 0;
this.updateTime = 0;
this.currentUpdate = -1;
this.currentLimit = -1;
this.currentChannels = null;
this.throttleX = 0;
this.doThrottle = false;
this.xmltv = xmltv;
}
async get() {
while (this.cached == null) {
await _wait(100);
}
this.doThrottle = true;
return this.cached;
}
async refresh(channels, limit) {
let t = (new Date()).getTime();
this.updateTime = t;
this.updateLimit = t + limit;
this.updateChannels = channels;
while( this.lastUpdate < t) {
if (this.currentUpdate == -1) {
this.currentUpdate = this.updateTime;
this.currentLimit = this.updateLimit;
this.currentChannels = this.updateChannels;
await this.buildIt();
}
await _wait(100);
}
return await this.get();
}
async makeAccumulated(channel) {
let n = channel.programs.length;
let arr = new Array( channel.programs.length + 1);
arr[0] = 0;
for (let i = 0; i < n; i++) {
arr[i+1] = arr[i] + channel.programs[i].duration;
await this._throttle();
}
return arr;
}
async getCurrentPlayingIndex(channel, t) {
let s = (new Date(channel.startTime)).getTime();
if (t < s) {
//it's flex time
return {
index : -1,
start : t,
program : {
isOffline : true,
duration : s - t,
}
}
} else {
let accumulate = this.accumulateTable[ channel.number ];
if (typeof(accumulate) === 'undefined') {
throw Error(channel.number + " wasn't preprocesed correctly???!?");
}
let hi = channel.programs.length;
let lo = 0;
let d = (t - s) % (accumulate[channel.programs.length]);
let epoch = t - d;
while (lo + 1 < hi) {
let ha = Math.floor( (lo + hi) / 2 );
if (accumulate[ha] > d) {
hi = ha;
} else {
lo = ha;
}
}
if (epoch + accumulate[lo+1] <= t) {
throw Error("General algorithm error, completely unexpected");
}
await this._throttle();
return {
index: lo,
start: epoch + accumulate[lo],
program: channel.programs[lo],
}
}
}
async getChannelPlaying(channel, previousKnown, t, depth) {
if (typeof(depth) === 'undefined') {
depth = [];
}
let playing = {};
if (
(typeof(previousKnown) !== 'undefined')
&& (previousKnown.program.duration == channel.programs[previousKnown.index].duration )
&& (previousKnown.start + previousKnown.program.duration == t)
) {
//turns out we know the index.
let index = (previousKnown.index + 1) % channel.programs.length;
playing = {
index : index,
program: channel.programs[index],
start : t,
}
} else {
playing = await this.getCurrentPlayingIndex(channel, t);
}
if ( playing.program.isOffline && playing.program.type === 'redirect') {
let ch2 = playing.program.channel;
if (depth.indexOf(ch2) != -1) {
console.error("Redirrect loop found! Involved channels = " + JSON.stringify(depth) );
} else {
depth.push( channel.number );
let channel2 = this.channelsByNumber[ch2];
if (typeof(channel2) === undefined) {
console.error("Redirrect to an unknown channel found! Involved channels = " + JSON.stringify(depth) );
} else {
let otherPlaying = await this.getChannelPlaying( channel2, undefined, t, depth );
let start = Math.max(playing.start, otherPlaying.start);
let duration = Math.min(
(playing.start + playing.program.duration) - start,
(otherPlaying.start + otherPlaying.program.duration) - start
);
let program2 = clone( otherPlaying.program );
program2.duration = duration;
playing = {
index: playing.index,
start : start,
program: program2,
}
}
}
}
return playing;
}
async getChannelPrograms(t0, t1, channel) {
let result = {
channel: makeChannelEntry(channel),
};
let programs = [];
let x = await this.getChannelPlaying(channel, undefined, t0);
if (x.program.duration == 0) throw Error("A " + channel.name + " " + JSON.stringify(x) );
let push = async (x) => {
await this._throttle();
if (
(programs.length > 0)
&& isProgramFlex(x.program)
&& (
(x.program.duration <= constants.TVGUIDE_MAXIMUM_PADDING_LENGTH_MS)
|| isProgramFlex(programs[ programs.length - 1].program)
)
) {
//meld with previous
let y = clone( programs[ programs.length - 1] );
y.program.duration += x.program.duration;
programs[ programs.length - 1] = y;
} else if (isProgramFlex(x.program) ) {
if (programs.length > 0) {
let y = programs[ programs.length - 1];
let a = y.start;
let b = a + y.program.duration;
let a2 = x.start;
if (b > a2) {
throw Error( [ "darn0", b, a2, JSON.stringify(y) , JSON.stringify(x) ] );
}
}
programs.push( {
start: x.start,
program: {
isOffline : true,
duration: x.program.duration,
},
} );
} else {
if (programs.length > 0) {
let y = programs[ programs.length - 1];
let a = y.start;
let b = a + y.program.duration;
let a2 = x.start;
if (b > a2) {
throw Error( [ "darn", b, a2, JSON.stringify(y) , JSON.stringify(x) ] );
}
}
programs.push(x);
}
};
while (x.start < t1) {
await push(x);
x = await this.getChannelPlaying(channel, x, x.start + x.program.duration);
if (x.program.duration == 0) throw Error("D");
}
result.programs = [];
for (let i = 0; i < programs.length; i++) {
await this._throttle();
if (isProgramFlex( programs[i].program) ) {
let start = programs[i].start;
let duration = programs[i].program.duration;
if (start <= t0) {
const M = 5*60*1000;
let newStart = t0 - t0%M;
if (start < newStart) {
duration -= (newStart - start);
start = newStart;
}
}
while( start < t1 && duration > 0) {
let d = Math.min(duration, constants.TVGUIDE_MAXIMUM_FLEX_DURATION);
if (duration - constants.TVGUIDE_MAXIMUM_FLEX_DURATION <= constants.TVGUIDE_MAXIMUM_PADDING_LENGTH_MS) {
d = duration;
}
let x = {
start: start,
program: {
isOffline: true,
duration: d,
}
}
duration -= d;
start += d;
result.programs.push( makeEntry(channel,x) );
}
} else {
if (i > 0) {
let y = programs[ i - 1];
let x = programs[i];
let a = y.start;
let b = a + y.program.duration;
let a2 = x.start;
if (b > a2) {
console.error( "darn2", b, a2 );
}
}
result.programs.push( makeEntry(channel, programs[i] ) );
}
}
return result;
}
async buildItManaged() {
let t0 = this.currentUpdate;
let t1 = this.currentLimit;
let channels = this.currentChannels;
let accumulateTable = {};
this.channelsByNumber = {};
for (let i = 0; i < channels.length; i++) {
this.channelsByNumber[ channels[i].number ] = channels[i];
accumulateTable[ channels[i].number ] = await this.makeAccumulated(channels[i]);
}
this.accumulateTable = accumulateTable;
let result = {};
if (channels.length == 0) {
result[1] = {
channel : {
name: "dizqueTV",
icon: FALLBACK_ICON,
},
programs: [
makeEntry( {
start: t0 - t0 % (30 * 60*1000),
program: {
icon: FALLBACK_ICON,
title: "No channels configured",
date: (new Date()).format('YYYY-MM-DD'),
summary : "Use the dizqueTV web UI to configure channels."
}
} )
]
}
} else {
for (let i = 0; i < channels.length; i++) {
let programs = await this.getChannelPrograms(t0, t1, channels[i] );
result[ channels[i].number ] = programs;
}
}
return result;
}
async buildIt() {
try {
console.log("<buildit>");
this.cached = await this.buildItManaged();
console.log("</buildit>");
console.log("Internal TV Guide data refreshed at " + (new Date()).toLocaleString() );
await this.refreshXML();
} catch(err) {
if (this.cached == null) {
throw err;
} else {
console.error("Unable to update internal guide data", err);
}
} finally {
this.lastUpdate = this.currentUpdate;
this.currentUpdate = -1;
}
}
async _throttle() {
//this.doThrottle = true;
if ( this.doThrottle && (this.throttleX++)%10 == 0) {
await _wait(0);
}
}
async refreshXML() {
let xmltvSettings = {
file : "./.dizquetv/xmltv.xml",
}
await this.xmltv.WriteXMLTV(this.cached, xmltvSettings, async() => await this._throttle() );
}
async getStatus() {
await this.get();
let channels = [];
Object.keys( this.cached )
.forEach( (k,index) => channels.push(k) );
return {
lastUpdate : new Date(this.lastUpdate).toISOString(),
channelNumbers: channels,
}
}
async getChannelLineup(channelNumber, dateFrom, dateTo) {
await this.get();
let t0 = dateFrom.toISOString();
let t1 = dateTo.toISOString();
let channel = this.cached[channelNumber];
if (typeof(channel) === undefined) {
return null;
}
let programs = channel.programs;
let result = {
icon: channel.channel.icon,
name: channel.channel.name,
number: channel.channel.number,
programs: [],
};
for (let i = 0; i < programs.length; i++) {
let program = programs[i];
let a;
if (program.start > t0) {
a = program.start;
} else {
a = t0;
}
let b;
if (program.stop < t1) {
b = program.stop;
} else {
b = t1;
}
if (a < b) {
result.programs.push( program );
}
}
return result;
}
}
function _wait(t) {
return new Promise((resolve) => {
setTimeout(resolve, t);
});
}
function isProgramFlex(program) {
return program.isOffline || program.duration <= constants.STEALTH_DURATION
}
function clone(o) {
return JSON.parse( JSON.stringify(o) );
}
function makeChannelEntry(channel) {
return {
name: channel.name,
icon: channel.icon,
number: channel.number,
}
}
function makeEntry(channel, x) {
let title = undefined;
let icon = undefined;
let sub = undefined;
if (isProgramFlex(x.program)) {
title = channel.name;
icon = channel.icon;
} else {
title = x.program.showTitle;
if (typeof(x.program.icon) !== 'undefined') {
icon = x.program.icon;
}
if (x.program.type === 'episode') {
sub = {
season: x.program.season,
episode: x.program.episode,
title: x.program.title,
}
}
}
//what data is needed here?
return {
start: (new Date(x.start)).toISOString(),
stop: (new Date(x.start + x.program.duration)).toISOString(),
summary: x.program.summary,
date: x.program.date,
rating: x.program.rating,
icon: icon,
title: title,
sub: sub,
}
}
module.exports = TVGuideService

View File

@ -5,35 +5,23 @@ const constants = require('./constants')
module.exports = { WriteXMLTV: WriteXMLTV }
function WriteXMLTV(channels, xmlSettings) {
function WriteXMLTV(json, xmlSettings, throttle ) {
return new Promise((resolve, reject) => {
let date = new Date()
let ws = fs.createWriteStream(xmlSettings.file)
let xw = new XMLWriter(true, (str, enc) => ws.write(str, enc))
ws.on('close', () => { resolve() })
ws.on('error', (err) => { reject(err) })
_writeDocStart(xw)
async function middle() {
if (channels.length === 0) { // Write Dummy dizqueTV Channel if no channel exists
_writeChannels(xw, [{ number: 1, name: "dizqueTV", icon: "https://raw.githubusercontent.com/vexorain/dizquetv/main/resources/dizquetv.png" }])
let program = {
program: {
type: 'movie',
title: 'No Channels Configured',
summary: 'Configure your channels using the dizqueTV Web UI.'
},
channel: '1',
start: date,
stop: new Date(date.valueOf() + xmlSettings.cache * 60 * 60 * 1000)
}
await _writeProgramme(null, xw, program, null)
} else {
_writeChannels(xw, channels)
for (let i = 0; i < channels.length; i++) {
await _writePrograms(xw, channels[i], date, xmlSettings.cache)
async function middle() {
let channelNumbers = [];
Object.keys(json).forEach( (key, index) => channelNumbers.push(key) );
let channels = channelNumbers.map( (number) => json[number].channel );
_writeChannels( xw, channels );
for (let i = 0; i < channelNumbers.length; i++) {
let number = channelNumbers[i];
await _writePrograms(xw, json[number].channel, json[number].programs, throttle);
}
}
}
middle().then( () => {
_writeDocEnd(xw, ws)
}).catch( (err) => {
@ -69,104 +57,65 @@ function _writeChannels(xw, channels) {
}
}
async function _writePrograms(xw, channel, date, cache) {
let item = helperFuncs.getCurrentProgramAndTimeElapsed(date.getTime(), channel)
let prog = item.program;
let cutoff = new Date( date.valueOf() + (cache * 60 * 60 * 1000) )
let temp = new Date(date.valueOf() - item.timeElapsed)
if (channel.programs.length === 0) {
return
}
let i = item.programIndex;
for (; temp < cutoff;) {
await _throttle(); //let's not block for this process
let program = {
program: prog,
channel: channel.number,
start: new Date(temp.valueOf()),
stop: new Date(temp.valueOf() + prog.duration)
}
let ni = (i + 1) % channel.programs.length;
if (
( (typeof(program.program.isOffline) === 'undefined') || !(program.program.isOffline) )
&&
(channel.programs[ni].isOffline)
&&
(channel.programs[ni].duration < constants.TVGUIDE_MAXIMUM_PADDING_LENGTH_MS )
) {
program.stop = new Date(temp.valueOf() + prog.duration + channel.programs[ni].duration)
i = (i + 2) % channel.programs.length;
} else {
i = ni;
}
_writeProgramme(channel, xw, program, cutoff)
temp = program.stop;
prog = channel.programs[i];
async function _writePrograms(xw, channel, programs, throttle) {
for (let i = 0; i < programs.length; i++) {
await throttle();
await _writeProgramme(channel, programs[i], xw);
}
}
async function _writeProgramme(channel, xw, program, cutoff) {
async function _writeProgramme(channel, program, xw) {
// Programme
xw.startElement('programme')
xw.writeAttribute('start', _createXMLTVDate(program.start))
xw.writeAttribute('stop', _createXMLTVDate(program.stop))
xw.writeAttribute('channel', program.channel)
xw.writeAttribute('stop', _createXMLTVDate(program.stop ))
xw.writeAttribute('channel', channel.number)
// Title
xw.startElement('title')
xw.writeAttribute('lang', 'en')
xw.text(program.title);
xw.endElement();
xw.writeRaw('\n <previously-shown/>')
if (program.program.isOffline) {
xw.text(channel.name)
xw.endElement()
} else if (program.program.type === 'episode') {
xw.text(program.program.showTitle)
xw.endElement()
xw.writeRaw('\n <previously-shown/>')
// Sub-Title
//sub-title
if ( typeof(program.sub) !== 'undefined') {
xw.startElement('sub-title')
xw.writeAttribute('lang', 'en')
xw.text(program.program.title)
xw.text(program.sub.title)
xw.endElement()
// Episode-Number
xw.startElement('episode-num')
xw.writeAttribute('system', 'xmltv_ns')
xw.text((program.program.season - 1) + ' . ' + (program.program.episode - 1) + ' . 0/1')
xw.endElement()
} else {
xw.text(program.program.title)
xw.text((program.sub.season - 1) + ' . ' + (program.sub.episode - 1) + ' . 0/1')
xw.endElement()
}
// Icon
if (typeof program.program.icon !== 'undefined') {
if (typeof program.icon !== 'undefined') {
xw.startElement('icon')
xw.writeAttribute('src', program.program.icon)
xw.writeAttribute('src', program.icon)
xw.endElement()
}
// Desc
xw.startElement('desc')
xw.writeAttribute('lang', 'en')
if (typeof(program.program.summary) !== 'undefined') {
xw.text(program.program.summary)
if ( (typeof(program.summary) !== 'undefined') && (program.summary.length > 0) ) {
xw.text(program.summary)
} else {
xw.text(channel.name)
}
xw.endElement()
// Rating
if (typeof program.program.rating !== 'undefined') {
if (typeof program.rating !== 'undefined') {
xw.startElement('rating')
xw.writeAttribute('system', 'MPAA')
xw.writeElement('value', program.program.rating)
xw.writeElement('value', program.rating)
xw.endElement()
}
// End of Programme
xw.endElement()
}
function _createXMLTVDate(d) {
try {
return d.toISOString().substring(0,19).replace(/[-T:]/g,"") + " +0000";
} catch(e) {
return (new Date()).toISOString().substring(0,19).replace(/[-T:]/g,"") + " +0000";
}
return d.substring(0,19).replace(/[-T:]/g,"") + " +0000";
}
function _throttle() {
return new Promise((resolve) => {

View File

@ -25,6 +25,7 @@ app.directive('channelConfig', require('./directives/channel-config'))
app.controller('settingsCtrl', require('./controllers/settings'))
app.controller('channelsCtrl', require('./controllers/channels'))
app.controller('versionCtrl', require('./controllers/version'))
app.controller('guideCtrl', require('./controllers/guide'))
app.config(function ($routeProvider) {
$routeProvider
@ -36,6 +37,10 @@ app.config(function ($routeProvider) {
templateUrl: "views/channels.html",
controller: 'channelsCtrl'
})
.when("/guide", {
templateUrl: "views/guide.html",
controller: 'guideCtrl'
})
.when("/version", {
templateUrl: "views/version.html",
controller: 'versionCtrl'

342
web/controllers/guide.js Normal file
View File

@ -0,0 +1,342 @@
const MINUTE = 60 * 1000;
module.exports = function ($scope, $timeout, dizquetv) {
$scope.offset = 0;
$scope.M = 60 * MINUTE;
$scope.zoomLevel = 3
$scope.T = 190 * MINUTE;
$scope.before = 15 * MINUTE;
$scope.enableNext = false;
$scope.enableBack = false;
$scope.showNow = false;
$scope.nowPosition = 0;
const intl = new Intl.DateTimeFormat('default',
{
hour12: true,
hour: 'numeric',
minute: 'numeric'
});
let hourMinute = (d) => {
return intl.format(d);
};
$scope.updateBasics = () => {
$scope.channelNumberWidth = 5;
$scope.channelIconWidth = 8;
$scope.channelWidth = $scope.channelNumberWidth + $scope.channelIconWidth;
//we want 1 minute = 1 colspan
$scope.colspanPercent = (100 - $scope.channelWidth) / ($scope.T / MINUTE);
$scope.channelColspan = Math.floor($scope.channelWidth / $scope.colspanPercent);
$scope.channelNumberColspan = Math.floor($scope.channelNumberWidth / $scope.colspanPercent);
$scope.channelIconColspan = $scope.channelColspan - $scope.channelNumberColspan;
$scope.totalSpan = Math.floor($scope.T / MINUTE);
$scope.colspanPercent = (100 - $scope.channelWidth) / ($scope.T / MINUTE);
$scope.channelColspan = Math.floor($scope.channelWidth / $scope.colspanPercent);
$scope.channelNumberColspan = Math.floor($scope.channelNumberWidth / $scope.colspanPercent);
$scope.channelIconColspan = $scope.channelColspan - $scope.channelNumberColspan;
}
$scope.updateBasics();
$scope.channelNumberWidth = 5;
$scope.channelIconWidth = 8;
$scope.channelWidth = $scope.channelNumberWidth + $scope.channelIconWidth;
//we want 1 minute = 1 colspan
$scope.applyLater = () => {
$timeout( () => $scope.$apply(), 0 );
};
$scope.channelNumbers = [];
$scope.channels = {};
$scope.lastUpdate = -1;
$scope.updateJustNow = () => {
$scope.t1 = (new Date()).getTime();
if ($scope.t0 <= $scope.t1 && $scope.t1 < $scope.t0 + $scope.T) {
let n = ($scope.t1 - $scope.t0) / MINUTE;
$scope.nowPosition = ($scope.channelColspan + n) * $scope.colspanPercent
if ($scope.nowPosition >= 50 && $scope.offset >= 0) {
$scope.offset = 0;
$scope.adjustZoom();
}
$scope.showNow = true;
} else {
$scope.showNow = false;
}
}
$scope.nowTimer = () => {
$scope.updateJustNow();
$timeout( () => $scope.nowTimer() , 10000);
}
$timeout( () => $scope.nowTimer() , 10000);
$scope.refreshManaged = async (skipStatus) => {
$scope.t1 = (new Date()).getTime();
$scope.t1 = ($scope.t1 - $scope.t1 % MINUTE );
$scope.t0 = $scope.t1 - $scope.before + $scope.offset;
$scope.title = "TV Guide";
$scope.times = [];
$scope.updateJustNow();
let pending = 0;
let addDuration = (d) => {
let m = (pending + d) % MINUTE;
let r = (pending + d) - m;
pending = m;
return Math.floor( r / MINUTE );
}
let deleteIfZero = () => {
if ( $scope.times.length > 0 && $scope.times[$scope.times.length - 1].duration < 1) {
$scope.times = $scope.times.slice(0, $scope.times.length - 1);
}
}
let rem = $scope.T;
let t = $scope.t0;
if (t % $scope.M != 0) {
let dif = $scope.M - t % $scope.M;
$scope.times.push( {
duration : addDuration(dif),
} );
deleteIfZero();
t += dif;
rem -= dif;
}
while (rem > 0) {
let d = Math.min(rem, $scope.M );
$scope.times.push( {
duration : addDuration(d),
label: hourMinute( new Date(t) ),
} );
t += d;
rem -= d;
}
if (skipStatus !== true) {
$scope.channelNumbers = [0];
$scope.channels = {} ;
$scope.channels[0] = {
loading: true,
}
$scope.applyLater();
console.log("getting status...");
let status = await dizquetv.getGuideStatus();
$scope.lastUpdate = new Date(status.lastUpdate).getTime();
console.log("got status: " + JSON.stringify(status) );
$scope.channelNumbers = status.channelNumbers;
$scope.channels = {} ;
}
for (let i = 0; i < $scope.channelNumbers.length; i++) {
if ( typeof($scope.channels[$scope.channelNumbers[i]]) === 'undefined') {
$scope.channels[$scope.channelNumbers[i]] = {};
}
$scope.channels[$scope.channelNumbers[i]].loading = true;
}
$scope.applyLater();
$scope.enableBack = false;
$scope.enableNext = false;
await Promise.all($scope.channelNumbers.map( $scope.loadChannel) );
};
$scope.adjustZoom = async() => {
switch ($scope.zoomLevel) {
case 1:
$scope.T = 50 * MINUTE;
$scope.M = 10 * MINUTE;
$scope.before = 5 * MINUTE;
break;
case 2:
$scope.T = 100 * MINUTE;
$scope.M = 15 * MINUTE;
$scope.before = 10 * MINUTE;
break;
case 3:
$scope.T = 190 * MINUTE;
$scope.M = 30 * MINUTE;
$scope.before = 15 * MINUTE;
break;
case 4:
$scope.T = 270 * MINUTE;
$scope.M = 60 * MINUTE;
$scope.before = 15 * MINUTE;
break;
case 5:
$scope.T = 380 * MINUTE;
$scope.M = 90 * MINUTE;
$scope.before = 15 * MINUTE;
break;
}
$scope.updateBasics();
await $scope.refresh(true);
}
$scope.zoomOut = async() => {
$scope.zoomLevel = Math.min( 5, $scope.zoomLevel + 1 );
await $scope.adjustZoom();
}
$scope.zoomIn = async() => {
$scope.zoomLevel = Math.max( 1, $scope.zoomLevel - 1 );
await $scope.adjustZoom();
}
$scope.zoomOutEnabled = () => {
return $scope.zoomLevel < 5;
}
$scope.zoomInEnabled = () => {
return $scope.zoomLevel > 1;
}
$scope.next = async() => {
$scope.offset += $scope.M * 7 / 8
await $scope.adjustZoom();
}
$scope.back = async() => {
$scope.offset -= $scope.M * 7 / 8
await $scope.adjustZoom();
}
$scope.backEnabled = () => {
return $scope.enableBack;
}
$scope.nextEnabled = () => {
return $scope.enableNext;
}
$scope.loadChannel = async (number) => {
console.log(`number=${number}` );
let d0 = new Date($scope.t0);
let d1 = new Date($scope.t0 + $scope.T);
let lineup = await dizquetv.getChannelLineup(number, d0, d1);
let ch = {
icon : lineup.icon,
number : lineup.number,
name: lineup.name,
altTitle: `${lineup.number} - ${lineup.name}`,
programs: [],
};
let pending = 0;
let totalAdded = 0;
let addDuration = (d) => {
totalAdded += d;
let m = (pending + d) % MINUTE;
let r = (pending + d) - m;
pending = m;
return Math.floor( r / MINUTE );
}
let deleteIfZero = () => {
if ( ch.programs.length > 0 && ch.programs[ ch.programs.length - 1].duration < 1) {
ch.programs = ch.programs.slice(0, ch.programs.length - 1);
}
}
for (let i = 0; i < lineup.programs.length; i++) {
let program = lineup.programs[i];
let ad = new Date(program.start);
let bd = new Date(program.stop);
let a = ad.getTime();
let b = bd.getTime();
let hasStart = true;
let hasStop = true;
if (a < $scope.t0) {
//cut-off
a = $scope.t0;
hasStart = false;
$scope.enableBack = true;
} else if ( (a > $scope.t0) && (i == 0) ) {
ch.programs.push( {
duration: addDuration( (a - $scope.t0) ),
showTitle: "",
start: false,
end: true,
} );
deleteIfZero();
}
if (b > $scope.t0 + $scope.T) {
b = $scope.t0 + $scope.T;
hasStop = false;
$scope.enableNext = true;
}
let subTitle = undefined;
let altTitle = hourMinute(ad) + "-" + hourMinute(bd);
if (typeof(program.title) !== 'undefined') {
altTitle = altTitle + " · " + program.title;
}
if (typeof(program.sub) !== 'undefined') {
ps = "" + program.sub.season;
if (ps.length < 2) {
ps = "0" + ps;
}
pe = "" + program.sub.episode;
if (pe.length < 2) {
pe = "0" + pe;
}
subTitle = `S${ps} · E${pe}`;
altTitle = altTitle + " " + subTitle;
} else if ( typeof(program.date) === 'undefined' ) {
subTitle = '.';
} else {
subTitle = program.date.slice(0,4);
}
ch.programs.push( {
duration: addDuration(b - a),
altTitle: altTitle,
showTitle: program.title,
subTitle: subTitle,
start: hasStart,
end: hasStop,
} );
deleteIfZero();
}
if (totalAdded < $scope.T) {
ch.programs.push( {
duration: addDuration( $scope.T - totalAdded ),
showTitle: "",
start: false,
end: true,
} );
deleteIfZero();
}
$scope.channels[number] = ch;
$scope.applyLater();
$timeout( () => $scope.checkUpdates(), 60000 );
}
$scope.refresh = async (skipStatus) => {
try {
await $scope.refreshManaged(skipStatus);
} catch (err) {
console.error("Refresh failed?", err);
}
}
$scope.refresh();
$scope.checkUpdates = async () => {
try {
let status = await dizquetv.getGuideStatus();
let t = new Date(status.lastUpdate).getTime();
if ( t > $scope.lastUpdate) {
$scope.refreshManaged();
} else {
$timeout( () => $scope.checkUpdates(), 60000 );
}
} catch(err) {
console.error(err);
}
};
}

View File

@ -19,7 +19,7 @@
</a>
</small>
</h1>
<a href="#!/channels">Channels</a> - <a href="#!/settings">Settings</a> - <a href="#!/version">Version</a>
<a href="#!/channels">Channels</a> - <a href="#!/guide">Guide</a> - <a href="#!/settings">Settings</a> - <a href="#!/version">Version</a>
<span class="pull-right">
<span style="margin-right: 15px;">
<a href="/api/xmltv.xml">XMLTV <span class="fa fa-file-code-o"></span></a>

View File

@ -105,6 +105,126 @@
-webkit-animation: spin 2s linear infinite; /* Safari */
animation: spin 2s linear infinite;
}
table.tvguide {
table-layout: fixed;
}
.tvguide th.hour {
padding-left: 0;
overflow: hidden;
white-space: nowrap;
}
.tvguide .program {
padding-left: 0.2em;
/*border-top: 1px solid black;
border-bottom: 1px solid black ;*/
overflow: hidden;
}
.tvguide .program-with-start {
}
.tvguide .program-with-end {
/*border-right: 1px solid black;*/
}
.tvguide .program .show-title {
white-space: nowrap;
font-weight: 400;
}
.tvguide .program .sub-title {
white-space: nowrap;
font-weight: 300;
}
.tvguide th button {
max-width: 20%;
}
.tvguide th {
position: sticky;
top: 0;
bottom: 0;
background: white;
border-bottom: 1px solid black;
}
.tvguide th.guidenav {
padding-left: 5px;
padding-right: 0;
}
.tvguide td, .tvguide th {
color: #F0F0f0;
border-top: 0;
height: 3.5em;
padding-top: 0;
padding-bottom: 0;
vertical-align: middle;
overflow: hidden;
}
.tvguide th {
height: 1.8em;
}
.tvguide td.channel-number, .tvguide td.channelLoading {
vertical-align: middle;
padding:0;
}
.tvguide td.channel-number div {
text-align:center;
width:100%;
padding:0;
}
.tvguide td.channel-icon {
align-items: center;
padding-top: 0;
padding-bottom: 0;
padding-left: 0;
padding-right: 0.2em;
text-align:center;
vertical-align: middle;
}
.tvguide td.channel-icon img {
max-height: 95%;
max-width:99%
}
.tvguide th.even {
background: #423cd4ff;
}
.tvguide th.odd {
background: #262198ff;
}
.tvguide tr.odd td.even {
background: #212121;
}
.tvguide tr.odd td.odd {
background: #515151;;
}
.tvguide tr.even td.odd {
background: #313131
}
.tvguide tr.even td.even {
background: #414141;
}
.tv-guide-now {
width:0.2em;
height: 100%;
position: absolute;
background: #FFFF0040;
top:0;
}
/* Safari */
@-webkit-keyframes spin {
@ -115,4 +235,5 @@
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
}

View File

@ -0,0 +1,81 @@
<div>
<h5>
{{title}}
</h5>
<div style='padding:0; position:relative'>
<table class="table tvguide" style="{'column-width': colspanPercent + '%' }">
<tr>
<th colspan="{{channelColspan}}" class='guidenav even' >
<button class="btn btn-sm btn-primary" ng-click="zoomIn()" ng-disabled='!zoomInEnabled()'>
<span class="fa fa-search-plus"></span>
</button>
<button class="btn btn-sm btn-primary" ng-click="zoomOut()" ng-disabled='!zoomOutEnabled()'>
<span class="fa fa-search-minus"></span>
</button>
<button class="btn btn-sm btn-primary" ng-click="back()" ng-disabled='!backEnabled()'>
<span class="fa fa-arrow-left"></span>
</button>
<button class="btn btn-sm btn-primary" ng-click="next()" ng-disabled='!nextEnabled()'>
<span class="fa fa-arrow-right"></span>
</button>
</th>
<th class="hour" ng-Class="{'even' : ($index % 2==1), 'odd' : ($index % 2==0) }" ng-repeat="time in times track by $index" colspan="{{time.duration}}">
{{time.label}}
</th>
</tr>
<tr ng-repeat="channelNumber in channelNumbers track by $index" ng-Class="{'even' : ($index % 2==0), 'odd' : ($index % 2==1) }" >
<td title='{{channels[channelNumber].altTitle}}' class='even channel-number' colspan="{{channelNumberColspan}}" >
<div>{{channels[channelNumber].number}}</div>
</td>
<td title='{{channels[channelNumber].altTitle}}' class='even channel-icon' colspan="{{channelIconColspan}}" >
<img src='{{channels[channelNumber].icon}}' alt='{{channels[channelNumber].name}}'></img>
</td>
<td class='odd program' colspan="{{totalSpan}}" ng-if="channels[channelNumber].loading">
<div class='loader'></div>
</td>
<td ng-repeat="program in channels[channelNumber].programs track by $index" colspan="{{program.duration}}"
title="{{program.altTitle}}"
ng-Class="{'program' : true, 'program-with-end' : program.end, 'program-with-start' : program.start, 'even' : ($index % 2==1), 'odd' : ($index % 2==0) }"
ng-if="! channels[channelNumber].loading"
>
<div class='show-title'>
{{program.showTitle}}
</div>
<div class='sub-title'>
{{program.subTitle}}
</div>
</td>
</tr>
<tr>
<th colspan="{{channelColspan}}" class='guidenav even' >
<button class="btn btn-sm btn-primary" ng-click="zoomIn()" ng-disabled='!zoomInEnabled()'>
<span class="fa fa-search-plus"></span>
</button>
<button class="btn btn-sm btn-primary" ng-click="zoomOut()" ng-disabled='!zoomOutEnabled()'>
<span class="fa fa-search-minus"></span>
</button>
<button class="btn btn-sm btn-primary" ng-click="back()" ng-disabled='!backEnabled()'>
<span class="fa fa-arrow-left"></span>
</button>
<button class="btn btn-sm btn-primary" ng-click="next()" ng-disabled='!nextEnabled()'>
<span class="fa fa-arrow-right"></span>
</button>
</th>
<th class="hour" ng-Class="{'even' : ($index % 2==1), 'odd' : ($index % 2==0) }" ng-repeat="time in times track by $index" colspan="{{time.duration}}">
{{time.label}}
</th>
</tr>
</table>
<div class="tv-guide-now" ng-style="{'left': nowPosition + '%'}" ng-show='showNow' ></div>
</div>
</div>

View File

@ -165,6 +165,32 @@ module.exports = function ($http) {
data: angular.toJson(channel),
headers: { 'Content-Type': 'application/json; charset=utf-8' }
}).then((d) => { return d.data })
}
},
/*======================================================================
* TV Guide endpoints
*/
getGuideStatus: async () => {
let d = await $http( {
method: 'GET',
url : '/api/guide/status',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
} );
return d.data;
},
getChannelLineup: async (channelNumber, dateFrom, dateTo) => {
let a = dateFrom.toISOString();
let b = dateTo.toISOString();
let d = await $http( {
method: 'GET',
url : `/api/guide/channels/${channelNumber}?dateFrom=${a}&dateTo=${b}`,
headers: { 'Content-Type': 'application/json; charset=utf-8' },
} );
return d.data;
},
}
}