Merge branch 'dev/0.0.x' into main

This commit is contained in:
vexorian 2020-08-26 21:20:59 -04:00
commit 665487e812
21 changed files with 799 additions and 79 deletions

View File

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

View File

@ -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}`)

View File

@ -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");
}
})

View File

@ -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];

View File

@ -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"
}

View File

@ -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) {

View File

@ -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',

View File

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

View File

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

View File

@ -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') {

View File

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

View File

@ -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";
}
}

View File

@ -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'))

View File

@ -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;
}
}

View File

@ -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;
};
}
};
}

View File

@ -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);
}
}
};
}

View File

@ -150,6 +150,16 @@
<h6>Add Breaks</h6>
<p>Adds Flex breaks between programs, attempting to avoid groups of consecutive programs that exceed the specified number of minutes.</p>
<h6>Repeat Blocks</h6>
<p>There&apos;s hopefully going to be an explanation here</p>
<h6>Add Redirect</h6>
<p>Adds a channel redirect. During this period of time, the channel will redirect to another channel.</p>
<h6>&quot;Channel at Night&quot;<h6>
<p>Will redirect to another channel while between the selected hours.</p>
<h6>Remove Duplicates</h6>
<p>Removes repeated videos.</p>
@ -159,6 +169,9 @@
<h6>Remove Specials</h6>
<p>Removes any specials from the schedule. Specials are episodes with season &quot;00&quot;.</p>
<h6>Remove Show(s)</h6>
<p>Allows you to pick specific shows to remove from your channel.</p>
<h6>Remove All</h6>
<p>Wipes out the schedule so that you can start over.</p>
@ -204,9 +217,10 @@
</div>
</div>
<div class="row">
<div class="input-group col-md-6" style="padding: 5px;">
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="addOffline()">Add Flex</button>
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="addOffline()">Add Flex...</button>
</div>
<div class="col-md-6" style="padding: 5px;">
@ -250,16 +264,61 @@
</div>
<div class="row">
<div class="input-group col-md-3" style="padding: 5px;">
<div class="input-group col-md-6" style="padding: 5px;">
<div class="input-group-prepend">
<select ng-model="rerunStart"
ng-options="o.id as o.description for o in rerunStartHours">
</select>
<select ng-model="rerunBlockSize"
ng-options="o.id as o.description for o in rerunBlockSizes">
</select>
<select ng-model="rerunRepeats"
ng-options="o.id as o.description for o in rerunRepeatOptions">
</select>
</div>
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="doReruns(rerunStart, rerunBlockSize, rerunRepeats)" ng-disabled="rerunStart == -1 || rerunBlockSize == -1 || rerunRepeats == -1" >Reruns</button>
</div>
</div>
<div class="row">
<div class="input-group col-md-6" style="padding: 5px;">
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="addRedirect()">Add Redirect...</button>
</div>
<div class="col-md-6" style="padding: 5px;">
<div class="input-group">
<div class="input-group-prepend">
<div class='loader' ng-hide='channelsDownloaded'></div>
<select ng-show='channelsDownloaded' style='width:5em;' ng-model="atNightChannelNumber"
ng-options="o.id as o.description for o in knownChannels" ></select>
<select ng-model="atNightStart"
ng-options="o.id as o.description for o in nightStartHours" ></select>
<select ng-model="atNightEnd"
ng-options="o.id as o.description for o in nightEndHours" ></select>
</div>
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="nightChannel(atNightEnd, atNightStart, atNightChannelNumber)" ng-disabled="atNightChannelNumber==-1 || atNightStart==-1 || atNightEnd==-1">&quot;Channel at Night&quot;</button>
</div>
</div>
</div>
<div class="row">
<div class="input-group col" style="padding: 5px;">
<button class="btn btn-sm btn-danger form-control form-control-sm" type="button" ng-click="removeDuplicates()">Remove Duplicates</button>
</div>
<div class="input-group col-md-3" style="padding: 5px;">
<div class="input-group col" style="padding: 5px;">
<button class="btn btn-sm btn-danger form-control form-control-sm" type="button" ng-click="removeOffline()">Remove Flex</button>
</div>
<div class="input-group col-md-3" style="padding: 5px;">
<div class="input-group col" style="padding: 5px;">
<button class="btn btn-sm btn-danger form-control form-control-sm" type="button" ng-click="wipeSpecials()">Remove Specials</button>
</div>
<div class="input-group col-md-3" style="padding: 5px;">
<div class="input-group col" style="padding: 5px;">
<button class="btn btn-sm btn-danger form-control form-control-sm" type="button" ng-click="startRemoveShows()">Remove Show(s)...</button>
</div>
<div class="input-group col" style="padding: 5px;">
<button class="btn btn-sm btn-danger form-control form-control-sm" type="button" ng-click="wipeSchedule()">Remove All</button>
</div>
</div>
@ -287,7 +346,7 @@
&#8942;
</div>
</div>
<div ng-if="minProgramIndex &lt;= $index &amp;&amp; $index &lt; minProgramIndex+100" ng-repeat="x in channel.programs" ng-click="selectProgram($index)" dnd-list="" dnd-drop="dropFunction(index , $index, item)"
<div ng-if="minProgramIndex &lt;= $index &amp;&amp; $index &lt; minProgramIndex+100" ng-repeat="x in channel.programs track by $index" ng-click="selectProgram($index)" dnd-list="" dnd-drop="dropFunction(index , $index, item)"
>
<div class="list-group-item flex-container program-row" dnd-draggable="x" dnd-moved="channel.programs.splice($index, 1);" dnd-effect-allowed="move"
>
@ -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}}
</div>
<div style="margin-right: 5px; font-weight:ligther" ng-show="x.isOffline">
<i>Flex</i>
<i ng-if="x.type !== 'redirect' " >Flex</i>
<span ng-if="x.type === 'redirect' " ><i>Redirect to channel:</i> <b>{{x.channel}}</b></span>
</div>
<div class="flex-pull-right"></div>
<button class="btn btn-sm btn-link" ng-click="removeItem($index); $event.stopPropagation()">
@ -347,6 +407,8 @@
<program-config program="_selectedProgram" on-done="finshedProgramEdit"></program-config>
<offline-config offline-title="Modify Flex Time" program="_selectedOffline" on-done="finishedOfflineEdit"></offline-config>
<frequency-tweak programs="_programFrequencies" message="_frequencyMessage" modified="_frequencyModified" on-done="tweakFrequencies"></frequency-tweak>
<remove-shows program-titles="_removablePrograms" on-done="removeShows" deleted="_deletedProgramNames"></remove-shows>
<offline-config offline-title="Add Flex Time" program="_addingOffline" on-done="finishedAddingOffline"></offline-config>
<plex-library height="300" visible="displayPlexLibrary" on-finish="importPrograms"></plex-library>
<channel-redirect visible="_displayRedirect" on-done="finishRedirect" form-title="_redirectTitle" program="_selectedRedirect" ></channel-redirect>
</div>

View File

@ -0,0 +1,35 @@
<div ng-show="visible">
<div class="modal" tabindex="-1" role="dialog" style="display: block; background-color: rgba(0, 0, 0, .5);">
<div class="modal-dialog modal-dialog-scrollable modal-xl" role="document">
<div class="modal-content">
<div>
<div class="modal-header">
<h5 class="modal-title">{{ formTitle }}</h5>
</div>
</div>
<div class="modal-body container">
<div class="form-group">
<label for="duration">Duration (seconds):</label>
<input id="duration" class="form-control" ng-model="durationSeconds" type="text" placeholder="{{state.server.name}}"></input>
</div>
<div ng-if="state.channelReport == null" class="form-group">
<label for="channel">Redirect to channel:</label>
<select id="channel" class="form-control" ng-model="program.channel"
ng-options="o.id as o.description for o in options" ></select>
<div class="loader" ng-if="loading"></div>
</div>
</div>
<div class="modal-footer">
<span class="text-danger">{{ error }}</span>
<button type="button" class="btn btn-sm btn-link" ng-click="onCancel()" >Cancel</button>
<button type="button" class="btn btn-sm btn-primary" ng-click="onDone()" >Save</button>
</div>
</div>
</div>
</div>
</div>

View File

@ -11,7 +11,7 @@
<div ng-if="state.channelReport == null" class="form-group">
<label for="serverName">Name:</label>
<input class="form-control" type="text" placeholder="{{state.server.name}}" readonly>
<input class="form-control" type="text" placeholder="{{state.server.name}}" readonly></input>
</div>
<div ng-if="state.channelReport == null" class="form-group">

View File

@ -0,0 +1,38 @@
<div ng-show="programTitles.length > 0">
<div class="modal" tabindex="-1" role="dialog" style="display: block; background-color: rgba(0, 0, 0, .5);">
<div class="modal-dialog modal-dialog-scrollable modal-xl" role="document">
<div class="modal-content">
<div>
<div class="modal-header">
<h5 class="modal-title">Remove TV Show(s)</h5>
</div>
</div>
<div class="modal-body container">
<div class="list-group list-group-root">
<div class="list-group-item flex-container program-row" ng-repeat="title in programTitles" ng-click="toggleShowDeletion(title)">
<div class='col-sm-7 col-md-9'>
<span ng-show='deleted.indexOf(title) === -1'>{{title}}</span>
<span class="text-muted" ng-show='deleted.indexOf(title) > -1'><strike>{{title}}</strike></span>
</div>
<div class="flex-pull-right"></div>
<div class='col-sm-1 col-md-1'>
<button class="btn btn-sm btn-link">
<i ng-show="deleted.indexOf(title) === -1" class="text-danger fa fa-trash"></i>
<i ng-show="deleted.indexOf(title) > -1" class="text-success fa fa-undo"></i>
</button>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-link" ng-click="programTitles = null" ng-show="deleted.length > 0">Cancel</button>
<button type="button" class="btn btn-sm btn-link" ng-click="programTitles = null" ng-show="deleted.length === 0">Close</button>
<button type="button" class="btn btn-sm btn-primary" ng-click="finished(programTitles);" ng-show="deleted.length > 0" >Apply</button>
</div>
</div>
</div>
</div>
</div>

View File

@ -9,7 +9,8 @@
</button>
</h5>
<h6>Output Path</h6>
<input type="text" class="form-control form-control-sm" ng-model="settings.file"/>
<input type="text" class="form-control form-control-sm" ng-model="settings.file" aria-describedby="pathhelp" readonly />
<small id="pathhelp" class="form-text text-muted">You can edit this location in file xmltv-settings.json.</small>
<br/>
<div class="row">
<div class="col-sm-6">