diff --git a/index.js b/index.js index 20d57bb..751ebd0 100644 --- a/index.js +++ b/index.js @@ -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, () => { diff --git a/src/api.js b/src/api.js index e2d953b..732c8f2 100644 --- a/src/api.js +++ b/src/api.js @@ -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 diff --git a/src/constants.js b/src/constants.js index f7f40ea..fe009fb 100644 --- a/src/constants.js +++ b/src/constants.js @@ -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" } diff --git a/src/plex.js b/src/plex.js index 4cb7672..97e4461 100644 --- a/src/plex.js +++ b/src/plex.js @@ -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); + } + } } } diff --git a/src/tv-guide-service.js b/src/tv-guide-service.js new file mode 100644 index 0000000..1376dda --- /dev/null +++ b/src/tv-guide-service.js @@ -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(""); + this.cached = await this.buildItManaged(); + console.log(""); + 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 \ No newline at end of file diff --git a/src/xmltv.js b/src/xmltv.js index b677533..054bf81 100644 --- a/src/xmltv.js +++ b/src/xmltv.js @@ -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 ') - 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 ') - // 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) => { diff --git a/web/app.js b/web/app.js index 9360f62..a81b421 100644 --- a/web/app.js +++ b/web/app.js @@ -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' diff --git a/web/controllers/guide.js b/web/controllers/guide.js new file mode 100644 index 0000000..f1594f4 --- /dev/null +++ b/web/controllers/guide.js @@ -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); + } + }; + +} \ No newline at end of file diff --git a/web/public/index.html b/web/public/index.html index ed623f3..188fe0b 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -19,7 +19,7 @@ - Channels - Settings - Version + Channels - Guide - Settings - Version XMLTV diff --git a/web/public/style.css b/web/public/style.css index 8c58dcc..a90e8f5 100644 --- a/web/public/style.css +++ b/web/public/style.css @@ -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); } -} \ No newline at end of file +} + diff --git a/web/public/views/guide.html b/web/public/views/guide.html new file mode 100644 index 0000000..f6db06a --- /dev/null +++ b/web/public/views/guide.html @@ -0,0 +1,81 @@ +
+ +
+ {{title}} +
+
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + {{time.label}} +
+
{{channels[channelNumber].number}}
+
+ {{channels[channelNumber].name}} + +
+
+
+ {{program.showTitle}} +
+
+ {{program.subTitle}} +
+
+ + + + + + + {{time.label}} +
+
+
+ +
\ No newline at end of file diff --git a/web/services/dizquetv.js b/web/services/dizquetv.js index 4b49016..52316cd 100644 --- a/web/services/dizquetv.js +++ b/web/services/dizquetv.js @@ -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; + }, + + } } \ No newline at end of file