diff --git a/README.md b/README.md index b598130..5572081 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# dizqueTV 0.0.67 +# dizqueTV 0.0.68-prerelease    Create live TV channel streams from media on your Plex servers. diff --git a/index.js b/index.js index 241f5e3..1fe19e6 100644 --- a/index.js +++ b/index.js @@ -67,19 +67,29 @@ let xmltvInterval = { interval: null, lastRefresh: null, updateXML: async () => { - let channels = []; - try { + let getChannelsCached = async() => { let channelNumbers = await channelDB.getAllChannelNumbers(); - channels = await Promise.all( channelNumbers.map( async (x) => { - return await channelCache.getChannelConfig(channelDB, x); + return await Promise.all( channelNumbers.map( async (x) => { + return (await channelCache.getChannelConfig(channelDB, x))[0]; }) ); + } + + let channels = []; + + try { + channels = await getChannelsCached(); let xmltvSettings = db['xmltv-settings'].find()[0]; - await guideService.refresh( await channelDB.getAllChannels(), xmltvSettings.cache*60*60*1000 ); + let t = guideService.prepareRefresh(channels, xmltvSettings.cache*60*60*1000); + channels = null; + + await guideService.refresh(t); xmltvInterval.lastRefresh = new Date() console.log('XMLTV Updated at ', xmltvInterval.lastRefresh.toLocaleString()); } catch (err) { console.error("Unable to update TV guide?", err); + return; } + channels = await getChannelsCached(); let plexServers = db['plex-servers'].find() for (let i = 0, l = plexServers.length; i < l; i++) { // Foreach plex server diff --git a/package.json b/package.json index 4372b69..22d7a59 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dependencies": { "angular": "^1.7.9", "angular-router-browserify": "0.0.2", + "angular-vs-repeat": "2.0.13", "axios": "^0.19.2", "body-parser": "^1.19.0", "diskdb": "^0.1.17", diff --git a/src/constants.js b/src/constants.js index ffc65cc..7cbdb4f 100644 --- a/src/constants.js +++ b/src/constants.js @@ -4,5 +4,5 @@ module.exports = { STEALTH_DURATION: 5 * 60* 1000, TVGUIDE_MAXIMUM_FLEX_DURATION : 6 * 60 * 60 * 1000, - VERSION_NAME: "0.0.67" + VERSION_NAME: "0.0.68-prerelease" } diff --git a/src/ffmpeg.js b/src/ffmpeg.js index a218943..801b9ba 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -30,6 +30,7 @@ class FFMPEG extends events.EventEmitter { this.audioChannelsSampleRate = this.opts.normalizeAudio; this.ensureResolution = this.opts.normalizeResolution; this.volumePercent = this.opts.audioVolumePercent; + this.hasBeenKilled = false; } async spawnConcat(streamUrl) { return await this.spawn(streamUrl, undefined, undefined, undefined, true, false, undefined, true) @@ -404,24 +405,37 @@ class FFMPEG extends events.EventEmitter { let doLogs = this.opts.logFfmpeg && !isConcatPlaylist; this.ffmpeg = spawn(this.ffmpegPath, ffmpegArgs, { stdio: ['ignore', 'pipe', (doLogs?process.stderr:"ignore") ] } ); - this.ffmpeg.on('close', (code) => { + let ffmpegName = (isConcatPlaylist ? "Concat FFMPEG": "Stream FFMPEG"); + + this.ffmpeg.on('exit', (code, signal) => { if (code === null) { + console.log( `${ffmpegName} exited due to signal: ${signal}` ); this.emit('close', code) } else if (code === 0) { + console.log( `${ffmpegName} exited normally.` ); this.emit('end') } else if (code === 255) { + if (this.hasBeenKilled) { + console.log( `${ffmpegName} finished with code 255.` ); + this.emit('close', code) + return; + } if (! this.sentData) { this.emit('error', { code: code, cmd: `${this.opts.ffmpegPath} ${ffmpegArgs.join(' ')}` }) } + console.log( `${ffmpegName} exited with code 255.` ); this.emit('close', code) } else { + console.log( `${ffmpegName} exited with code ${code}.` ); this.emit('error', { code: code, cmd: `${this.opts.ffmpegPath} ${ffmpegArgs.join(' ')}` }) } - }) + }); + return this.ffmpeg.stdout; } kill() { if (typeof this.ffmpeg != "undefined") { + this.hasBeenKilled = true; this.ffmpeg.kill() } } diff --git a/src/plexTranscoder.js b/src/plexTranscoder.js index a88f6d7..47caba5 100644 --- a/src/plexTranscoder.js +++ b/src/plexTranscoder.js @@ -42,9 +42,11 @@ class PlexTranscoder { this.log(` deinterlace: ${deinterlace}`) this.log(` streamPath: ${this.settings.streamPath}`) - - if (this.settings.streamPath === 'direct' || this.settings.forceDirectPlay) { + if (this.settings.enableSubtitles) { + console.log("Direct play is forced, so subtitles are forcibly disabled."); + this.settings.enableSubtitles = false; + } stream = {directPlay: true} } else { try { diff --git a/src/tv-guide-service.js b/src/tv-guide-service.js index 1d89e36..841f6b6 100644 --- a/src/tv-guide-service.js +++ b/src/tv-guide-service.js @@ -28,12 +28,16 @@ class TVGuideService return this.cached; } - async refresh(inputChannels, limit) { + prepareRefresh(inputChannels, limit) { let t = (new Date()).getTime(); this.updateTime = t; this.updateLimit = t + limit; - let channels = inputChannels.filter( ch => (ch.stealth !== true) ); + let channels = inputChannels; this.updateChannels = channels; + return t; + } + + async refresh(t) { while( this.lastUpdate < t) { if (this.currentUpdate == -1) { this.currentUpdate = this.updateTime; @@ -47,6 +51,9 @@ class TVGuideService } async makeAccumulated(channel) { + if (typeof(channel.programs) === 'undefined') { + throw Error( JSON.stringify(channel).slice(0,200) ); + } let n = channel.programs.length; let arr = new Array( channel.programs.length + 1); arr[0] = 0; @@ -296,8 +303,10 @@ class TVGuideService } } else { for (let i = 0; i < channels.length; i++) { + if(! channels[i].stealth) { let programs = await this.getChannelPrograms(t0, t1, channels[i] ); result[ channels[i].number ] = programs; + } } } return result; diff --git a/web/app.js b/web/app.js index a81b421..bc4dfeb 100644 --- a/web/app.js +++ b/web/app.js @@ -3,8 +3,9 @@ require('angular-router-browserify')(angular) require('./ext/lazyload')(angular) require('./ext/dragdrop') require('./ext/angularjs-scroll-glue') +require('angular-vs-repeat'); -var app = angular.module('myApp', ['ngRoute', 'angularLazyImg', 'dndLists', 'luegg.directives']) +var app = angular.module('myApp', ['ngRoute', 'vs-repeat', 'angularLazyImg', 'dndLists', 'luegg.directives']) app.service('plex', require('./services/plex')) app.service('dizquetv', require('./services/dizquetv')) diff --git a/web/directives/channel-config.js b/web/directives/channel-config.js index a21fde0..7a1988e 100644 --- a/web/directives/channel-config.js +++ b/web/directives/channel-config.js @@ -16,6 +16,9 @@ module.exports = function ($timeout, $location, dizquetv) { scope._frequencyMessage = ""; scope.millisecondsOffset = 0; scope.minProgramIndex = 0; + scope.episodeMemory = { + saved : false, + }; if (typeof scope.channel === 'undefined' || scope.channel == null) { scope.channel = {} scope.channel.programs = [] @@ -113,6 +116,7 @@ module.exports = function ($timeout, $location, dizquetv) { setTimeout( () => { scope.channel.programs.splice(dropIndex + index, 0, program); updateChannelDuration() + scope.$apply(); }, 1); return true; } @@ -328,6 +332,22 @@ module.exports = function ($timeout, $location, dizquetv) { } } + let interpolate = ( () => { + let h = 60*60*1000; + let ix = [0, 1*h, 2*h, 4*h, 8*h, 24*h]; + let iy = [0, 1.0, 1.25, 1.5, 1.75, 2.0]; + let n = ix.length; + + return (x) => { + for (let i = 0; i < n-1; i++) { + if( (ix[i] <= x) && ( (x < ix[i+1]) || i==n-2 ) ) { + return iy[i] + (iy[i+1] - iy[i]) * ( (x - ix[i]) / (ix[i+1] - ix[i]) ); + } + } + } + + } )(); + scope.programSquareStyle = (program) => { let background =""; if ( (program.isOffline) && (program.type !== 'redirect') ) { @@ -371,26 +391,26 @@ module.exports = function ($timeout, $location, dizquetv) { } let rgb1 = "rgb("+ r + "," + g + "," + b +")"; let rgb2 = "rgb("+ r2 + "," + g2 + "," + b2 +")" + angle += 90; background = "repeating-linear-gradient( " + angle + "deg, " + rgb1 + ", " + rgb1 + " " + w + "px, " + rgb2 + " " + w + "px, " + rgb2 + " " + (w*2) + "px)"; } - let ems = Math.pow( Math.min(24*60*60*1000, program.duration), 0.7 ); - ems = ems / Math.pow(5*60*1000., 0.7); - ems = Math.max( 0.25 , ems); - let top = Math.max(0.0, (1.75 - ems) / 2.0) ; - if (top == 0.0) { - top = "1px"; - } else { - top = top + "em"; - } + let f = interpolate; + let w = 5.0; + let t = 4*60*60*1000; + //let d = Math.log( Math.min(t, program.duration) ) / Math.log(2); + //let a = (d * Math.log(2) ) / Math.log(t); + let a = ( f(program.duration) *w) / f(t); + a = Math.min( w, Math.max(0.3, a) ); + b = w - a + 0.01; return { - 'width': '0.5em', - 'height': ems + 'em', - 'margin-right': '0.50em', + 'width': `${a}%`, + 'height': '1.3em', + 'margin-right': `${b}%`, 'background': background, 'border': '1px solid black', - 'margin-top': top, + 'margin-top': "0.01em", 'margin-bottom': '1px', }; } @@ -491,6 +511,17 @@ module.exports = function ($timeout, $location, dizquetv) { for (let i = 0, l = scope.channel.programs.length; i < l; i++) { let p = pos(t); if ( (p != 0) && (p + scope.channel.programs[i].duration > b) ) { + if (b - 30000 > p) { + let d = b- p; + t += d; + p = pos(t); + progs.push( + { + duration: d, + isOffline: true, + } + ) + } //time to pad let d = m - p; progs.push( @@ -508,19 +539,126 @@ module.exports = function ($timeout, $location, dizquetv) { t += scope.channel.programs[i].duration; } if (pos(t) != 0) { + if (b > pos(t)) { + let d = b - pos(t) % m; + t += d; + progs.push( + { + duration: d, + isOffline: true, + } + ) + } let d = m - pos(t); progs.push( { duration: d, isOffline: true, - type: (typeof(ch) === 'undefined') ? undefined: "redirect", channel: ch, + type: (typeof(ch) === 'undefined') ? undefined: "redirect", } ) } scope.channel.programs = progs; updateChannelDuration(); } + scope.savePositions = () => { + scope.episodeMemory = { + saved : false, + }; + let array = scope.channel.programs; + for (let i = 0; i < array.length; i++) { + if (array[i].type === 'episode' && array[i].season != 0) { + let key = array[i].showTitle; + if (typeof(scope.episodeMemory[key]) === 'undefined') { + scope.episodeMemory[key] = { + season: array[i].season, + episode: array[i].episode, + } + } + } + } + scope.episodeMemory.saved = true; + } + scope.recoverPositions = () => { + //this is basically the code for cyclic shuffle + let array = scope.channel.programs; + let shows = {}; + let next = {}; + let counts = {}; + // some precalculation, useful to stop the shuffle from being quadratic... + for (let i = 0; i < array.length; i++) { + let vid = array[i]; + if (vid.type === 'episode' && vid.season != 0) { + let countKey = { + title: vid.showTitle, + s: vid.season, + e: vid.episode, + } + let key = JSON.stringify(countKey); + let c = ( (typeof(counts[key]) === 'undefined') ? 0 : counts[key] ); + counts[key] = c + 1; + let showEntry = { + c: c, + it: vid + } + if ( typeof(shows[vid.showTitle]) === 'undefined') { + shows[vid.showTitle] = []; + } + shows[vid.showTitle].push(showEntry); + } + } + //this is O(|N| log|M|) where |N| is the total number of TV + // episodes and |M| is the maximum number of episodes + // in a single show. I am pretty sure this is a lower bound + // on the time complexity that's possible here. + Object.keys(shows).forEach(function(key,index) { + shows[key].sort( (a,b) => { + if (a.c == b.c) { + if (a.it.season == b.it.season) { + if (a.it.episode == b.it.episode) { + return 0; + } else { + return (a.it.episode < b.it.episode)?-1: 1; + } + } else { + return (a.it.season < b.it.season)?-1: 1; + } + } else { + return (a.c < b.c)? -1: 1; + } + }); + next[key] = 0; + if (typeof(scope.episodeMemory[key]) !== 'undefined') { + for (let i = 0; i < shows[key].length; i++) { + if ( + (shows[key][i].it.season === scope.episodeMemory[key].season) + &&(shows[key][i].it.episode === scope.episodeMemory[key].episode) + ) { + next[key] = i; + break; + } + } + } + }); + for (let i = 0; i < array.length; i++) { + if (array[i].type === 'episode' && array[i].season != 0) { + let title = array[i].showTitle; + var sequence = shows[title]; + let j = next[title]; + array[i] = sequence[j].it; + + next[title] = (j + 1) % sequence.length; + } + } + scope.channel.programs = array; + updateChannelDuration(); + + } + scope.cannotRecoverPositions = () => { + return scope.episodeMemory.saved !== true; + } + scope.addBreaks = (afterMinutes, minDurationSeconds, maxDurationSeconds) => { let after = afterMinutes * 60 * 1000 + 5000; //allow some seconds of excess let minDur = minDurationSeconds; @@ -869,6 +1007,7 @@ module.exports = function ($timeout, $location, dizquetv) { scope.hasFlex = false; for (let i = 0, l = scope.channel.programs.length; i < l; i++) { scope.channel.programs[i].start = new Date(scope.channel.startTime.valueOf() + scope.channel.duration) + scope.channel.programs[i].$index = i; scope.channel.duration += scope.channel.programs[i].duration scope.channel.programs[i].stop = new Date(scope.channel.startTime.valueOf() + scope.channel.duration) if (scope.channel.programs[i].isOffline) { @@ -906,6 +1045,9 @@ module.exports = function ($timeout, $location, dizquetv) { scope.error.programs = "No programs have been selected. Select at least one program." else { channel.startTime.setMilliseconds( scope.millisecondsOffset); + for (let i = 0; i < scope.channel.programs.length; i++) { + delete scope.channel.programs[i].$index; + } scope.onDone(JSON.parse(angular.toJson(channel))) } $timeout(() => { scope.error = {} }, 3500) diff --git a/web/directives/plex-settings.js b/web/directives/plex-settings.js index 3b7abca..353962c 100644 --- a/web/directives/plex-settings.js +++ b/web/directives/plex-settings.js @@ -145,6 +145,9 @@ module.exports = function (plex, dizquetv, $timeout) { return r; } + scope.shouldDisableSubtitles = () => { + return scope.settings.forceDirectPlay || (scope.settings.streamPath === "direct" ); + } scope.addPlexServer = async () => { scope.isProcessing = true; diff --git a/web/public/index.html b/web/public/index.html index 188fe0b..5037cb9 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -4,7 +4,7 @@
Sorts everything by its release date. This will only work correctly if the release dates in Plex are correct. In case any item does not have a release date specified, it will be moved to the bottom.
+Sorts everything by its release date. This will only work correctly if the release dates in Plex are correct. In case any item does not have a release date specified, it will be moved to the bottom.
+Attempts to make the total amount of time each TV show appears in the programming as balanced as possible. This works by adding multiple copies of TV shows that have too little total time and by possibly removing duplicated episodes from TV shows that have too much total time. Note that in many situations it would be impossible to achieve perfect balance because channel duration is not infinite. Movies/Clips are treated as a single TV show. Note that this will most likely result in a larger channel and that having large channels makes some UI operations slower.
@@ -160,6 +163,10 @@Divides the programming in blocks of 6, 8 or 12 hours then repeats each of the blocks the specified number of times. For example, you can make a channel that plays exactly the same channels in the morning and in the afternoon.
+The "Save" button saves the current episodes that are next to be played for each tv show. Then whenever you click the "Recover Episode Popsitions" button, episodes will be rearranged cyclically and they will start with the saved positions. So you can maintain episode sequences even after modifying the channel. If there are any new TV shows, they will start at their current positions. Movies and specials won't change positions. +
+Adds a channel redirect. During this period of time, the channel will redirect to another channel.
@@ -189,7 +196,7 @@