From d6b2bd1d5e161c89f98cee87d5f516f4e06e6d70 Mon Sep 17 00:00:00 2001 From: vexorian Date: Fri, 19 Feb 2021 16:20:46 -0400 Subject: [PATCH 1/2] Random Slots --- src/api.js | 19 +- src/services/random-slots-service.js | 469 ++++++++++++++++++ web/app.js | 1 + web/directives/channel-config.js | 26 +- .../random-slots-schedule-editor.js | 338 +++++++++++++ web/directives/time-slots-schedule-editor.js | 2 +- web/public/templates/channel-config.html | 18 +- .../random-slots-schedule-editor.html | 185 +++++++ web/services/dizquetv.js | 13 + 9 files changed, 1063 insertions(+), 8 deletions(-) create mode 100644 src/services/random-slots-service.js create mode 100644 web/directives/random-slots-schedule-editor.js create mode 100644 web/public/templates/random-slots-schedule-editor.html diff --git a/src/api.js b/src/api.js index 5ab4a41..9c41ef6 100644 --- a/src/api.js +++ b/src/api.js @@ -8,8 +8,9 @@ const constants = require('./constants'); const FFMPEGInfo = require('./ffmpeg-info'); const PlexServerDB = require('./dao/plex-server-db'); const Plex = require("./plex.js"); -const FillerDB = require('./dao/filler-db'); + const timeSlotsService = require('./services/time-slots-service'); +const randomSlotsService = require('./services/random-slots-service'); module.exports = { router: api } function api(db, channelDB, fillerDB, xmltvInterval, guideService, _m3uService ) { @@ -583,9 +584,23 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService, _m3uService //tool services router.post('/api/channel-tools/time-slots', async (req, res) => { try { - await m3uService.clearCache(); let toolRes = await timeSlotsService(req.body.programs, req.body.schedule); if ( typeof(toolRes.userError) !=='undefined') { + console.error("time slots error: " + toolRes.userError); + return res.status(400).send(toolRes.userError); + } + res.status(200).send(toolRes); + } catch(err) { + console.error(err); + res.status(500).send("Internal error"); + } + }); + + router.post('/api/channel-tools/random-slots', async (req, res) => { + try { + let toolRes = await randomSlotsService(req.body.programs, req.body.schedule); + if ( typeof(toolRes.userError) !=='undefined') { + console.error("random slots error: " + toolRes.userError); return res.status(400).send(toolRes.userError); } res.status(200).send(toolRes); diff --git a/src/services/random-slots-service.js b/src/services/random-slots-service.js new file mode 100644 index 0000000..931eeac --- /dev/null +++ b/src/services/random-slots-service.js @@ -0,0 +1,469 @@ +const constants = require("../constants"); + +const random = require('../helperFuncs').random; + +const MINUTE = 60*1000; +const DAY = 24*60*MINUTE; +const LIMIT = 40000; + + + +//This is a quadruplicate code, but maybe it doesn't have to be? +function getShow(program) { + //used for equalize and frequency tweak + if (program.isOffline) { + if (program.type == 'redirect') { + return { + description : `Redirect to channel ${program.channel}`, + id: "redirect." + program.channel, + channel: program.channel, + } + } else { + return null; + } + } else if ( (program.type == 'episode') && ( typeof(program.showTitle) !== 'undefined' ) ) { + return { + description: program.showTitle, + id: "tv." + program.showTitle, + } + } else { + return { + description: "Movies", + id: "movie.", + } + } +} + + +function shuffle(array, lo, hi ) { + if (typeof(lo) === 'undefined') { + lo = 0; + hi = array.length; + } + let currentIndex = hi, temporaryValue, randomIndex + while (lo !== currentIndex) { + randomIndex = random.integer(lo, currentIndex-1); + currentIndex -= 1 + temporaryValue = array[currentIndex] + array[currentIndex] = array[randomIndex] + array[randomIndex] = temporaryValue + } + return array +} + +function _wait(t) { + return new Promise((resolve) => { + setTimeout(resolve, t); + }); +} + +function getProgramId(program) { + let s = program.serverKey; + if (typeof(s) === 'undefined') { + s = 'unknown'; + } + let p = program.key; + if (typeof(p) === 'undefined') { + p = 'unknown'; + } + return s + "|" + p; +} + +function addProgramToShow(show, program) { + if ( (show.id == 'flex.') || show.id.startsWith("redirect.") ) { + //nothing to do + return; + } + let id = getProgramId(program) + if(show.programs[id] !== true) { + show.programs.push(program); + show.programs[id] = true + } +} + +function getShowOrderer(show) { + if (typeof(show.orderer) === 'undefined') { + + let sortedPrograms = JSON.parse( JSON.stringify(show.programs) ); + sortedPrograms.sort((a, b) => { + if (a.season === b.season) { + if (a.episode > b.episode) { + return 1 + } else { + return -1 + } + } else if (a.season > b.season) { + return 1; + } else if (b.season > a.season) { + return -1; + } else { + return 0 + } + }); + + let position = 0; + while ( + (position + 1 < sortedPrograms.length ) + && + ( + show.founder.season !== sortedPrograms[position].season + || + show.founder.episode !== sortedPrograms[position].episode + ) + ) { + position++; + } + + + show.orderer = { + + current : () => { + return sortedPrograms[position]; + }, + + next: () => { + position = (position + 1) % sortedPrograms.length; + }, + + } + } + return show.orderer; +} + + +function getShowShuffler(show) { + if (typeof(show.shuffler) === 'undefined') { + if (typeof(show.programs) === 'undefined') { + throw Error(show.id + " has no programs?") + } + + let randomPrograms = JSON.parse( JSON.stringify(show.programs) ); + let n = randomPrograms.length; + shuffle( randomPrograms, 0, n); + let position = 0; + + show.shuffler = { + + current : () => { + return randomPrograms[position]; + }, + + next: () => { + position++; + if (position == n) { + let a = Math.floor(n / 2); + shuffle(randomPrograms, 0, a ); + shuffle(randomPrograms, a, n ); + position = 0; + } + }, + + } + } + return show.shuffler; +} + +module.exports = async( programs, schedule ) => { + if (! Array.isArray(programs) ) { + return { userError: 'Expected a programs array' }; + } + if (typeof(schedule) === 'undefined') { + return { userError: 'Expected a schedule' }; + } + //verify that the schedule is in the correct format + if (! Array.isArray(schedule.slots) ) { + return { userError: 'Expected a "slots" array in schedule' }; + } + if (typeof(schedule).period === 'undefined') { + schedule.period = DAY; + } + for (let i = 0; i < schedule.slots.length; i++) { + if (typeof(schedule.slots[i].duration) === 'undefined') { + return { userError: "Each slot should have a duration" }; + } + if (typeof(schedule.slots[i].showId) === 'undefined') { + return { userError: "Each slot should have a showId" }; + } + if ( + (schedule.slots[i].duration <= 0) + || (Math.floor(schedule.slots[i].duration) != schedule.slots[i].duration) + ) { + return { userError: "Slot duration should be a integer number of milliseconds greater than 0" }; + } + if ( isNaN(schedule.slots[i].cooldown) ) { + schedule.slots[i].cooldown = 0; + } + if ( isNaN(schedule.slots[i].weight) ) { + schedule.slots[i].weight = 1; + } + } + if (typeof(schedule.pad) === 'undefined') { + return { userError: "Expected schedule.pad" }; + } + if (typeof(schedule.maxDays) == 'undefined') { + return { userError: "schedule.maxDays must be defined." }; + } + if (typeof(schedule.flexPreference) === 'undefined') { + schedule.flexPreference = "distribute"; + } + if (typeof(schedule.padStyle) === 'undefined') { + schedule.padStyle = "slot"; + } + if (schedule.padStyle !== "slot" && schedule.padStyle !== "episode") { + return { userError: `Invalid schedule.padStyle value: "${schedule.padStyle}"` }; + } + let flexBetween = ( schedule.flexPreference !== "end" ); + + // throttle so that the stream is not affected negatively + let steps = 0; + let throttle = async() => { + if (steps++ == 10) { + steps = 0; + await _wait(1); + } + } + + let showsById = {}; + let shows = []; + + function getNextForSlot(slot, remaining) { + //remaining doesn't restrict what next show is picked. It is only used + //for shows with flexible length (flex and redirects) + if (slot.showId === "flex.") { + return { + isOffline: true, + duration: remaining, + } + } + let show = shows[ showsById[slot.showId] ]; + if (slot.showId.startsWith("redirect.")) { + return { + isOffline: true, + type: "redirect", + duration: remaining, + channel: show.channel, + } + } else if (slot.order === 'shuffle') { + return getShowShuffler(show).current(); + } else if (slot.order === 'next') { + return getShowOrderer(show).current(); + } + } + + function advanceSlot(slot) { + if ( (slot.showId === "flex.") || (slot.showId.startsWith("redirect") ) ) { + return; + } + let show = shows[ showsById[slot.showId] ]; + if (slot.order === 'shuffle') { + return getShowShuffler(show).next(); + } else if (slot.order === 'next') { + return getShowOrderer(show).next(); + } + } + + function makePadded(item) { + let padOption = schedule.pad; + if (schedule.padStyle === "slot") { + padOption = 1; + } + let x = item.duration; + let m = x % padOption; + let f = 0; + if ( (m > constants.SLACK) && (padOption - m > constants.SLACK) ) { + f = padOption - m; + } + return { + item: item, + pad: f, + totalDuration: item.duration + f, + } + + } + + // load the programs + for (let i = 0; i < programs.length; i++) { + let p = programs[i]; + let show = getShow(p); + if (show != null) { + if (typeof(showsById[show.id] ) === 'undefined') { + showsById[show.id] = shows.length; + shows.push( show ); + show.founder = p; + show.programs = []; + } else { + show = shows[ showsById[show.id] ]; + } + addProgramToShow( show, p ); + } + } + + let s = schedule.slots; + let ts = (new Date() ).getTime(); + let curr = ts - ts % DAY; + let t0 = ts; + let p = []; + let t = t0; + let wantedFinish = 0; + let hardLimit = t0 + schedule.maxDays * DAY; + + let pushFlex = (d) => { + if (d > 0) { + t += d; + if ( (p.length > 0) && p[p.length-1].isOffline && (p[p.length-1].type != 'redirect') ) { + p[p.length-1].duration += d; + } else { + p.push( { + duration: d, + isOffline : true, + } ); + } + } + } + + let slotLastPlayed = {}; + + while ( (t < hardLimit) && (p.length < LIMIT) ) { + await throttle(); + //ensure t is padded + let m = t % schedule.pad; + if ( (t % schedule.pad > constants.SLACK) && (schedule.pad - m > constants.SLACK) ) { + pushFlex( schedule.pad - m ); + continue; + } + + let slot = null; + let slotIndex = null; + let remaining = null; + + let n = 0; + let minNextTime = t + 24*DAY; + for (let i = 0; i < s.length; i++) { + if ( typeof( slotLastPlayed[i] ) !== undefined ) { + let lastt = slotLastPlayed[i]; + minNextTime = Math.min( minNextTime, lastt + s[i].cooldown ); + if (t - lastt < s[i].cooldown - constants.SLACK ) { + continue; + } + } + n += s[i].weight; + if ( random.bool(s[i].weight,n) ) { + slot = s[i]; + slotIndex = i; + remaining = s[i].duration; + } + } + if (slot == null) { + //Nothing to play, likely due to cooldown + pushFlex( minNextTime - t); + continue; + } + let item = getNextForSlot(slot, remaining); + + if (item.isOffline) { + //flex or redirect. We can just use the whole duration + p.push(item); + t += remaining; + slotLastPlayed[ slotIndex ] = t; + continue; + } + if (item.duration > remaining) { + // Slide + p.push(item); + t += item.duration; + slotLastPlayed[ slotIndex ] = t; + advanceSlot(slot); + continue; + } + + let padded = makePadded(item); + let total = padded.totalDuration; + advanceSlot(slot); + let pads = [ padded ]; + + while(true) { + let item2 = getNextForSlot(slot); + if (total + item2.duration > remaining) { + break; + } + let padded2 = makePadded(item2); + pads.push(padded2); + advanceSlot(slot); + total += padded2.totalDuration; + } + let temt = t + total; + let rem = 0; + if ( + (temt % schedule.pad >= constants.SLACK) + && (temt % schedule.pad < schedule.pad - constants.SLACK) + ) { + rem = schedule.pad - temt % schedule.pad; + } + + + if (flexBetween && (schedule.padStyle === 'episode') ) { + let div = Math.floor(rem / schedule.pad ); + let mod = rem % schedule.pad; + // add mod to the latest item + pads[ pads.length - 1].pad += mod; + pads[ pads.length - 1].totalDuration += mod; + + let sortedPads = pads.map( (p, $index) => { + return { + pad: p.pad, + index : $index, + } + }); + sortedPads.sort( (a,b) => { return a.pad - b.pad; } ); + for (let i = 0; i < pads.length; i++) { + let q = Math.floor( div / pads.length ); + if (i < div % pads.length) { + q++; + } + let j = sortedPads[i].index; + pads[j].pad += q * schedule.pad; + } + } else if (flexBetween) { + //just distribute it equitatively + let div = rem / pads.length; + for (let i = 0; i < pads.length; i++) { + pads[i].pad += div; + } + } else { + //also add div to the latest item + pads[ pads.length - 1].pad += rem; + pads[ pads.length - 1].totalDuration += rem; + } + // now unroll them all + for (let i = 0; i < pads.length; i++) { + p.push( pads[i].item ); + t += pads[i].item.duration; + slotLastPlayed[ slotIndex ] = t; + pushFlex( pads[i].pad ); + } + } + while ( (t > hardLimit) || (p.length >= LIMIT) ) { + t -= p.pop().duration; + } + let m = t % schedule.period; + let rem = 0; + if (m > wantedFinish) { + rem = schedule.period + wantedFinish - m; + } else if (m < wantedFinish) { + rem = wantedFinish - m; + } + if (rem > constants.SLACK) { + pushFlex(rem); + } + + + return { + programs: p, + startTime: (new Date(t0)).toISOString(), + } + +} + + + + diff --git a/web/app.js b/web/app.js index 9cfa66d..b1d9887 100644 --- a/web/app.js +++ b/web/app.js @@ -27,6 +27,7 @@ app.directive('channelRedirect', require('./directives/channel-redirect')) app.directive('plexServerEdit', require('./directives/plex-server-edit')) app.directive('channelConfig', require('./directives/channel-config')) app.directive('timeSlotsScheduleEditor', require('./directives/time-slots-schedule-editor')) +app.directive('randomSlotsScheduleEditor', require('./directives/random-slots-schedule-editor')) app.controller('settingsCtrl', require('./controllers/settings')) app.controller('channelsCtrl', require('./controllers/channels')) diff --git a/web/directives/channel-config.js b/web/directives/channel-config.js index fbbff90..8f15518 100644 --- a/web/directives/channel-config.js +++ b/web/directives/channel-config.js @@ -1846,8 +1846,7 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { return false; } - - scope.onTimeSlotsDone = (slotsResult) => { + let readSlotsResult = (slotsResult) => { scope.channel.programs = slotsResult.programs; let t = (new Date()).getTime(); @@ -1857,7 +1856,6 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { total += slotsResult.programs[i].duration; } - scope.channel.scheduleBackup = slotsResult.schedule; while(t1 > t) { //TODO: Replace with division @@ -1866,10 +1864,27 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { scope.channel.startTime = new Date(t1); adjustStartTimeToCurrentProgram(); updateChannelDuration(); + + }; + + + scope.onTimeSlotsDone = (slotsResult) => { + scope.channel.scheduleBackup = slotsResult.schedule; + readSlotsResult(slotsResult); } + + scope.onRandomSlotsDone = (slotsResult) => { + scope.channel.randomScheduleBackup = slotsResult.schedule; + readSlotsResult(slotsResult); + } + + scope.onTimeSlotsButtonClick = () => { scope.timeSlots.startDialog(scope.channel.programs, scope.maxSize, scope.channel.scheduleBackup ); } + scope.onRandomSlotsButtonClick = () => { + scope.randomSlots.startDialog(scope.channel.programs, scope.maxSize, scope.channel.randomScheduleBackup ); + } scope.logoOnChange = (event) => { const formData = new FormData(); @@ -1892,9 +1907,14 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { pre: function(scope) { scope.timeSlots = null; + scope.randomSlots = null; scope.registerTimeSlots = (timeSlots) => { scope.timeSlots = timeSlots; } + scope.registerRandomSlots = (randomSlots) => { + scope.randomSlots = randomSlots; + } + }, } diff --git a/web/directives/random-slots-schedule-editor.js b/web/directives/random-slots-schedule-editor.js new file mode 100644 index 0000000..7a6f334 --- /dev/null +++ b/web/directives/random-slots-schedule-editor.js @@ -0,0 +1,338 @@ + +module.exports = function ($timeout, dizquetv) { + const MINUTE = 60*1000; + const HOUR = 60*MINUTE; + const DAY = 24*HOUR; + const WEEK = 7 * DAY; + + return { + restrict: 'E', + templateUrl: 'templates/random-slots-schedule-editor.html', + replace: true, + scope: { + linker: "=linker", + onDone: "=onDone" + }, + + link: function (scope, element, attrs) { + scope.limit = 50000; + scope.visible = false; + + scope.badTimes = false; + scope._editedTime = null; + let showsById; + let shows; + + + function reset() { + showsById = {}; + shows = []; + scope.schedule = { + maxDays: 365, + flexPreference : "distribute", + padStyle: "slot", + randomDistribution: "uniform", + slots : [], + pad: 1, + } + } + reset(); + + function loadBackup(backup) { + scope.schedule = JSON.parse( JSON.stringify(backup) ); + if (typeof(scope.schedule.pad) == 'undefined') { + scope.schedule.pad = 1; + } + let slots = scope.schedule.slots; + for (let i = 0; i < slots.length; i++) { + let found = false; + for (let j = 0; j < scope.showOptions.length; j++) { + if (slots[i].showId == scope.showOptions[j].id) { + found = true; + } + } + if (! found) { + slots[i].showId = "flex."; + slots[i].order = "shuffle"; + } + } + if (typeof(scope.schedule.flexPreference) === 'undefined') { + scope.schedule.flexPreference = "distribute"; + } + if (typeof(scope.schedule.padStyle) === 'undefined') { + scope.schedule.padStyle = "slot"; + } + if (typeof(scope.schedule.randomDistribution) === 'undefined') { + scope.schedule.randomDistribution = "uniform"; + } + + scope.refreshSlots(); + + } + + getTitle = (index) => { + let showId = scope.schedule.slots[index].showId; + for (let i = 0; i < scope.showOptions.length; i++) { + if (scope.showOptions[i].id == showId) { + return scope.showOptions[i].description; + } + } + return "Unknown"; + } + scope.isWeekly = () => { + return (scope.schedule.period === WEEK); + }; + scope.addSlot = () => { + scope.schedule.slots.push( + { + duration: 30 * MINUTE, + showId: "flex.", + order: "next", + cooldown : 0, + } + ); + } + scope.timeColumnClass = () => { + return { "col-md-1": true}; + } + scope.programColumnClass = () => { + return { "col-md-6": true}; + }; + scope.durationOptions = [ + { id: 5 * MINUTE , description: "5 Minutes" }, + { id: 10 * MINUTE , description: "10 Minutes" }, + { id: 15 * MINUTE , description: "15 Minutes" }, + { id: 20 * MINUTE , description: "20 Minutes" }, + { id: 25 * MINUTE , description: "25 Minutes" }, + { id: 30 * MINUTE , description: "30 Minutes" }, + { id: 45 * MINUTE , description: "45 Minutes" }, + { id: 1 * HOUR , description: "1 Hour" }, + { id: 90 * MINUTE , description: "90 Minutes" }, + { id: 100 * MINUTE , description: "100 Minutes" }, + { id: 2 * HOUR , description: "2 Hours" }, + { id: 3 * HOUR , description: "3 Hours" }, + { id: 4 * HOUR , description: "4 Hours" }, + { id: 5 * HOUR , description: "5 Hours" }, + { id: 6 * HOUR , description: "6 Hours" }, + { id: 8 * HOUR , description: "8 Hours" }, + { id: 10* HOUR , description: "10 Hours" }, + { id: 12* HOUR , description: "12 Hours" }, + { id: 1 * DAY , description: "1 Day" }, + ]; + scope.cooldownOptions = [ + { id: 0 , description: "No cooldown" }, + { id: 1 * MINUTE , description: "1 Minute" }, + { id: 5 * MINUTE , description: "5 Minutes" }, + { id: 10 * MINUTE , description: "10 Minutes" }, + { id: 15 * MINUTE , description: "15 Minutes" }, + { id: 20 * MINUTE , description: "20 Minutes" }, + { id: 25 * MINUTE , description: "25 Minutes" }, + { id: 30 * MINUTE , description: "30 Minutes" }, + { id: 45 * MINUTE , description: "45 Minutes" }, + { id: 1 * HOUR , description: "1 Hour" }, + { id: 90 * MINUTE , description: "90 Minutes" }, + { id: 100 * MINUTE , description: "100 Minutes" }, + { id: 2 * HOUR , description: "2 Hours" }, + { id: 3 * HOUR , description: "3 Hours" }, + { id: 4 * HOUR , description: "4 Hours" }, + { id: 5 * HOUR , description: "5 Hours" }, + { id: 6 * HOUR , description: "6 Hours" }, + { id: 8 * HOUR , description: "8 Hours" }, + { id: 10* HOUR , description: "10 Hours" }, + { id: 12* HOUR , description: "12 Hours" }, + { id: 1 * DAY , description: "1 Day" }, + { id: 1 * DAY , description: "2 Days" }, + { id: 3 * DAY + 12 * HOUR , description: "3.5 Days" }, + { id: 7 * DAY , description: "1 Week" }, + ]; + + scope.flexOptions = [ + { id: "distribute", description: "Between videos" }, + { id: "end", description: "End of the slot" }, + ] + + scope.distributionOptions = [ + { id: "uniform", description: "Uniform" }, + { id: "weighted", description: "Weighted" }, + ] + + + scope.padOptions = [ + {id: 1, description: "Do not pad" }, + {id: 1*MINUTE, description: "0:00, 0:01, 0:02, ..., 0:59" }, + {id: 5*MINUTE, description: "0:00, 0:05, 0:10, ..., 0:55" }, + {id: 10*60*1000, description: "0:00, 0:10, 0:20, ..., 0:50" }, + {id: 15*60*1000, description: "0:00, 0:15, 0:30, ..., 0:45" }, + {id: 30*60*1000, description: "0:00, 0:30" }, + {id: 1*60*60*1000, description: "0:00" }, + ]; + scope.padStyleOptions = [ + {id: "episode" , description: "Pad Episodes" }, + {id: "slot" , description: "Pad Slots" }, + ]; + + scope.showOptions = []; + scope.orderOptions = [ + { id: "next", description: "Play Next" }, + { id: "shuffle", description: "Shuffle" }, + ]; + + let doIt = async() => { + let res = await dizquetv.calculateRandomSlots(scope.programs, scope.schedule ); + for (let i = 0; i < scope.schedule.slots.length; i++) { + delete scope.schedule.slots[i].weightPercentage; + } + res.schedule = scope.schedule; + return res; + } + + + + + let startDialog = (programs, limit, backup) => { + scope.limit = limit; + scope.programs = programs; + + reset(); + + + + programs.forEach( (p) => { + let show = getShow(p); + if (show != null) { + if (typeof(showsById[show.id]) === 'undefined') { + showsById[show.id] = shows.length; + shows.push( show ); + } else { + show = shows[ showsById[show.id] ]; + } + } + } ); + scope.showOptions = shows.map( (show) => { return show } ); + scope.showOptions.push( { + id: "flex.", + description: "Flex", + } ); + if (typeof(backup) !== 'undefined') { + loadBackup(backup); + } + + scope.visible = true; + } + + + scope.linker( { + startDialog: startDialog, + } ); + + scope.finished = async (cancel) => { + scope.error = null; + if (!cancel) { + try { + scope.loading = true; + $timeout(); + scope.onDone( await doIt() ); + scope.visible = false; + } catch(err) { + console.error("Unable to generate channel lineup", err); + scope.error = "There was an error processing the schedule"; + return; + } finally { + scope.loading = false; + $timeout(); + } + } else { + scope.visible = false; + } + } + + scope.deleteSlot = (index) => { + scope.schedule.slots.splice(index, 1); + } + + scope.hasTimeError = (slot) => { + return typeof(slot.timeError) !== 'undefined'; + } + + scope.disableCreateLineup = () => { + if (scope.badTimes) { + return true; + } + if (typeof(scope.schedule.maxDays) === 'undefined') { + return true; + } + if (scope.schedule.slots.length == 0) { + return true; + } + return false; + } + + scope.canShowSlot = (slot) => { + return (slot.showId != 'flex.') && !(slot.showId.startsWith('redirect.')); + } + + scope.refreshSlots = () => { + let sum = 0; + for (let i = 0; i < scope.schedule.slots.length; i++) { + sum += scope.schedule.slots[i].weight; + } + for (let i = 0; i < scope.schedule.slots.length; i++) { + if (scope.schedule.slots[i].showId == 'movie.') { + scope.schedule.slots[i].order = "shuffle"; + } + if ( isNaN(scope.schedule.slots[i].cooldown) ) { + scope.schedule.slots[i].cooldown = 0; + } + scope.schedule.slots[i].weightPercentage + = (100 * scope.schedule.slots[i].weight / sum).toFixed(2) + "%"; + } + $timeout(); + } + + scope.randomDistributionChanged = () => { + if (scope.schedule.randomDistribution === 'uniform') { + for (let i = 0; i < scope.schedule.slots.length; i++) { + scope.schedule.slots[i].weight = 1; + } + } else { + for (let i = 0; i < scope.schedule.slots.length; i++) { + scope.schedule.slots[i].weight = 300; + } + } + scope.refreshSlots(); + } + + + + } + }; +} + + +//This is a duplicate code, but maybe it doesn't have to be? +function getShow(program) { + //used for equalize and frequency tweak + if (program.isOffline) { + if (program.type == 'redirect') { + return { + description : `Redirect to channel ${program.channel}`, + id: "redirect." + program.channel, + channel: program.channel, + } + } else { + return null; + } + } else if ( (program.type == 'episode') && ( typeof(program.showTitle) !== 'undefined' ) ) { + return { + description: program.showTitle, + id: "tv." + program.showTitle, + } + } else { + return { + description: "Movies", + id: "movie.", + } + } +} + + diff --git a/web/directives/time-slots-schedule-editor.js b/web/directives/time-slots-schedule-editor.js index b3d34f5..bf673cc 100644 --- a/web/directives/time-slots-schedule-editor.js +++ b/web/directives/time-slots-schedule-editor.js @@ -68,7 +68,7 @@ module.exports = function ($timeout, dizquetv) { } } - getTitle = (index) => { + let getTitle = (index) => { let showId = scope.schedule.slots[index].showId; for (let i = 0; i < scope.showOptions.length; i++) { if (scope.showOptions[i].id == showId) { diff --git a/web/public/templates/channel-config.html b/web/public/templates/channel-config.html index 53606da..d0a655d 100644 --- a/web/public/templates/channel-config.html +++ b/web/public/templates/channel-config.html @@ -449,7 +449,7 @@

Slides the whole schedule. The "Fast-Forward" button will advance the stream by the specified amount of time. The "Rewind" button does the opposite.

-
+
-

A more advanced dialog wip description.

+

This allows to schedul specific shows to run at specific time slots of the day or a week. It's recommended you first populate the channel with the episodes from the shows you want to play and/or other content like movies and redirects.

+ +
+ +
+
+ +
+

This is similar to Time Slots, but instead of time sections, you pick a probability to play each tv show and the length of the block.

+
@@ -849,4 +862,5 @@ +
diff --git a/web/public/templates/random-slots-schedule-editor.html b/web/public/templates/random-slots-schedule-editor.html new file mode 100644 index 0000000..3cb0eff --- /dev/null +++ b/web/public/templates/random-slots-schedule-editor.html @@ -0,0 +1,185 @@ +
+ +
\ No newline at end of file diff --git a/web/services/dizquetv.js b/web/services/dizquetv.js index de70f0d..0c20a91 100644 --- a/web/services/dizquetv.js +++ b/web/services/dizquetv.js @@ -266,6 +266,19 @@ module.exports = function ($http, $q) { return d.data; }, + calculateRandomSlots: async( programs, schedule) => { + let d = await $http( { + method: "POST", + url : "/api/channel-tools/random-slots", + data: { + programs: programs, + schedule: schedule, + }, + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + } ); + return d.data; + }, + /*====================================================================== * Settings */ From 87b6bb6d85c0758843e95be2575da6270105fb42 Mon Sep 17 00:00:00 2001 From: vexorian Date: Sun, 21 Feb 2021 00:33:25 -0400 Subject: [PATCH 2/2] #144 Notification toast when updating settings (and other things) --- index.js | 47 ++- src/api.js | 297 +++++++++++++++++- src/services/event-service.js | 47 +++ src/tv-guide-service.js | 29 +- web/app.js | 1 + web/directives/toast-notifications.js | 116 +++++++ web/public/index.html | 1 + web/public/style.css | 32 ++ web/public/templates/ffmpeg-settings.html | 2 +- web/public/templates/hdhr-settings.html | 2 +- web/public/templates/plex-settings.html | 2 +- web/public/templates/toast-notifications.html | 11 + web/public/templates/xmltv-settings.html | 2 +- 13 files changed, 579 insertions(+), 10 deletions(-) create mode 100644 src/services/event-service.js create mode 100644 web/directives/toast-notifications.js create mode 100644 web/public/templates/toast-notifications.html diff --git a/index.js b/index.js index 442f8ec..6eaf637 100644 --- a/index.js +++ b/index.js @@ -21,6 +21,7 @@ const ChannelDB = require("./src/dao/channel-db"); const M3uService = require("./src/services/m3u-service"); const FillerDB = require("./src/dao/filler-db"); const TVGuideService = require("./src/tv-guide-service"); +const EventService = require("./src/services/event-service"); const onShutdown = require("node-graceful-shutdown").onShutdown; console.log( @@ -76,6 +77,7 @@ db.connect(process.env.DATABASE, ['channels', 'plex-servers', 'ffmpeg-settings', fileCache = new FileCacheService( path.join(process.env.DATABASE, 'cache') ); cacheImageService = new CacheImageService(db, fileCache); m3uService = new M3uService(channelDB, fileCache, channelCache) +eventService = new EventService(); initDB(db, channelDB) @@ -165,6 +167,8 @@ xmltvInterval.startInterval() let hdhr = HDHR(db, channelDB) let app = express() +eventService.setup(app); + app.use(fileUpload({ createParentPath: true })); @@ -197,7 +201,7 @@ app.use('/favicon.svg', express.static( ) ); // API Routers -app.use(api.router(db, channelDB, fillerDB, xmltvInterval, guideService, m3uService )) +app.use(api.router(db, channelDB, fillerDB, xmltvInterval, guideService, m3uService, eventService )) app.use('/api/cache/images', cacheImageService.apiRouters()) app.use(video.router( channelDB, fillerDB, db)) @@ -242,8 +246,49 @@ function initDB(db, channelDB) { } + +function _wait(t) { + return new Promise((resolve) => { + setTimeout(resolve, t); + }); +} + + +async function sendEventAfterTime() { + let t = (new Date()).getTime(); + await _wait(20000); + eventService.push( + "lifecycle", + { + "message": `Server Started`, + "detail" : { + "time": t, + }, + "level" : "success" + } + ); + +} +sendEventAfterTime(); + + + + onShutdown("log" , [], async() => { + let t = (new Date()).getTime(); + eventService.push( + "lifecycle", + { + "message": `Initiated Server Shutdown`, + "detail" : { + "time": t, + }, + "level" : "warning" + } + ); + console.log("Received exit signal, attempting graceful shutdonw..."); + await _wait(2000); }); onShutdown("xmltv-writer" , [], async() => { await xmltv.shutdown(); diff --git a/src/api.js b/src/api.js index 9c41ef6..1e417b8 100644 --- a/src/api.js +++ b/src/api.js @@ -12,9 +12,20 @@ const Plex = require("./plex.js"); const timeSlotsService = require('./services/time-slots-service'); const randomSlotsService = require('./services/random-slots-service'); +function safeString(object) { + let o = object; + for(let i = 1; i < arguments.length; i++) { + o = o[arguments[i]]; + if (typeof(o) === 'undefined') { + return "missing"; + } + } + return String(o); +} + module.exports = { router: api } -function api(db, channelDB, fillerDB, xmltvInterval, guideService, _m3uService ) { - const m3uService = _m3uService; +function api(db, channelDB, fillerDB, xmltvInterval, guideService, _m3uService, eventService ) { + let m3uService = _m3uService; const router = express.Router() const plexServerDB = new PlexServerDB(channelDB, channelCache, db); @@ -89,34 +100,113 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService, _m3uService } }) router.delete('/api/plex-servers', async (req, res) => { + let name = "unknown"; try { - let name = req.body.name; + name = req.body.name; if (typeof(name) === 'undefined') { return res.status(400).send("Missing name"); } let report = await plexServerDB.deleteServer(name); res.send(report) + eventService.push( + "settings-update", + { + "message": `Plex server ${name} removed.`, + "module" : "plex-server", + "detail" : { + "serverName" : name, + "action" : "delete" + }, + "level" : "warn" + } + ); + } catch(err) { console.error(err); res.status(500).send("error"); + eventService.push( + "settings-update", + { + "message": "Error deleting plex server.", + "module" : "plex-server", + "detail" : { + "action": "delete", + "serverName" : name, + "error" : safeString(err, "message"), + }, + "level" : "danger" + } + ); } }) router.post('/api/plex-servers', async (req, res) => { try { await plexServerDB.updateServer(req.body); res.status(204).send("Plex server updated.");; + eventService.push( + "settings-update", + { + "message": `Plex server ${req.body.name} updated.`, + "module" : "plex-server", + "detail" : { + "serverName" : req.body.name, + "action" : "update" + }, + "level" : "info" + } + ); + } catch (err) { - console.error("Could not add plex server.", err); + console.error("Could not update plex server.", err); res.status(400).send("Could not add plex server."); + eventService.push( + "settings-update", + { + "message": "Error updating plex server.", + "module" : "plex-server", + "detail" : { + "action": "update", + "serverName" : safeString(req, "body", "name"), + "error" : safeString(err, "message"), + }, + "level" : "danger" + } + ); } }) router.put('/api/plex-servers', async (req, res) => { try { await plexServerDB.addServer(req.body); res.status(201).send("Plex server added.");; + eventService.push( + "settings-update", + { + "message": `Plex server ${req.body.name} added.`, + "module" : "plex-server", + "detail" : { + "serverName" : req.body.name, + "action" : "add" + }, + "level" : "info" + } + ); + } catch (err) { console.error("Could not add plex server.", err); res.status(400).send("Could not add plex server."); + eventService.push( + "settings-update", + { + "message": "Error adding plex server.", + "module" : "plex-server", + "detail" : { + "action": "add", + "serverName" : safeString(req, "body", "name"), + "error" : safeString(err, "message"), + }, + "level" : "danger" + } + ); } }) @@ -341,10 +431,34 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService, _m3uService if (typeof(err) !== 'undefined') { return res.status(400).send(err); } + eventService.push( + "settings-update", + { + "message": "FFMPEG configuration updated.", + "module" : "ffmpeg", + "detail" : { + "action" : "update" + }, + "level" : "info" + } + ); res.send(ffmpeg) } catch(err) { console.error(err); res.status(500).send("error"); + eventService.push( + "settings-update", + { + "message": "Error updating FFMPEG configuration.", + "module" : "ffmpeg", + "detail" : { + "action": "update", + "error" : safeString(err, "message"), + }, + "level" : "danger" + } + ); + } }) router.post('/api/ffmpeg-settings', (req, res) => { // RESET @@ -353,10 +467,34 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService, _m3uService ffmpeg.ffmpegPath = req.body.ffmpegPath; db['ffmpeg-settings'].update({ _id: req.body._id }, ffmpeg) ffmpeg = db['ffmpeg-settings'].find()[0] + eventService.push( + "settings-update", + { + "message": "FFMPEG configuration reset.", + "module" : "ffmpeg", + "detail" : { + "action" : "reset" + }, + "level" : "warning" + } + ); res.send(ffmpeg) } catch(err) { console.error(err); res.status(500).send("error"); + eventService.push( + "settings-update", + { + "message": "Error reseting FFMPEG configuration.", + "module" : "ffmpeg", + "detail" : { + "action": "reset", + "error" : safeString(err, "message"), + }, + "level" : "danger" + } + ); + } }) @@ -384,9 +522,34 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService, _m3uService db['plex-settings'].update({ _id: req.body._id }, req.body) let plex = db['plex-settings'].find()[0] res.send(plex) + eventService.push( + "settings-update", + { + "message": "Plex configuration updated.", + "module" : "plex", + "detail" : { + "action" : "update" + }, + "level" : "info" + } + ); + } catch(err) { console.error(err); res.status(500).send("error"); + eventService.push( + "settings-update", + { + "message": "Error updating Plex configuration", + "module" : "plex", + "detail" : { + "action": "update", + "error" : safeString(err, "message"), + }, + "level" : "danger" + } + ); + } }) @@ -415,9 +578,35 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService, _m3uService }) let plex = db['plex-settings'].find()[0] res.send(plex) + eventService.push( + "settings-update", + { + "message": "Plex configuration reset.", + "module" : "plex", + "detail" : { + "action" : "reset" + }, + "level" : "warning" + } + ); } catch(err) { console.error(err); res.status(500).send("error"); + + eventService.push( + "settings-update", + { + "message": "Error reseting Plex configuration", + "module" : "plex", + "detail" : { + "action": "reset", + "error" : safeString(err, "message"), + }, + "level" : "danger" + } + ); + + } }) @@ -458,10 +647,35 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService, _m3uService ); xmltv = db['xmltv-settings'].find()[0] res.send(xmltv) + eventService.push( + "settings-update", + { + "message": "xmltv settings updated.", + "module" : "xmltv", + "detail" : { + "action" : "update" + }, + "level" : "info" + } + ); updateXmltv() } catch(err) { console.error(err); res.status(500).send("error"); + + eventService.push( + "settings-update", + { + "message": "Error updating xmltv configuration", + "module" : "xmltv", + "detail" : { + "action": "update", + "error" : safeString(err, "message"), + }, + "level" : "danger" + } + ); + } }) @@ -475,10 +689,35 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService, _m3uService }) var xmltv = db['xmltv-settings'].find()[0] res.send(xmltv) + eventService.push( + "settings-update", + { + "message": "xmltv settings reset.", + "module" : "xmltv", + "detail" : { + "action" : "reset" + }, + "level" : "warning" + } + ); + updateXmltv() } catch(err) { console.error(err); res.status(500).send("error"); + eventService.push( + "settings-update", + { + "message": "Error reseting xmltv configuration", + "module" : "xmltv", + "detail" : { + "action": "reset", + "error" : safeString(err, "message"), + }, + "level" : "danger" + } + ); + } }) @@ -537,9 +776,34 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService, _m3uService db['hdhr-settings'].update({ _id: req.body._id }, req.body) let hdhr = db['hdhr-settings'].find()[0] res.send(hdhr) + eventService.push( + "settings-update", + { + "message": "HDHR configuration updated.", + "module" : "hdhr", + "detail" : { + "action" : "update" + }, + "level" : "info" + } + ); + } catch(err) { console.error(err); res.status(500).send("error"); + eventService.push( + "settings-update", + { + "message": "Error updating HDHR configuration", + "module" : "hdhr", + "detail" : { + "action": "action", + "error" : safeString(err, "message"), + }, + "level" : "danger" + } + ); + } }) @@ -552,9 +816,34 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService, _m3uService }) var hdhr = db['hdhr-settings'].find()[0] res.send(hdhr) + eventService.push( + "settings-update", + { + "message": "HDHR configuration reset.", + "module" : "hdhr", + "detail" : { + "action" : "reset" + }, + "level" : "warning" + } + ); + } catch(err) { console.error(err); res.status(500).send("error"); + eventService.push( + "settings-update", + { + "message": "Error reseting HDHR configuration", + "module" : "hdhr", + "detail" : { + "action": "reset", + "error" : safeString(err, "message"), + }, + "level" : "danger" + } + ); + } }) diff --git a/src/services/event-service.js b/src/services/event-service.js new file mode 100644 index 0000000..1ac9893 --- /dev/null +++ b/src/services/event-service.js @@ -0,0 +1,47 @@ +const EventEmitter = require("events"); + +class EventsService { + constructor() { + this.stream = new EventEmitter(); + let that = this; + let fun = () => { + that.push( "heartbeat", "{}"); + setTimeout(fun, 5000) + }; + fun(); + + } + + setup(app) { + app.get("/api/events", (request, response) => { + console.log("Open event channel."); + response.writeHead(200, { + "Content-Type" : "text/event-stream", + "Cache-Control" : "no-cache", + "connection" : "keep-alive", + } ); + let listener = (event,data) => { + //console.log( String(event) + " " + JSON.stringify(data) ); + response.write("event: " + String(event) + "\ndata: " + + JSON.stringify(data) + "\nretry: 5000\n\n" ); + }; + + this.stream.on("push", listener ); + response.on( "close", () => { + console.log("Remove event channel."); + this.stream.removeListener("push", listener); + } ); + } ); + } + + push(event, data) { + if (typeof(data.message) !== 'undefined') { + console.log("Push event: " + data.message ); + } + this.stream.emit("push", event, data ); + } + + +} + +module.exports = EventsService; \ No newline at end of file diff --git a/src/tv-guide-service.js b/src/tv-guide-service.js index 5936dcd..d64da30 100644 --- a/src/tv-guide-service.js +++ b/src/tv-guide-service.js @@ -7,7 +7,7 @@ class TVGuideService /**** * **/ - constructor(xmltv, db, cacheImageService) { + constructor(xmltv, db, cacheImageService, eventService) { this.cached = null; this.lastUpdate = 0; this.updateTime = 0; @@ -19,6 +19,7 @@ class TVGuideService this.xmltv = xmltv; this.db = db; this.cacheImageService = cacheImageService; + this.eventService = eventService; } async get() { @@ -44,6 +45,19 @@ class TVGuideService this.currentUpdate = this.updateTime; this.currentLimit = this.updateLimit; this.currentChannels = this.updateChannels; + let t = "" + ( (new Date()) ); + eventService.push( + "xmltv", + { + "message": `Started building tv-guide at = ${t}`, + "module" : "xmltv", + "detail" : { + "time": new Date(), + }, + "level" : "info" + } + ); + await this.buildIt(); } await _wait(100); @@ -353,6 +367,19 @@ class TVGuideService async refreshXML() { let xmltvSettings = this.db['xmltv-settings'].find()[0]; await this.xmltv.WriteXMLTV(this.cached, xmltvSettings, async() => await this._throttle(), this.cacheImageService); + let t = "" + ( (new Date()) ); + eventService.push( + "xmltv", + { + "message": `XMLTV updated at server time = ${t}`, + "module" : "xmltv", + "detail" : { + "time": new Date(), + }, + "level" : "info" + } + ); + } async getStatus() { diff --git a/web/app.js b/web/app.js index b1d9887..1b15e15 100644 --- a/web/app.js +++ b/web/app.js @@ -19,6 +19,7 @@ app.directive('plexLibrary', require('./directives/plex-library')) app.directive('programConfig', require('./directives/program-config')) app.directive('flexConfig', require('./directives/flex-config')) app.directive('timeSlotsTimeEditor', require('./directives/time-slots-time-editor')) +app.directive('toastNotifications', require('./directives/toast-notifications')) app.directive('fillerConfig', require('./directives/filler-config')) app.directive('deleteFiller', require('./directives/delete-filler')) app.directive('frequencyTweak', require('./directives/frequency-tweak')) diff --git a/web/directives/toast-notifications.js b/web/directives/toast-notifications.js new file mode 100644 index 0000000..bb6f645 --- /dev/null +++ b/web/directives/toast-notifications.js @@ -0,0 +1,116 @@ +module.exports = function ($timeout) { + return { + restrict: 'E', + templateUrl: 'templates/toast-notifications.html', + replace: true, + scope: { + }, + link: function (scope, element, attrs) { + + const FADE_IN_START = 100; + const FADE_IN_END = 1000; + const FADE_OUT_START = 10000; + const TOTAL_DURATION = 11000; + + + scope.toasts = []; + + let eventSource = null; + + let timerHandle = null; + let refreshHandle = null; + + + let setResetTimer = () => { + if (timerHandle != null) { + clearTimeout( timerHandle ); + } + timerHandle = setTimeout( () => { + scope.setup(); + } , 10000); + }; + + let updateAfter = (wait) => { + if (refreshHandle != null) { + $timeout.cancel( refreshHandle ); + } + refreshHandle = $timeout( ()=> updater(), wait ); + }; + + let updater = () => { + let wait = 10000; + let updatedToasts = []; + try { + let t = (new Date()).getTime(); + for (let i = 0; i < scope.toasts.length; i++) { + let toast = scope.toasts[i]; + let diff = t - toast.time; + if (diff < TOTAL_DURATION) { + if (diff < FADE_IN_START) { + toast.clazz = { "about-to-fade-in" : true } + wait = Math.min( wait, FADE_IN_START - diff ); + } else if (diff < FADE_IN_END) { + toast.clazz = { "fade-in" : true } + wait = Math.min( wait, FADE_IN_END - diff ); + } else if (diff < FADE_OUT_START) { + toast.clazz = {} + wait = Math.min( wait, FADE_OUT_START - diff ); + } else { + toast.clazz = { "fade-out" : true } + wait = Math.min( wait, TOTAL_DURATION - diff ); + } + toast.clazz[toast.deco] = true; + updatedToasts.push(toast); + } + } + } catch (err) { + console.error("error", err); + } + scope.toasts = updatedToasts; + updateAfter(wait); + }; + + let addToast = (toast) => { + toast.time = (new Date()).getTime(); + toast.clazz= { "about-to-fade-in": true }; + toast.clazz[toast.deco] = true; + scope.toasts.push(toast); + $timeout( () => updateAfter(0) ); + }; + + let getDeco = (data) => { + return "bg-" + data.level; + } + + scope.setup = () => { + if (eventSource != null) { + eventSource.close(); + eventSource = null; + } + setResetTimer(); + + eventSource = new EventSource("api/events"); + + eventSource.addEventListener("heartbeat", () => { + setResetTimer(); + } ); + + let normalEvent = (title) => { + return (event) => { + let data = JSON.parse(event.data); + addToast ( { + title : title, + text : data.message, + deco: getDeco(data) + } ) + }; + }; + + eventSource.addEventListener('settings-update', normalEvent("Settings Update") ); + eventSource.addEventListener('xmltv', normalEvent("TV Guide") ); + eventSource.addEventListener('lifecycle', normalEvent("Server") ); + }; + scope.setup(); + } + }; +} diff --git a/web/public/index.html b/web/public/index.html index 3b20003..360b80c 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -43,6 +43,7 @@
+ diff --git a/web/public/style.css b/web/public/style.css index 0102e80..92643a7 100644 --- a/web/public/style.css +++ b/web/public/style.css @@ -357,6 +357,38 @@ div.programming-programs div.list-group-item { background : rgba(255,255,255, 0.1); } +.dizque-toast { + margin-top: 0.2rem; + padding: 0.5rem; + background: #FFFFFF; + border: 1px solid rgba(0,0,0,.1); + border-radius: .25rem; + color: #FFFFFF; +} + +.dizque-toast.bg-warning { + color: black +} + +.about-to-fade-in { + opacity: 0.00; + transition: opacity 1.00s ease-in-out; + -moz-transition: opacity 1.00s ease-in-out; + -webkit-transition: opacity 1.00s ease-in-out; +} +.fade-in { + opacity: 0.95; + transition: opacity 1.00s ease-in-out; + -moz-transition: opacity 1.00s ease-in-out; + -webkit-transition: opacity 1.00s ease-in-out; +} + +.fade-out { + transition: opacity 1.00s ease-in-out; + -moz-transition: opacity 1.00s ease-in-out; + -webkit-transition: opacity 1.00s ease-in-out; + opacity: 0.0; +} #dizquetv-logo { width: 1em; diff --git a/web/public/templates/ffmpeg-settings.html b/web/public/templates/ffmpeg-settings.html index 09fb68f..8ebd3ff 100644 --- a/web/public/templates/ffmpeg-settings.html +++ b/web/public/templates/ffmpeg-settings.html @@ -4,7 +4,7 @@ - diff --git a/web/public/templates/hdhr-settings.html b/web/public/templates/hdhr-settings.html index 0b6cd34..c00b6d3 100644 --- a/web/public/templates/hdhr-settings.html +++ b/web/public/templates/hdhr-settings.html @@ -3,7 +3,7 @@ - diff --git a/web/public/templates/plex-settings.html b/web/public/templates/plex-settings.html index 1324bf1..b5dfb39 100644 --- a/web/public/templates/plex-settings.html +++ b/web/public/templates/plex-settings.html @@ -70,7 +70,7 @@ - diff --git a/web/public/templates/toast-notifications.html b/web/public/templates/toast-notifications.html new file mode 100644 index 0000000..825a520 --- /dev/null +++ b/web/public/templates/toast-notifications.html @@ -0,0 +1,11 @@ +
+ +
+ {{ toast.title }} +
{{ toast.text }}
+
+
diff --git a/web/public/templates/xmltv-settings.html b/web/public/templates/xmltv-settings.html index 20c65a0..cfd3b97 100644 --- a/web/public/templates/xmltv-settings.html +++ b/web/public/templates/xmltv-settings.html @@ -4,7 +4,7 @@ -