diff --git a/README.md b/README.md index 3232cd3..688407e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# dizqueTV 0.0.64 +# dizqueTV 0.0.65-prerelease ![Discord](https://img.shields.io/discord/711313431457693727?logo=discord&logoColor=fff&style=flat-square) ![GitHub top language](https://img.shields.io/github/languages/top/vexorian/dizquetv?logo=github&style=flat-square) ![Docker Pulls](https://img.shields.io/docker/pulls/vexorian/dizquetv?logo=docker&logoColor=fff&style=flat-square) Create live TV channel streams from media on your Plex servers. diff --git a/index.js b/index.js index 87411a3..20d57bb 100644 --- a/index.js +++ b/index.js @@ -131,7 +131,7 @@ 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(video.router(db)) +app.use(video.router( channelDB, db)) app.use(hdhr.router) app.listen(process.env.PORT, () => { console.log(`HTTP server running on port: http://*:${process.env.PORT}`) diff --git a/src/api.js b/src/api.js index 4fcac57..e2d953b 100644 --- a/src/api.js +++ b/src/api.js @@ -1,6 +1,6 @@ const express = require('express') -const fs = require('fs') +const path = require('path') const databaseMigration = require('./database-migration'); const channelCache = require('./channel-cache') const constants = require('./constants'); @@ -14,21 +14,32 @@ function api(db, channelDB, xmltvInterval) { let plexServerDB = new PlexServerDB(channelDB, channelCache, db); router.get('/api/version', async (req, res) => { + try { let ffmpegSettings = db['ffmpeg-settings'].find()[0]; let v = await (new FFMPEGInfo(ffmpegSettings)).getVersion(); res.send( { "dizquetv" : constants.VERSION_NAME, "ffmpeg" : v, } ); + } catch(err) { + console.error(err); + res.status(500).send("error"); + } }); // Plex Servers router.get('/api/plex-servers', (req, res) => { + try { let servers = db['plex-servers'].find() servers.sort( (a,b) => { return a.index - b.index } ); res.send(servers) + } catch(err) { + console.error(err); + res.status(500).send("error"); + } }) router.post("/api/plex-servers/status", async (req, res) => { + try { let servers = db['plex-servers'].find( { name: req.body.name, }); @@ -47,8 +58,13 @@ function api(db, channelDB, xmltvInterval) { res.send( { status: s, }); + } catch(err) { + console.error(err); + res.status(500).send("error"); + } }) router.post("/api/plex-servers/foreignstatus", async (req, res) => { + try { let server = req.body; let plex = new Plex(server); let s = await Promise.race( [ @@ -62,14 +78,23 @@ function api(db, channelDB, xmltvInterval) { res.send( { status: s, }); + } catch(err) { + console.error(err); + res.status(500).send("error"); + } }) router.delete('/api/plex-servers', async (req, res) => { + try { let name = req.body.name; if (typeof(name) === 'undefined') { return res.status(400).send("Missing name"); } let report = await plexServerDB.deleteServer(name); res.send(report) + } catch(err) { + console.error(err); + res.status(500).send("error"); + } }) router.post('/api/plex-servers', async (req, res) => { try { @@ -93,11 +118,17 @@ function api(db, channelDB, xmltvInterval) { // Channels router.get('/api/channels', async (req, res) => { + try { let channels = await channelDB.getAllChannels(); channels.sort((a, b) => { return a.number < b.number ? -1 : 1 }) res.send(channels) + } catch(err) { + console.error(err); + res.status(500).send("error"); + } }) router.get('/api/channel/:number', async (req, res) => { + try { let number = parseInt(req.params.number, 10); let channel = await channelCache.getChannelConfig(channelDB, number); if (channel.length == 1) { @@ -106,8 +137,13 @@ function api(db, channelDB, xmltvInterval) { } else { return res.status(404).send("Channel not found"); } + } catch(err) { + console.error(err); + res.status(500).send("error"); + } }) router.get('/api/channel/description/:number', async (req, res) => { + try { let number = parseInt(req.params.number, 10); let channel = await channelCache.getChannelConfig(channelDB, number); if (channel.length == 1) { @@ -120,62 +156,114 @@ function api(db, channelDB, xmltvInterval) { } else { return res.status(404).send("Channel not found"); } + } catch(err) { + console.error(err); + res.status(500).send("error"); + } }) router.get('/api/channelNumbers', async (req, res) => { + try { let channels = await channelDB.getAllChannelNumbers(); channels.sort( (a,b) => { return parseInt(a) - parseInt(b) } ); res.send(channels) + } catch(err) { + console.error(err); + res.status(500).send("error"); + } }) router.post('/api/channel', async (req, res) => { + try { cleanUpChannel(req.body); await channelDB.saveChannel( req.body.number, req.body ); channelCache.clear(); res.send( { number: req.body.number} ) updateXmltv() + } catch(err) { + console.error(err); + res.status(500).send("error"); + } }) router.put('/api/channel', async (req, res) => { + try { cleanUpChannel(req.body); await channelDB.saveChannel( req.body.number, req.body ); channelCache.clear(); res.send( { number: req.body.number} ) updateXmltv() + } catch(err) { + console.error(err); + res.status(500).send("error"); + } }) router.delete('/api/channel', async (req, res) => { + try { await channelDB.deleteChannel( req.body.number ); channelCache.clear(); res.send( { number: req.body.number} ) updateXmltv() + } catch(err) { + console.error(err); + res.status(500).send("error"); + } }) // FFMPEG SETTINGS router.get('/api/ffmpeg-settings', (req, res) => { + try { let ffmpeg = db['ffmpeg-settings'].find()[0] res.send(ffmpeg) + } catch(err) { + console.error(err); + res.status(500).send("error"); + } }) router.put('/api/ffmpeg-settings', (req, res) => { + try { db['ffmpeg-settings'].update({ _id: req.body._id }, req.body) let ffmpeg = db['ffmpeg-settings'].find()[0] res.send(ffmpeg) + } catch(err) { + console.error(err); + res.status(500).send("error"); + } }) router.post('/api/ffmpeg-settings', (req, res) => { // RESET + try { let ffmpeg = databaseMigration.defaultFFMPEG() ; ffmpeg.ffmpegPath = req.body.ffmpegPath; db['ffmpeg-settings'].update({ _id: req.body._id }, ffmpeg) ffmpeg = db['ffmpeg-settings'].find()[0] res.send(ffmpeg) + } catch(err) { + console.error(err); + res.status(500).send("error"); + } + }) // PLEX SETTINGS router.get('/api/plex-settings', (req, res) => { + try { let plex = db['plex-settings'].find()[0] res.send(plex) + } catch(err) { + console.error(err); + res.status(500).send("error"); + } }) router.put('/api/plex-settings', (req, res) => { + try { db['plex-settings'].update({ _id: req.body._id }, req.body) let plex = db['plex-settings'].find()[0] res.send(plex) + } catch(err) { + console.error(err); + res.status(500).send("error"); + } + }) router.post('/api/plex-settings', (req, res) => { // RESET + try { db['plex-settings'].update({ _id: req.body._id }, { streamPath: 'plex', debugLogging: true, @@ -199,24 +287,57 @@ function api(db, channelDB, xmltvInterval) { }) let plex = db['plex-settings'].find()[0] res.send(plex) + } catch(err) { + console.error(err); + res.status(500).send("error"); + } + }) router.get('/api/xmltv-last-refresh', (req, res) => { + try { res.send(JSON.stringify({ value: xmltvInterval.lastUpdated.valueOf() })) + } catch(err) { + console.error(err); + res.status(500).send("error"); + } + }) // XMLTV SETTINGS router.get('/api/xmltv-settings', (req, res) => { + try { let xmltv = db['xmltv-settings'].find()[0] res.send(xmltv) + } catch(err) { + console.error(err); + res.status(500).send("error"); + } + }) router.put('/api/xmltv-settings', (req, res) => { - db['xmltv-settings'].update({ _id: req.body._id }, req.body) + try { let xmltv = db['xmltv-settings'].find()[0] + db['xmltv-settings'].update( + { _id: req.body._id }, + { + _id: req.body._id, + cache: req.body.cache, + refresh: req.body.refresh, + file: xmltv.file, + } + ); + xmltv = db['xmltv-settings'].find()[0] res.send(xmltv) updateXmltv() + } catch(err) { + console.error(err); + res.status(500).send("error"); + } + }) router.post('/api/xmltv-settings', (req, res) => { + try { db['xmltv-settings'].update({ _id: req.body._id }, { _id: req.body._id, cache: 12, @@ -226,20 +347,38 @@ function api(db, channelDB, xmltvInterval) { var xmltv = db['xmltv-settings'].find()[0] res.send(xmltv) updateXmltv() + } catch(err) { + console.error(err); + res.status(500).send("error"); + } + }) //HDHR SETTINGS router.get('/api/hdhr-settings', (req, res) => { + try { let hdhr = db['hdhr-settings'].find()[0] res.send(hdhr) + } catch(err) { + console.error(err); + res.status(500).send("error"); + } + }) router.put('/api/hdhr-settings', (req, res) => { + try { db['hdhr-settings'].update({ _id: req.body._id }, req.body) let hdhr = db['hdhr-settings'].find()[0] res.send(hdhr) + } catch(err) { + console.error(err); + res.status(500).send("error"); + } + }) router.post('/api/hdhr-settings', (req, res) => { + try { db['hdhr-settings'].update({ _id: req.body._id }, { _id: req.body._id, tunerCount: 1, @@ -247,18 +386,32 @@ function api(db, channelDB, xmltvInterval) { }) var hdhr = db['hdhr-settings'].find()[0] res.send(hdhr) + } catch(err) { + console.error(err); + res.status(500).send("error"); + } + }) // XMLTV.XML Download router.get('/api/xmltv.xml', (req, res) => { + try { + res.set('Cache-Control', 'no-store') res.type('text') let xmltvSettings = db['xmltv-settings'].find()[0] - res.send(fs.readFileSync(xmltvSettings.file)) + let f = path.resolve(xmltvSettings.file); + res.sendFile(f) + } catch(err) { + console.error(err); + res.status(500).send("error"); + } + }) // CHANNELS.M3U Download router.get('/api/channels.m3u', async (req, res) => { + try { res.type('text') let channels = await channelDB.getAllChannels(); channels.sort((a, b) => { return a.number < b.number ? -1 : 1 }) @@ -272,10 +425,17 @@ function api(db, channelDB, xmltvInterval) { data += `${req.protocol}://${req.get('host')}/setup\n` } res.send(data) + } catch(err) { + console.error(err); + res.status(500).send("error"); + } + + }) // hls.m3u Download is not really working correctly right now router.get('/api/hls.m3u', async (req, res) => { + try { res.type('text') let channels = await channelDB.getAllChannels(); channels.sort((a, b) => { return a.number < b.number ? -1 : 1 }) @@ -289,6 +449,11 @@ function api(db, channelDB, xmltvInterval) { data += `${req.protocol}://${req.get('host')}/setup\n` } res.send(data) + } catch(err) { + console.error(err); + res.status(500).send("error"); + } + }) diff --git a/src/channel-cache.js b/src/channel-cache.js index beacc58..681e99a 100644 --- a/src/channel-cache.js +++ b/src/channel-cache.js @@ -9,8 +9,12 @@ async function getChannelConfig(channelDB, channelId) { if ( typeof(configCache[channelId]) === 'undefined') { let channel = await channelDB.getChannel(channelId) - //console.log("channel=" + JSON.stringify(channel) ); - configCache[channelId] = [channel]; + if (channel == null) { + configCache[channelId] = []; + } else { + //console.log("channel=" + JSON.stringify(channel) ); + configCache[channelId] = [channel]; + } } //console.log("channel=" + JSON.stringify(configCache[channelId]).slice(0,200) ); return configCache[channelId]; diff --git a/src/constants.js b/src/constants.js index 46a08a1..03ce000 100644 --- a/src/constants.js +++ b/src/constants.js @@ -2,5 +2,5 @@ module.exports = { SLACK: 9999, TVGUIDE_MAXIMUM_PADDING_LENGTH_MS: 30*60*1000, - VERSION_NAME: "0.0.64" + VERSION_NAME: "0.0.65-prerelease" } diff --git a/src/dao/channel-db.js b/src/dao/channel-db.js index 5684d05..ac81bf4 100644 --- a/src/dao/channel-db.js +++ b/src/dao/channel-db.js @@ -9,18 +9,23 @@ class ChannelDB { async getChannel(number) { let f = path.join(this.folder, `${number}.json` ); - return await new Promise( (resolve, reject) => { - fs.readFile(f, (err, data) => { - if (err) { - return reject(err); - } - try { - resolve( JSON.parse(data) ) - } catch (err) { - reject(err); - } - }) - }); + try { + return await new Promise( (resolve, reject) => { + fs.readFile(f, (err, data) => { + if (err) { + return reject(err); + } + try { + resolve( JSON.parse(data) ) + } catch (err) { + reject(err); + } + }) + }); + } catch (err) { + console.error(err); + return null; + } } async saveChannel(number, json) { diff --git a/src/ffmpeg.js b/src/ffmpeg.js index 73316e0..8c0219b 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -299,7 +299,6 @@ class FFMPEG extends events.EventEmitter { // add the video encoder flags ffmpegArgs.push( `-b:v`, `${this.opts.videoBitrate}k`, - `-minrate:v`, `${this.opts.videoBitrate}k`, `-maxrate:v`, `${this.opts.videoBitrate}k`, `-bufsize:v`, `${this.opts.videoBufSize}k` ); @@ -308,7 +307,6 @@ class FFMPEG extends events.EventEmitter { // add the audio encoder flags ffmpegArgs.push( `-b:a`, `${this.opts.audioBitrate}k`, - `-minrate:a`, `${this.opts.audioBitrate}k`, `-maxrate:a`, `${this.opts.audioBitrate}k`, `-bufsize:a`, `${this.opts.videoBufSize}k` ); @@ -319,6 +317,15 @@ class FFMPEG extends events.EventEmitter { ); } } + if (transcodeAudio && transcodeVideo) { + console.log("Video and Audio are being transcoded by ffmpeg"); + } else if (transcodeVideo) { + console.log("Video is being transcoded by ffmpeg. Audio is being copied."); + } else if (transcodeAudio) { + console.log("Audio is being transcoded by ffmpeg. Video is being copied."); + } else { + console.log("Video and Audio are being copied. ffmpeg is not transcoding."); + } ffmpegArgs.push( `-c:a`, (transcodeAudio ? this.opts.audioEncoder : 'copy'), '-map_metadata', '-1', diff --git a/src/helperFuncs.js b/src/helperFuncs.js index c8259f9..4151d6f 100644 --- a/src/helperFuncs.js +++ b/src/helperFuncs.js @@ -8,10 +8,22 @@ let channelCache = require('./channel-cache'); const SLACK = require('./constants').SLACK; function getCurrentProgramAndTimeElapsed(date, channel) { - let channelStartTime = new Date(channel.startTime) - if (channelStartTime > date) - throw new Error("startTime cannot be set in the future. something fucked up..") - let timeElapsed = (date.valueOf() - channelStartTime.valueOf()) % channel.duration + let channelStartTime = (new Date(channel.startTime)).getTime(); + if (channelStartTime > date) { + let t0 = date; + let t1 = channelStartTime; + console.log(t0, t1); + console.log("Channel start time is above the given date. Flex time is picked till that."); + return { + program: { + isOffline: true, + duration : t1 - t0, + }, + timeElapsed: 0, + programIndex: -1, + } + } + let timeElapsed = (date - channelStartTime) % channel.duration let currentProgramIndex = -1 for (let y = 0, l2 = channel.programs.length; y < l2; y++) { let program = channel.programs[y] @@ -42,6 +54,20 @@ function createLineup(obj, channel, isFirst) { let lineup = [] + if ( typeof(activeProgram.err) !== 'undefined') { + let remaining = activeProgram.duration - timeElapsed; + lineup.push( { + type: 'offline', + title: 'Error', + err: activeProgram.err, + streamDuration: remaining, + duration: remaining, + start: 0 + }) + return lineup; + } + + if (activeProgram.isOffline === true) { //offline case let remaining = activeProgram.duration - timeElapsed; diff --git a/src/plex-player.js b/src/plex-player.js index 1fb2bf3..baf5c9c 100644 --- a/src/plex-player.js +++ b/src/plex-player.js @@ -9,6 +9,7 @@ const PlexTranscoder = require('./plexTranscoder') const EventEmitter = require('events'); const helperFuncs = require('./helperFuncs') const FFMPEG = require('./ffmpeg') +const constants = require('./constants'); let USED_CLIENTS = {}; @@ -60,8 +61,10 @@ class PlexPlayer { let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options this.ffmpeg = ffmpeg; let streamDuration; - if (typeof(streamDuration)!=='undefined') { - streamDuration = lineupItem.streamDuration / 1000; + if (typeof(lineupItem.streamDuration)!=='undefined') { + if (lineupItem.start + lineupItem.streamDuration + constants.SLACK < lineupItem.duration) { + streamDuration = lineupItem.streamDuration / 1000; + } } let deinterlace = ffmpegSettings.enableFFMPEGTranscoding; //for now it will always deinterlace when transcoding is enabled but this is sub-optimal diff --git a/src/program-player.js b/src/program-player.js index 30e1283..576cff9 100644 --- a/src/program-player.js +++ b/src/program-player.js @@ -34,7 +34,7 @@ class ProgramPlayer { // people might want the codec normalization to stay because of player support context.ffmpegSettings.normalizeResolution = false; } - if (program.err instanceof Error) { + if ( typeof(program.err) !== 'undefined') { console.log("About to play error stream"); this.delegate = new OfflinePlayer(true, context); } else if (program.type === 'loading') { diff --git a/src/video.js b/src/video.js index 84bd32c..aab95ba 100644 --- a/src/video.js +++ b/src/video.js @@ -9,7 +9,7 @@ const channelCache = require('./channel-cache') module.exports = { router: video } -function video(db) { +function video( channelDB , db) { var router = express.Router() router.get('/setup', (req, res) => { @@ -49,7 +49,7 @@ function video(db) { return } let number = parseInt(req.query.channel, 10); - let channel = await channelCache.getChannelConfig(db, number); + let channel = await channelCache.getChannelConfig(channelDB, number); if (channel.length === 0) { res.status(500).send("Channel doesn't exist") return @@ -118,7 +118,7 @@ function video(db) { } let m3u8 = (req.query.m3u8 === '1'); let number = parseInt(req.query.channel); - let channel = await channelCache.getChannelConfig(db, number); + let channel = await channelCache.getChannelConfig(channelDB, number); if (channel.length === 0) { res.status(404).send("Channel doesn't exist") @@ -150,6 +150,11 @@ function video(db) { // Get video lineup (array of video urls with calculated start times and durations.) let t0 = (new Date()).getTime(); let lineupItem = channelCache.getCurrentLineupItem( channel.number, t0); + let prog = null; + let brandChannel = channel; + let redirectChannels = []; + let upperBounds = []; + if (isLoading) { lineupItem = { type: 'loading', @@ -158,9 +163,58 @@ function video(db) { start: 0, }; } else if (lineupItem == null) { - let prog = helperFuncs.getCurrentProgramAndTimeElapsed(t0, channel) + prog = helperFuncs.getCurrentProgramAndTimeElapsed(t0, channel); + + while (true) { + redirectChannels.push( brandChannel ); + upperBounds.push( prog.program.duration - prog.timeElapsed ); - if (prog.program.isOffline && channel.programs.length == 1) { + if ( !(prog.program.isOffline) || (prog.program.type != 'redirect') ) { + break; + } + channelCache.recordPlayback( brandChannel.number, t0, { + /*type: 'offline',*/ + title: 'Error', + err: Error("Recursive channel redirect found"), + duration : 60000, + start: 0, + }); + + + + let newChannelNumber= prog.program.channel; + let newChannel = await channelCache.getChannelConfig(channelDB, newChannelNumber); + + if (newChannel.length == 0) { + let err = Error("Invalid redirect to a channel that doesn't exist"); + console.error("Invalid redirect to channel that doesn't exist.", err); + prog = { + program: { + isOffline: true, + err: err, + duration : 60000, + }, + timeElapsed: 0, + } + continue; + } + newChannel = newChannel[0]; + brandChannel = newChannel; + lineupItem = channelCache.getCurrentLineupItem( newChannel.number, t0); + if (lineupItem != null) { + lineupItem = JSON.parse( JSON.stringify(lineupItem)) ; + break; + } else { + prog = helperFuncs.getCurrentProgramAndTimeElapsed(t0, newChannel); + } + } + } + if (lineupItem == null) { + if (prog == null) { + res.status(500).send("server error"); + throw Error("Shouldn't prog be non-null?"); + } + if (prog.program.isOffline && channel.programs.length == 1 && prog.programIndex != -1) { //there's only one program and it's offline. So really, the channel is //permanently offline, it doesn't matter what duration was set //and it's best to give it a long duration to ensure there's always @@ -180,15 +234,33 @@ function video(db) { if ( (prog == null) || (typeof(prog) === 'undefined') || (prog.program == null) || (typeof(prog.program) == "undefined") ) { throw "No video to play, this means there's a serious unexpected bug or the channel db is corrupted." } - let lineup = helperFuncs.createLineup(prog, channel, isFirst) + let lineup = helperFuncs.createLineup(prog, brandChannel, isFirst) lineupItem = lineup.shift() } - + + if ( !isLoading && (lineupItem != null) ) { + let upperBound = 1000000000; + //adjust upper bounds and record playbacks + for (let i = redirectChannels.length-1; i >= 0; i--) { + lineupItem = JSON.parse( JSON.stringify(lineupItem )); + let u = upperBounds[i]; + if (typeof(u) !== 'undefined') { + let u2 = upperBound; + if ( typeof(lineupItem.streamDuration) !== 'undefined') { + u2 = Math.min(u2, lineupItem.streamDuration); + } + lineupItem.streamDuration = Math.min(u2, u); + upperBound = lineupItem.streamDuration; + } + channelCache.recordPlayback( redirectChannels[i].number, t0, lineupItem ); + } + } + console.log("========================================================="); console.log("! Start playback"); console.log(`! Channel: ${channel.name} (${channel.number})`); - if (typeof(lineupItem) === 'undefined') { + if (typeof(lineupItem.title) === 'undefined') { lineupItem.title = 'Unknown'; } console.log(`! Title: ${lineupItem.title}`); @@ -206,7 +278,7 @@ function video(db) { let playerContext = { lineupItem : lineupItem, ffmpegSettings : ffmpegSettings, - channel: channel, + channel: brandChannel, db: db, m3u8: m3u8, } @@ -267,7 +339,7 @@ function video(db) { } let channelNum = parseInt(req.query.channel, 10) - let channel = await channelCache.getChannelConfig(db, channelNum ); + let channel = await channelCache.getChannelConfig(channelDB, channelNum ); if (channel.length === 0) { res.status(500).send("Channel doesn't exist") return @@ -312,7 +384,7 @@ function video(db) { } let channelNum = parseInt(req.query.channel, 10) - let channel = await channelCache.getChannelConfig(db, channelNum ); + let channel = await channelCache.getChannelConfig(channelDB, channelNum ); if (channel.length === 0) { res.status(500).send("Channel doesn't exist") return diff --git a/src/xmltv.js b/src/xmltv.js index 5901527..b677533 100644 --- a/src/xmltv.js +++ b/src/xmltv.js @@ -70,19 +70,21 @@ function _writeChannels(xw, channels) { } async function _writePrograms(xw, channel, date, cache) { - let prog = helperFuncs.getCurrentProgramAndTimeElapsed(date, channel) + 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() - prog.timeElapsed) - if (channel.programs.length === 0) + let temp = new Date(date.valueOf() - item.timeElapsed) + if (channel.programs.length === 0) { return - let i = prog.programIndex + } + let i = item.programIndex; for (; temp < cutoff;) { await _throttle(); //let's not block for this process let program = { - program: channel.programs[i], + program: prog, channel: channel.number, start: new Date(temp.valueOf()), - stop: new Date(temp.valueOf() + channel.programs[i].duration) + stop: new Date(temp.valueOf() + prog.duration) } let ni = (i + 1) % channel.programs.length; if ( @@ -92,13 +94,14 @@ async function _writePrograms(xw, channel, date, cache) { && (channel.programs[ni].duration < constants.TVGUIDE_MAXIMUM_PADDING_LENGTH_MS ) ) { - program.stop = new Date(temp.valueOf() + channel.programs[i].duration + channel.programs[ni].duration) + 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]; } } @@ -159,11 +162,9 @@ async function _writeProgramme(channel, xw, program, cutoff) { xw.endElement() } function _createXMLTVDate(d) { - //console.log("d=" + d.getTime() ); try { return d.toISOString().substring(0,19).replace(/[-T:]/g,"") + " +0000"; } catch(e) { - console.log("d=" + d.getTime(), e); return (new Date()).toISOString().substring(0,19).replace(/[-T:]/g,"") + " +0000"; } } diff --git a/web/app.js b/web/app.js index 131c284..9360f62 100644 --- a/web/app.js +++ b/web/app.js @@ -17,6 +17,8 @@ app.directive('plexLibrary', require('./directives/plex-library')) app.directive('programConfig', require('./directives/program-config')) app.directive('offlineConfig', require('./directives/offline-config')) app.directive('frequencyTweak', require('./directives/frequency-tweak')) +app.directive('removeShows', require('./directives/remove-shows')) +app.directive('channelRedirect', require('./directives/channel-redirect')) app.directive('plexServerEdit', require('./directives/plex-server-edit')) app.directive('channelConfig', require('./directives/channel-config')) diff --git a/web/directives/channel-config.js b/web/directives/channel-config.js index d068789..a21fde0 100644 --- a/web/directives/channel-config.js +++ b/web/directives/channel-config.js @@ -1,9 +1,10 @@ -module.exports = function ($timeout, $location) { +module.exports = function ($timeout, $location, dizquetv) { return { restrict: 'E', templateUrl: 'templates/channel-config.html', replace: true, scope: { + visible: "=visible", channels: "=channels", channel: "=channel", onDone: "=onDone" @@ -93,6 +94,11 @@ module.exports = function ($timeout, $location) { updateChannelDuration(); setTimeout( () => { scope.showRotatedNote = true }, 1, 'funky'); } + scope._selectedRedirect = { + isOffline : true, + type : "redirect", + duration : 60*60*1000, + } scope.finshedProgramEdit = (program) => { scope.channel.programs[scope.selectedProgram] = program @@ -151,7 +157,7 @@ module.exports = function ($timeout, $location) { let newProgs = [] let progs = scope.channel.programs for (let i = 0, l = progs.length; i < l; i++) { - if (progs[i].type === 'movie') { + if ( progs[i].isOffline || (progs[i].type === 'movie') ) { movies.push(progs[i]) } else { if (typeof shows[progs[i].showTitle] === 'undefined') @@ -241,7 +247,9 @@ module.exports = function ($timeout, $location) { let tmpProgs = {} let progs = scope.channel.programs for (let i = 0, l = progs.length; i < l; i++) { - if (progs[i].type === 'movie') { + if ( progs[i].type ==='redirect' ) { + tmpProgs['_redirect ' + progs[i].channel + ' _ '+ progs[i].duration ] = progs[i]; + } else if (progs[i].type === 'movie') { tmpProgs[progs[i].title + progs[i].durationStr] = progs[i] } else { tmpProgs[progs[i].showTitle + '-' + progs[i].season + '-' + progs[i].episode] = progs[i] @@ -258,7 +266,7 @@ module.exports = function ($timeout, $location) { let tmpProgs = [] let progs = scope.channel.programs for (let i = 0, l = progs.length; i < l; i++) { - if (progs[i].isOffline !== true) { + if ( (progs[i].isOffline !== true) || (progs[i].type === 'redirect') ) { tmpProgs.push(progs[i]); } } @@ -278,7 +286,32 @@ module.exports = function ($timeout, $location) { updateChannelDuration() } + scope.getShowTitle = (program) => { + if (program.isOffline && program.type == 'redirect') { + return `Redirect to channel ${program.channel}`; + } else { + return program.showTitle; + } + } + scope.startRemoveShows = () => { + scope._removablePrograms = scope.channel.programs + .map(scope.getShowTitle) + .reduce((dedupedArr, showTitle) => { + if (!dedupedArr.includes(showTitle)) { + dedupedArr.push(showTitle) + } + return dedupedArr + }, []) + .filter(showTitle => !!showTitle); + scope._deletedProgramNames = []; + } + scope.removeShows = (deletedShowNames) => { + const p = scope.channel.programs; + let set = {}; + deletedShowNames.forEach( (a) => set[a] = true ); + scope.channel.programs = p.filter( (a) => (set[scope.getShowTitle(a)]!==true) ); + } scope.describeFallback = () => { if (scope.channel.offlineMode === 'pic') { @@ -297,31 +330,45 @@ module.exports = function ($timeout, $location) { scope.programSquareStyle = (program) => { let background =""; - if (program.isOffline) { + if ( (program.isOffline) && (program.type !== 'redirect') ) { background = "rgb(255, 255, 255)"; } else { let r = 0, g = 0, b = 0, r2=0, g2=0,b2=0; - let i = 0; let angle = 45; let w = 3; - if (program.type === 'episode') { + if (program.type === 'redirect') { + angle = 0; + w = 4 + (program.channel % 10); + let c = (program.channel * 100019); + //r = 255, g = 0, b = 0; + //r2 = 0, g2 = 0, b2 = 255; + + r = ( (c & 3) * 77 ); + g = ( ( (c >> 1) & 3) * 77 ); + b = ( ( (c >> 2) & 3) * 77 ); + r2 = ( ( (c >> 5) & 3) * 37 ); + g2 = ( ( (c >> 3) & 3) * 37 ); + b2 = ( ( (c >> 4) & 3) * 37 ); + + } else if (program.type === 'episode') { let h = Math.abs(scope.getHashCode(program.showTitle, false)); let h2 = Math.abs(scope.getHashCode(program.showTitle, true)); r = h % 256; g = (h / 256) % 256; b = (h / (256*256) ) % 256; - i = h % 360; r2 = (h2 / (256*256) ) % 256; g2 = (h2 / (256*256) ) % 256; b2 = (h2 / (256*256) ) % 256; - angle = -90 + h % 180 + angle = (360 - 90 + h % 180) % 360; + if ( angle >= 350 || angle < 10 ) { + angle += 53; + } } else { r = 10, g = 10, b = 10; r2 = 245, g2 = 245, b2 = 245; angle = 45; w = 6; } - let rgb1 = "rgb("+ r + "," + g + "," + b +")"; let rgb2 = "rgb("+ r2 + "," + g2 + "," + b2 +")" background = "repeating-linear-gradient( " + angle + "deg, " + rgb1 + ", " + rgb1 + " " + w + "px, " + rgb2 + " " + w + "px, " + rgb2 + " " + (w*2) + "px)"; @@ -361,7 +408,67 @@ module.exports = function ($timeout, $location) { return hash; } - scope.nightChannel = (a, b) => { + scope.doReruns = (rerunStart, rerunBlockSize, rerunRepeats) => { + let o =(new Date()).getTimezoneOffset() * 60 * 1000; + let start = (o + rerunStart * 60 * 60 * 1000) % (24*60*60*1000); + let blockSize = rerunBlockSize * 60*60* 1000; + let repeats = rerunRepeats; + + let programs = []; + let block = []; + let currentBlockSize = 0; + let currentSize = 0; + let addBlock = () => { + + let high = currentSize + currentBlockSize; + let m = high % blockSize; + if (m >= 1000) { + high = high - m + blockSize; + } + high -= currentSize; + let rem = Math.max(0, high - currentBlockSize); + if (rem >= 1000) { + currentBlockSize += rem; + let t = block.length; + if ( + (t > 0) + && block[t-1].isOffline + && (block[t-1].type !== 'redirect') + ) { + block[t-1].duration += rem; + } else { + block.push( { + isOffline: true, + duration: rem, + } ); + } + } + for (let i = 0; i < repeats; i++) { + for (let j = 0; j < block.length; j++) { + programs.push( JSON.parse( JSON.stringify(block[j]) ) ); + } + } + currentSize += repeats * currentBlockSize; + block = []; + currentBlockSize = 0; + + }; + for (let i = 0; i < scope.channel.programs.length; i++) { + if (currentBlockSize + scope.channel.programs[i].duration - 500 > blockSize) { + addBlock(); + } + block.push( scope.channel.programs[i] ); + currentBlockSize += scope.channel.programs[i].duration; + } + if (currentBlockSize != 0) { + addBlock(); + } + scope.channel.startTime = new Date( scope.channel.startTime.getTime() - scope.channel.startTime % (24*60*60*1000) + start ); + scope.channel.programs = programs; + scope.updateChannelDuration(); + }; + + scope.nightChannel = (a, b, ch) => { let o =(new Date()).getTimezoneOffset() * 60 * 1000; let m = 24*60*60*1000; a = (m + a * 60 * 60 * 1000 + o) % m; @@ -390,6 +497,8 @@ module.exports = function ($timeout, $location) { { duration: d, isOffline: true, + channel: ch, + type: (typeof(ch) === 'undefined') ? undefined: "redirect", } ) t += d; @@ -404,6 +513,8 @@ module.exports = function ($timeout, $location) { { duration: d, isOffline: true, + type: (typeof(ch) === 'undefined') ? undefined: "redirect", + channel: ch, } ) } @@ -418,7 +529,7 @@ module.exports = function ($timeout, $location) { let tired = 0; for (let i = 0, l = scope.channel.programs.length; i <= l; i++) { let prog = scope.channel.programs[i % l]; - if (prog.isOffline) { + if (prog.isOffline && prog.type != 'redirect') { tired = 0; } else { if (tired + prog.duration >= after) { @@ -542,7 +653,7 @@ module.exports = function ($timeout, $location) { scope.startFrequencyTweak = () => { let programs = {}; for (let i = 0; i < scope.channel.programs.length; i++) { - if (! scope.channel.programs[i].isOffline) { + if ( !scope.channel.programs[i].isOffline || (scope.channel.programs[i].type === 'redirect') ) { let c = getShowCode(scope.channel.programs[i]); if ( typeof(programs[c]) === 'undefined') { programs[c] = 0; @@ -614,7 +725,9 @@ module.exports = function ($timeout, $location) { function getShowCode(program) { //used for equalize and frequency tweak let showName = "_internal.Unknown"; - if ( (program.type == 'episode') && ( typeof(program.showTitle) !== 'undefined' ) ) { + if ( program.isOffline && (program.type == 'redirect') ) { + showName = `Redirect to channel ${program.channel}`; + } else if ( (program.type == 'episode') && ( typeof(program.showTitle) !== 'undefined' ) ) { showName = program.showTitle; } else { showName = "_internal.Movies"; @@ -642,11 +755,11 @@ module.exports = function ($timeout, $location) { let shows = {}; let progs = []; for (let i = 0; i < array.length; i++) { - if (array[i].isOffline) { + if (array[i].isOffline && array[i].type !== 'redirect') { continue; } - vid = array[i]; - let code = getShowCode(array[i]); + let vid = array[i]; + let code = getShowCode(vid); if ( typeof(shows[code]) === 'undefined') { shows[code] = { total: 0, @@ -693,7 +806,7 @@ module.exports = function ($timeout, $location) { let counts = {}; // some precalculation, useful to stop the shuffle from being quadratic... for (let i = 0; i < array.length; i++) { - var vid = array[i]; + let vid = array[i]; if (vid.type === 'episode' && vid.season != 0) { let countKey = { title: vid.showTitle, @@ -737,10 +850,10 @@ module.exports = function ($timeout, $location) { }); shuffle(array); for (let i = 0; i < array.length; i++) { - if (array[i].type !== 'movie' && array[i].season != 0) { + if (array[i].type === 'episode' && array[i].season != 0) { let title = array[i].showTitle; var sequence = shows[title]; - var j = next[title]; + let j = next[title]; array[i] = sequence[j].it; next[title] = (j + 1) % sequence.length; @@ -812,12 +925,37 @@ module.exports = function ($timeout, $location) { }, 0 ); } + scope.finishRedirect = (program) => { + if (scope.selectedProgram == -1) { + scope.channel.programs.splice(scope.minProgramIndex, 0, program); + } else { + scope.channel.programs[ scope.selectedProgram ] = program; + } + updateChannelDuration(); + } + scope.addRedirect = () => { + scope.selectedProgram = -1; + scope._displayRedirect = true; + scope._redirectTitle = "Add Redirect"; + scope._selectedRedirect = { + isOffline : true, + type : "redirect", + duration : 60*60*1000, + } + + }; scope.selectProgram = (index) => { scope.selectedProgram = index; let program = scope.channel.programs[index]; if(program.isOffline) { - scope._selectedOffline = scope.makeOfflineFromChannel( Math.round( (program.duration + 500) / 1000 ) ); + if (program.type === 'redirect') { + scope._displayRedirect = true; + scope._redirectTitle = "Edit Redirect"; + scope._selectedRedirect = JSON.parse(angular.toJson(program)); + } else { + scope._selectedOffline = scope.makeOfflineFromChannel( Math.round( (program.duration + 500) / 1000 ) ); + } } else { scope._selectedProgram = JSON.parse(angular.toJson(program)); } @@ -826,6 +964,32 @@ module.exports = function ($timeout, $location) { scope.channel.programs.splice(x, 1) updateChannelDuration() } + scope.knownChannels = [ + { id: -1, description: "# Channel #"}, + ] + scope.loadChannels = async () => { + let channelNumbers = await dizquetv.getChannelNumbers(); + try { + await Promise.all( channelNumbers.map( async(x) => { + let desc = await dizquetv.getChannelDescription(x); + if (desc.number != scope.channel.number) { + scope.knownChannels.push( { + id: desc.number, + description: `${desc.number} - ${desc.name}`, + }); + } + }) ); + } catch (err) { + console.error(err); + } + scope.knownChannels.sort( (a,b) => a.id - b.id); + scope.channelsDownloaded = true; + $timeout( () => scope.$apply(), 0); + + + }; + scope.loadChannels(); + scope.paddingOptions = [ { id: -1, description: "Allowed start times", allow5: false }, { id: 30, description: ":00, :30", allow5: false }, @@ -875,15 +1039,36 @@ module.exports = function ($timeout, $location) { ] scope.maxBreakSizeOptions = scope.maxBreakSizeOptions.concat(breakSizeOptions); + scope.rerunStart = -1; + scope.rerunBlockSize = -1; + scope.rerunBlockSizes = [ + { id: -1, description: "Block" }, + { id: 6, description: "6 Hours" }, + { id: 8, description: "8 Hours" }, + { id: 12, description: "12 Hours" }, + ]; + scope.rerunRepeats = -1; + scope.rerunRepeatOptions = [ + { id: -1, description: "Repeats" }, + { id: 2, description: "2" }, + { id: 3, description: "3" }, + { id: 4, description: "4" }, + ]; + + scope.nightStartHours = [ { id: -1, description: "Start" } ]; scope.nightEndHours = [ { id: -1, description: "End" } ]; scope.nightStart = -1; scope.nightEnd = -1; + scope.atNightChannelNumber = -1; + scope.atNightStart = -1; + scope.atNightEnd = -1; for (let i=0; i < 24; i++) { let v = { id: i, description: ( (i<10) ? "0" : "") + i + ":00" }; scope.nightStartHours.push(v); scope.nightEndHours.push(v); } + scope.rerunStartHours = scope.nightStartHours; scope.paddingMod = 30; } } diff --git a/web/directives/channel-redirect.js b/web/directives/channel-redirect.js new file mode 100644 index 0000000..d9c9607 --- /dev/null +++ b/web/directives/channel-redirect.js @@ -0,0 +1,85 @@ +module.exports = function ($timeout, dizquetv) { + return { + restrict: 'E', + templateUrl: 'templates/channel-redirect.html', + replace: true, + scope: { + formTitle: "=formTitle", + visible: "=visible", + program: "=program", + _onDone: "=onDone" + }, + link: function (scope, element, attrs) { + scope.error = ""; + scope.options = []; + scope.loading = true; + + scope.$watch('program', () => { + if (typeof(scope.program) === 'undefined') { + return; + } + if ( isNaN(scope.program.duration) ) { + scope.program.duration = 15000; + } + scope.durationSeconds = Math.ceil( scope.program.duration / 1000.0 );; + }) + + scope.refreshChannels = async() => { + let channelNumbers = await dizquetv.getChannelNumbers(); + try { + await Promise.all( channelNumbers.map( async(x) => { + let desc = await dizquetv.getChannelDescription(x); + let option = { + id: x, + description: `${x} - ${desc.name}`, + }; + let i = 0; + while (i < scope.options.length) { + if (scope.options[i].id == x) { + scope.options[i] = option; + break; + } + i++; + } + if (i == scope.options.length) { + scope.options.push(option); + } + scope.$apply(); + }) ); + } catch (err) { + console.error(err); + } + scope.options.sort( (a,b) => a.id - b.id ); + scope.loading = false; + $timeout( () => scope.$apply(), 0); + }; + scope.refreshChannels(); + + scope.onCancel = () => { + scope.visible = false; + } + + scope.onDone = () => { + scope.error = ""; + if (typeof(scope.program.channel) === 'undefined') { + scope.error = "Please select a channel."; + } + if ( isNaN(scope.program.channel) ) { + scope.error = "Channel must be a number."; + } + if ( isNaN(scope.durationSeconds) ) { + scope.error = "Duration must be a number."; + } + if ( scope.error != "" ) { + $timeout( () => scope.error = "", 60000); + return; + } + scope.program.duration = scope.durationSeconds * 1000; + scope._onDone( scope.program ); + scope.visible = false; + + }; + + } + }; +} diff --git a/web/directives/remove-shows.js b/web/directives/remove-shows.js new file mode 100644 index 0000000..33efcd5 --- /dev/null +++ b/web/directives/remove-shows.js @@ -0,0 +1,29 @@ +module.exports = function ($timeout) { + return { + restrict: 'E', + templateUrl: 'templates/remove-shows.html', + replace: true, + scope: { + programTitles: "=programTitles", + visible: "=visible", + onDone: "=onDone", + deleted: "=deleted" + }, + link: function (scope, element, attrs) { + scope.toggleShowDeletion = (programTitle) => { + const deletedIdx = scope.deleted.indexOf(programTitle); + if (deletedIdx === -1) { + scope.deleted.push(programTitle); + } else { + scope.deleted.splice(deletedIdx, 1); + } + } + scope.finished = () => { + const d = scope.deleted; + scope.programTitles = null; + scope.deleted = null; + scope.onDone(d); + } + } + }; +} diff --git a/web/public/templates/channel-config.html b/web/public/templates/channel-config.html index 83cfd0c..2cfe8e4 100644 --- a/web/public/templates/channel-config.html +++ b/web/public/templates/channel-config.html @@ -150,6 +150,16 @@
Add Breaks

