diff --git a/README.md b/README.md index 1b61744..0738cfd 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# dizqueTV 1.2.1 +# dizqueTV 1.2.2-prerelease ![Discord](https://img.shields.io/discord/711313431457693727?logo=discord&logoColor=fff&style=flat-square) ![GitHub top language](https://img.shields.io/github/languages/top/vexorian/dizquetv?logo=github&style=flat-square) ![Docker Pulls](https://img.shields.io/docker/pulls/vexorian/dizquetv?logo=docker&logoColor=fff&style=flat-square) Create live TV channel streams from media on your Plex servers. diff --git a/index.js b/index.js index 79d90a3..36ea844 100644 --- a/index.js +++ b/index.js @@ -17,6 +17,7 @@ const constants = require('./src/constants') const ChannelDB = require("./src/dao/channel-db"); const FillerDB = require("./src/dao/filler-db"); const TVGuideService = require("./src/tv-guide-service"); +const onShutdown = require("node-graceful-shutdown").onShutdown; console.log( ` \\ @@ -209,3 +210,11 @@ function initDB(db, channelDB) { } } + +onShutdown("log" , [], async() => { + console.log("Received exit signal, attempting graceful shutdonw..."); +}); +onShutdown("xmltv-writer" , [], async() => { + await xmltv.shutdown(); +} ); + diff --git a/package.json b/package.json index 22d7a59..a5498d0 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "angular": "^1.7.9", "angular-router-browserify": "0.0.2", "angular-vs-repeat": "2.0.13", + "random-js" : "2.1.0", "axios": "^0.19.2", "body-parser": "^1.19.0", "diskdb": "^0.1.17", @@ -26,6 +27,7 @@ "node-ssdp": "^4.0.0", "request": "^2.88.2", "uuid": "^8.0.0", + "node-graceful-shutdown" : "1.1.0", "xml-writer": "^1.7.0" }, "bin": "dist/index.js", diff --git a/src/constants.js b/src/constants.js index 43e5397..2211eda 100644 --- a/src/constants.js +++ b/src/constants.js @@ -5,5 +5,5 @@ module.exports = { TVGUIDE_MAXIMUM_FLEX_DURATION : 6 * 60 * 60 * 1000, TOO_FREQUENT: 100, - VERSION_NAME: "1.2.1" + VERSION_NAME: "1.2.2-prerelease" } diff --git a/src/helperFuncs.js b/src/helperFuncs.js index aaffd4c..abae7d0 100644 --- a/src/helperFuncs.js +++ b/src/helperFuncs.js @@ -6,6 +6,11 @@ module.exports = { let channelCache = require('./channel-cache'); const SLACK = require('./constants').SLACK; +const randomJS = require("random-js"); +const Random = randomJS.Random; +const random = new Random( randomJS.MersenneTwister19937.autoSeed() ); + +module.exports.random = random; function getCurrentProgramAndTimeElapsed(date, channel) { let channelStartTime = (new Date(channel.startTime)).getTime(); @@ -102,7 +107,7 @@ function createLineup(obj, channel, fillers, isFirst) { //it's boring and odd to tune into a channel and it's always //the start of a commercial. let more = Math.max(0, filler.duration - fillerstart - 15000 - SLACK); - fillerstart += Math.floor(more * Math.random() ); + fillerstart += random.integer(0, more); } lineup.push({ // just add the video, starting at 0, playing the entire duration type: 'commercial', @@ -152,12 +157,7 @@ function createLineup(obj, channel, fillers, isFirst) { } function weighedPick(a, total) { - if (a==total) { - return true; - } else { - let ran = Math.random(); - return ran * total < a; - } + return random.bool(a, total); } function pickRandomWithMaxDuration(channel, fillers, maxDuration) { @@ -293,3 +293,4 @@ function getWatermark( ffmpegSettings, channel, type) { } return result; } + diff --git a/src/plexTranscoder.js b/src/plexTranscoder.js index ee1a79a..11f3def 100644 --- a/src/plexTranscoder.js +++ b/src/plexTranscoder.js @@ -182,7 +182,7 @@ lang=en` try { return this.getVideoStats().videoDecision === "copy"; } catch (e) { - console.log("Error at decision:" + e); + console.log("Error at decision:", e); return false; } } @@ -199,7 +199,7 @@ lang=en` try { return this.getVideoStats().videoDecision === "copy" && this.getVideoStats().audioDecision === "copy"; } catch (e) { - console.log("Error at decision:" + e); + console.log("Error at decision:" , e); return false; } } @@ -245,7 +245,7 @@ lang=en` } }.bind(this) ) } catch (e) { - console.log("Error at decision:" + e); + console.log("Error at decision:" , e); } this.log("Current video stats:") @@ -289,7 +289,11 @@ lang=en` } - async getDecision(directPlay) { + async getDecisionUnmanaged(directPlay) { + if (this.settings.streamPath === 'direct') { + console.log("Skip get transcode decision because direct path is enabled"); + return; + } let res = await axios.get(`${this.server.uri}/video/:/transcode/universal/decision?${this.transcodingArgs}`, { headers: { Accept: 'application/json' } }) @@ -307,6 +311,14 @@ lang=en` } } + async getDecision(directPlay) { + try { + await this.getDecisionUnmanaged(directPlay); + } catch (err) { + console.error(err); + } + } + getStatusUrl() { let profileName=`Generic`; diff --git a/src/services/time-slots-service.js b/src/services/time-slots-service.js index d50ad78..9868ea2 100644 --- a/src/services/time-slots-service.js +++ b/src/services/time-slots-service.js @@ -1,10 +1,13 @@ const constants = require("../constants"); +const random = require('../helperFuncs').random; + const MINUTE = 60*1000; const DAY = 24*60*MINUTE; const LIMIT = 40000; + //This is a triplicate code, but maybe it doesn't have to be? function getShow(program) { //used for equalize and frequency tweak @@ -39,7 +42,7 @@ function shuffle(array, lo, hi ) { } let currentIndex = hi, temporaryValue, randomIndex while (lo !== currentIndex) { - randomIndex = lo + Math.floor(Math.random() * (currentIndex -lo) ); + randomIndex = random.integer(lo, currentIndex-1); currentIndex -= 1 temporaryValue = array[currentIndex] array[currentIndex] = array[randomIndex] @@ -208,6 +211,13 @@ module.exports = async( programs, schedule ) => { if (typeof(schedule.maxDays) == 'undefined') { return { userError: "schedule.maxDays must be defined." }; } + if (typeof(schedule.flexPreference) === 'undefined') { + schedule.flexPreference = "distribute"; + } + if (schedule.flexPreference !== "distribute" && schedule.flexPreference !== "end") { + return { userError: `Invalid schedule.flexPreference value: "${schedule.flexPreference}"` }; + } + let flexBetween = ( schedule.flexPreference !== "end" ); // throttle so that the stream is not affected negatively let steps = 0; @@ -222,6 +232,8 @@ module.exports = async( programs, schedule ) => { 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, @@ -255,6 +267,21 @@ module.exports = async( programs, schedule ) => { } } + function makePadded(item) { + let x = item.duration; + let m = x % schedule.pad; + let f = 0; + if ( (m > constants.SLACK) && (schedule.pad - m > constants.SLACK) ) { + f = schedule.pad - 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]; @@ -282,7 +309,7 @@ module.exports = async( programs, schedule ) => { let t0 = d.getTime(); let p = []; let t = t0; - let previous = null; + let wantedFinish = t % DAY; let hardLimit = t0 + schedule.maxDays * DAY; let pushFlex = (d) => { @@ -299,11 +326,19 @@ module.exports = async( programs, schedule ) => { } } - for (let i = 0; i < LIMIT; i++) { + 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 dayTime = t % DAY; let slot = null; let remaining = null; + let late = null; for (let i = 0; i < s.length; i++) { let endTime; if (i == s.length - 1) { @@ -315,62 +350,109 @@ module.exports = async( programs, schedule ) => { if ((s[i].time <= dayTime) && (dayTime < endTime)) { slot = s[i]; remaining = endTime - dayTime; + late = dayTime - s[i].time; break; } if ((s[i].time <= dayTime + DAY) && (dayTime + DAY < endTime)) { slot = s[i]; dayTime += DAY; remaining = endTime - dayTime; + late = dayTime + DAY - s[i].time; break; } } if (slot == null) { throw Error("Unexpected. Unable to find slot for time of day " + t + " " + dayTime); } - - let first = (previous !== slot.showId); - let skip = false; //skips to the next one - if (first) { - //check if it's too late - let d = dayTime - slot.time; - if (d >= schedule.lateness + constants.SLACK) { - skip = true; - } - } let item = getNextForSlot(slot, remaining); - if ( (item.duration >= remaining + constants.SLACK) && !first) { - skip = true; - } - if (t + item.duration - constants.SLACK >= hardLimit) { - pushFlex( hardLimit - t ); - break; - } - if (item.isOffline && item.type != 'redirect') { - //it's the same, really - skip = true; - } - if (skip) { - pushFlex(remaining); - } else { - previous = slot.showId; - let clone = JSON.parse( JSON.stringify(item) ); - clone.$index = p.length; - p.push( clone ); - t += clone.duration; - - advanceSlot(slot); - } - let nt = t; - let m = t % schedule.pad; - if (m != 0) { - nt = t - m + schedule.pad; - let remaining = nt - t; - if (remaining >= constants.SLACK) { - pushFlex(remaining); + if (late >= schedule.lateness + constants.SLACK ) { + //it's late. + item = { + isOffline : true, + duration: remaining, } } + + if (item.isOffline) { + //flex or redirect. We can just use the whole duration + p.push(item); + t += remaining; + continue; + } + if (item.duration > remaining) { + // Slide + p.push(item); + t += item.duration; + 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 rem = Math.max(0, remaining - total); + + if (flexBetween) { + 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 { + //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; + pushFlex( pads[i].pad ); + } } + while ( (t > hardLimit) || (p.length >= LIMIT) ) { + t -= p.pop().duration; + } + let m = t % DAY; + let rem = 0; + if (m > wantedFinish) { + rem = DAY + wantedFinish - m; + } else if (m < wantedFinish) { + rem = wantedFinish - m; + } + if (rem > constants.SLACK) { + pushFlex(rem); + } + return { programs: p, diff --git a/src/xmltv.js b/src/xmltv.js index d1596be..6d738b2 100644 --- a/src/xmltv.js +++ b/src/xmltv.js @@ -1,11 +1,29 @@ const XMLWriter = require('xml-writer') const fs = require('fs') -const helperFuncs = require('./helperFuncs') -const constants = require('./constants') -module.exports = { WriteXMLTV: WriteXMLTV } +module.exports = { WriteXMLTV: WriteXMLTV, shutdown: shutdown } -function WriteXMLTV(json, xmlSettings, throttle ) { +let isShutdown = false; +let isWorking = false; + +async function WriteXMLTV(json, xmlSettings, throttle ) { + if (isShutdown) { + return; + } + if (isWorking) { + console.log("Concurrent xmltv write attempt detected, skipping"); + return; + } + isWorking = true; + try { + await writePromise(json, xmlSettings, throttle); + } catch (err) { + console.error("Error writing xmltv", err); + } + isWorking = false; +} + +function writePromise(json, xmlSettings, throttle) { return new Promise((resolve, reject) => { let ws = fs.createWriteStream(xmlSettings.file) let xw = new XMLWriter(true, (str, enc) => ws.write(str, enc)) @@ -59,7 +77,9 @@ function _writeChannels(xw, channels) { async function _writePrograms(xw, channel, programs, throttle) { for (let i = 0; i < programs.length; i++) { - await throttle(); + if (! isShutdown) { + await throttle(); + } await _writeProgramme(channel, programs[i], xw); } } @@ -117,8 +137,25 @@ async function _writeProgramme(channel, program, xw) { function _createXMLTVDate(d) { return d.substring(0,19).replace(/[-T:]/g,"") + " +0000"; } -function _throttle() { +function wait(x) { return new Promise((resolve) => { - setTimeout(resolve, 0); + setTimeout(resolve, x); }); } + +async function shutdown() { + isShutdown = true; + console.log("Shutting down xmltv writer."); + if (isWorking) { + let s = "Wait for xmltv writer..."; + while (isWorking) { + console.log(s); + await wait(100); + s = "Still waiting for xmltv writer..."; + } + console.log("Write finished."); + } else { + console.log("xmltv writer had no pending jobs."); + } +} + diff --git a/web/directives/channel-config.js b/web/directives/channel-config.js index a7b9cbf..0cb41b8 100644 --- a/web/directives/channel-config.js +++ b/web/directives/channel-config.js @@ -17,6 +17,24 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { scope.maxSize = 50000; + scope.programming = { + maxHeight: 30, + step : 1, + } + + + try { + let h = parseFloat( localStorage.getItem("channel-programming-list-height" ) ); + if (isNaN(h)) { + h = 30; + } + h = Math.min(64, Math.max(1, h)); + console.log("loaded=" + h); + scope.programming.maxHeight = h; + } catch (e) { + console.error(e); + } + scope.blockCount = 1; scope.showShuffleOptions = (localStorage.getItem("channel-tools") === "on"); scope.reverseTools = (localStorage.getItem("channel-tools-position") === "left"); @@ -220,18 +238,23 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { scope._selectedProgram = null updateChannelDuration() } - scope.dropFunction = (dropIndex, index, program) => { - if (scope.channel.programs[index].start == program.start) { - return false; + scope.dropFunction = (dropIndex, program) => { + let y = program.$index; + let z = dropIndex + scope.currentStartIndex - 1; + scope.channel.programs.splice(y, 1); + if (z >= y) { + z--; } - - setTimeout( () => { - scope.channel.programs.splice(dropIndex + index, 0, program); - updateChannelDuration() - scope.$apply(); - }, 1); - return true; + scope.channel.programs.splice(z, 0, program ); + updateChannelDuration(); + $timeout(); + return false; } + scope.setUpWatcher = function setupWatchers() { + this.$watch('vsRepeat.startIndex', function(val) { + scope.currentStartIndex = val; + }); + }; scope.finishedOfflineEdit = (program) => { let editedProgram = scope.channel.programs[scope.selectedProgram]; @@ -380,6 +403,7 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { newProgs.push(tmpProgs[keys[i]]) } scope.channel.programs = newProgs + updateChannelDuration(); //oops someone forgot to add this } scope.removeOffline = () => { let tmpProgs = [] @@ -1400,6 +1424,8 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { scope.minBreakSize = -1; scope.maxBreakSize = -1; let breakSizeOptions = [ + { id: 10, description: "10 seconds" }, + { id: 15, description: "15 seconds" }, { id: 30, description: "30 seconds" }, { id: 45, description: "45 seconds" }, { id: 60, description: "60 seconds" }, @@ -1408,8 +1434,9 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { { id: 180, description: "3 minutes" }, { id: 300, description: "5 minutes" }, { id: 450, description: "7.5 minutes" }, - { id: 600, description: "10 minutes" }, - { id: 1200, description: "20 minutes" }, + { id: 10*60, description: "10 minutes" }, + { id: 20*60, description: "20 minutes" }, + { id: 30*60, description: "30 minutes" }, ] scope.minBreakSizeOptions = [ { id: -1, description: "Min Duration" }, @@ -1535,6 +1562,28 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { return options; } + scope.programmingHeight = () => { + return scope.programming.maxHeight + "rem"; + } + let setProgrammingHeight = (h) => { + scope.programming.step++; + $timeout( () => { + scope.programming.step--; + }, 1000 ) + scope.programming.maxHeight = h; + localStorage.setItem("channel-programming-list-height", "" + h ); + }; + scope.programmingZoomIn = () => { + let h = scope.programming.maxHeight; + h = Math.min( Math.ceil(h + scope.programming.step ), 64); + setProgrammingHeight(h); + } + scope.programmingZoomOut = () => { + let h = scope.programming.maxHeight; + h = Math.max( Math.floor(h - scope.programming.step ), 2 ); + setProgrammingHeight(h); + } + scope.refreshFillerStuff = () => { if (typeof(scope.channel.fillerCollections) === 'undefined') { return; diff --git a/web/directives/filler-config.js b/web/directives/filler-config.js index ce95311..f5c89f5 100644 --- a/web/directives/filler-config.js +++ b/web/directives/filler-config.js @@ -14,6 +14,40 @@ module.exports = function ($timeout) { scope.visible = false; scope.error = undefined; + function refreshContentIndexes() { + for (let i = 0; i < scope.content.length; i++) { + scope.content[i].$index = i; + } + } + + scope.contentSplice = (a,b) => { + scope.content.splice(a,b) + refreshContentIndexes(); + } + + scope.dropFunction = (dropIndex, program) => { + let y = program.$index; + let z = dropIndex + scope.currentStartIndex - 1; + scope.content.splice(y, 1); + if (z >= y) { + z--; + } + scope.content.splice(z, 0, program ); + $timeout(); + return false; + } + scope.setUpWatcher = function setupWatchers() { + this.$watch('vsRepeat.startIndex', function(val) { + scope.currentStartIndex = val; + }); + }; + + scope.movedFunction = (index) => { + console.log("movedFunction(" + index + ")"); + } + + + scope.linker( (filler) => { if ( typeof(filler) === 'undefined') { scope.name = ""; @@ -26,6 +60,7 @@ module.exports = function ($timeout) { scope.id = filler.id; scope.title = "Edit Filler List"; } + refreshContentIndexes(); scope.visible = true; } ); @@ -49,7 +84,10 @@ module.exports = function ($timeout) { scope.visible = false; scope.onDone( { name: scope.name, - content: scope.content, + content: scope.content.map( (c) => { + delete c.$index + return c; + } ), id: scope.id, } ); } @@ -58,9 +96,11 @@ module.exports = function ($timeout) { } scope.sortFillers = () => { scope.content.sort( (a,b) => { return a.duration - b.duration } ); + refreshContentIndexes(); } scope.fillerRemoveAllFiller = () => { scope.content = []; + refreshContentIndexes(); } scope.fillerRemoveDuplicates = () => { function getKey(p) { @@ -77,12 +117,14 @@ module.exports = function ($timeout) { } } scope.content = newFiller; + refreshContentIndexes(); } scope.importPrograms = (selectedPrograms) => { for (let i = 0, l = selectedPrograms.length; i < l; i++) { selectedPrograms[i].commercials = [] } scope.content = scope.content.concat(selectedPrograms); + refreshContentIndexes(); scope.showPlexLibrary = false; } diff --git a/web/directives/time-slots-schedule-editor.js b/web/directives/time-slots-schedule-editor.js index 6a11334..9abe540 100644 --- a/web/directives/time-slots-schedule-editor.js +++ b/web/directives/time-slots-schedule-editor.js @@ -26,6 +26,7 @@ module.exports = function ($timeout, dizquetv) { scope.schedule = { lateness : 0, maxDays: 365, + flexPreference : "distribute", slots : [], pad: 1, fake: { time: -1 }, @@ -52,6 +53,9 @@ module.exports = function ($timeout, dizquetv) { slots[i].order = "shuffle"; } } + if (typeof(scope.schedule.flexPreference) === 'undefined') { + scope.schedule.flexPreference = "distribute"; + } scope.schedule.fake = { time: -1, } @@ -71,13 +75,23 @@ module.exports = function ($timeout, dizquetv) { { id: 10*60*1000 , description: "10 minutes" }, { id: 15*60*1000 , description: "15 minutes" }, { id: 1*60*60*1000 , description: "1 hour" }, + { id: 2*60*60*1000 , description: "2 hours" }, + { id: 3*60*60*1000 , description: "3 hours" }, + { id: 4*60*60*1000 , description: "4 hours" }, + { id: 8*60*60*1000 , description: "8 hours" }, + { id: 24*60*60*1000 , description: "I don't care about lateness" }, ]; + scope.flexOptions = [ + { id: "distribute", description: "Between videos" }, + { id: "end", description: "End of the slot" }, + ] scope.fakeTimeOptions = JSON.parse( JSON.stringify( scope.timeOptions ) ); scope.fakeTimeOptions.push( {id: -1, description: "Add slot"} ); scope.padOptions = [ {id: 1, description: "Do not pad" }, {id: 5*60*1000, 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" }, @@ -137,6 +151,7 @@ module.exports = function ($timeout, dizquetv) { } ); scope.finished = async (cancel) => { + scope.error = null; if (!cancel) { try { scope.loading = true; diff --git a/web/public/index.html b/web/public/index.html index c701ec2..a495695 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -25,7 +25,7 @@ - + diff --git a/web/public/style.css b/web/public/style.css index f1aa30b..12e6445 100644 --- a/web/public/style.css +++ b/web/public/style.css @@ -288,7 +288,6 @@ div.programming-panes div.reverse { flex-direction: row-reverse; } div.programming-panes div.programming-pane { - max-height: 30rem; overflow-y: auto; padding-top: 0; padding-bottom: 0; diff --git a/web/public/templates/channel-config.html b/web/public/templates/channel-config.html index 108325e..c605114 100644 --- a/web/public/templates/channel-config.html +++ b/web/public/templates/channel-config.html @@ -87,6 +87,25 @@ +
+ +
+ +
+ +
+ +
-