Adds Flex breaks between programs, attempting to avoid groups of consecutive programs that exceed the specified number of minutes.

+
Repeat Blocks
+

There's hopefully going to be an explanation here

+ +
Add Redirect
+

Adds a channel redirect. During this period of time, the channel will redirect to another channel.

+ +
"Channel at Night"
+

Will redirect to another channel while between the selected hours.

+ +
Remove Duplicates

Removes repeated videos.

@@ -159,6 +169,9 @@
Remove Specials

Removes any specials from the schedule. Specials are episodes with season "00".

+
Remove Show(s)
+

Allows you to pick specific shows to remove from your channel.

+
Remove All

Wipes out the schedule so that you can start over.

@@ -204,9 +217,10 @@ +
- +
@@ -250,16 +264,61 @@
-
+
+
+ + + +
+ +
+
+ + +
+
+ +
+ +
+
+
+
+ + + +
+ +
+
+ + +
+ + +
+
-
+
-
+
-
+
+ +
+
@@ -287,7 +346,7 @@ ⋮
-
@@ -301,7 +360,8 @@ {{ x.type === 'episode' ? x.showTitle + ' - S' + x.season.toString().padStart(2, '0') + 'E' + x.episode.toString().padStart(2, '0') : x.title}}
- Flex + Flex + Redirect to channel: {{x.channel}}
diff --git a/web/public/templates/channel-redirect.html b/web/public/templates/channel-redirect.html new file mode 100644 index 0000000..19cb498 --- /dev/null +++ b/web/public/templates/channel-redirect.html @@ -0,0 +1,35 @@ +
+ +
\ No newline at end of file diff --git a/web/public/templates/plex-server-edit.html b/web/public/templates/plex-server-edit.html index 9a0d7d7..6859629 100644 --- a/web/public/templates/plex-server-edit.html +++ b/web/public/templates/plex-server-edit.html @@ -11,7 +11,7 @@
- +
diff --git a/web/public/templates/remove-shows.html b/web/public/templates/remove-shows.html new file mode 100644 index 0000000..0dc7dbf --- /dev/null +++ b/web/public/templates/remove-shows.html @@ -0,0 +1,38 @@ +
+ +
\ No newline at end of file diff --git a/web/public/templates/xmltv-settings.html b/web/public/templates/xmltv-settings.html index 0d685b2..f5cb495 100644 --- a/web/public/templates/xmltv-settings.html +++ b/web/public/templates/xmltv-settings.html @@ -9,7 +9,8 @@
Output Path
- + + You can edit this location in file xmltv-settings.json.