diff --git a/Dockerfile b/Dockerfile index 6e3f458..cb88826 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,42 +1,45 @@ FROM node:12.18-alpine3.12 # Should be ffmpeg v4.2.3 -RUN apk add --no-cache ffmpeg && ffmpeg -version -# Remove the previous line and uncommenting the following lines will allow the -# ffmpeg version to support draw_text filter, but it makes the docker build take -# a long time and it's only used for minor features at the moment. -#RUN apk add --update \ -# curl yasm build-base gcc zlib-dev libc-dev openssl-dev yasm-dev lame-dev libogg-dev x264-dev libvpx-dev libvorbis-dev x265-dev freetype-dev libass-dev libwebp-dev rtmpdump-dev libtheora-dev opus-dev && \ -# DIR=$(mktemp -d) && cd ${DIR} && \ -# curl -s http://ffmpeg.org/releases/ffmpeg-4.2.3.tar.gz | tar zxvf - -C . && \ -# cd ffmpeg-4.2.3 && \ -# ./configure \ -# --enable-version3 \ -# --enable-gpl \ -# --enable-nonfree \ -# --enable-small \ -# --enable-libmp3lame \ -# --enable-libx264 \ -# --enable-libx265 \ -# --enable-libvpx \ -# --enable-libtheora \ -# --enable-libvorbis \ -# --enable-libopus \ -# --enable-libass \ -# --enable-libwebp \ -# --enable-librtmp \ -# --enable-postproc \ -# --enable-avresample \ -# --enable-libfreetype \ -# --enable-openssl \ -# --enable-filter=drawtext \ -# --disable-debug && \ -# make && \ -# make install && \ -# make distclean && \ -# rm -rf ${DIR} && \ -# mv /usr/local/bin/ffmpeg /usr/bin/ffmpeg && \ -# apk del build-base curl tar bzip2 x264 openssl nasm openssl xz gnupg && rm -rf /v +ARG LIBDAV1D_VERSION=0.7.1 +ARG LIBDAV1D_URL="https://code.videolan.org/videolan/dav1d/-/archive/$LIBDAV1D_VERSION/dav1d-$LIBDAV1D_VERSION.tar.gz" + +RUN apk add --update \ + curl nasm yasm build-base gcc zlib-dev libc-dev openssl-dev yasm-dev lame-dev libogg-dev x264-dev libvpx-dev libvorbis-dev x265-dev freetype-dev libass-dev libwebp-dev rtmpdump-dev libtheora-dev opus-dev meson ninja && \ + wget -O dav1d.tar.gz "$LIBDAV1D_URL" && \ + tar xfz dav1d.tar.gz && \ + cd dav1d-* && meson build --buildtype release -Ddefault_library=static && ninja -C build install && \ + DIR=$(mktemp -d) && cd ${DIR} && \ + curl -s http://ffmpeg.org/releases/ffmpeg-4.2.3.tar.gz | tar zxvf - -C . && \ + cd ffmpeg-4.2.3 && \ + ./configure \ + --enable-version3 \ + --enable-gpl \ + --enable-nonfree \ + --enable-small \ + --enable-libmp3lame \ + --enable-libx264 \ + --enable-libdav1d \ + --enable-libx265 \ + --enable-libvpx \ + --enable-libtheora \ + --enable-libvorbis \ + --enable-libopus \ + --enable-libass \ + --enable-libwebp \ + --enable-librtmp \ + --enable-postproc \ + --enable-avresample \ + --enable-libfreetype \ + --enable-openssl \ + --enable-filter=drawtext \ + --disable-debug && \ + make && \ + make install && \ + make distclean && \ + rm -rf ${DIR} && \ + mv /usr/local/bin/ffmpeg /usr/bin/ffmpeg && \ + apk del build-base curl tar bzip2 x264 openssl nasm openssl xz gnupg && rm -rf /v WORKDIR /home/node/app COPY package*.json ./ RUN npm install diff --git a/README.md b/README.md index 3393d83..5d45d89 100644 --- a/README.md +++ b/README.md @@ -20,18 +20,14 @@ EPG (Guide Information) data is stored to `.pseudotv/xmltv.xml` - Ability to auto update Plex DVR guide data and channel mappings - Auto update the xmltv.xml file at a set interval (in hours). You can also set the amount EPG cache (in hours). - Continuous playback support +- Ability to add breaks or padding between episodes and use Commercials, Trailers, Bumpers or other filler. - Commercial support. 5 commercial slots for a program (BEFORE, 1/4, 1/2, 3/4, AFTER). Place as many commercials as desired per slot to chain commercials. - Media track selection (video, audio, subtitle). (subtitles disabled by default) -- Subtitle support (some subtitle formats may cause a delay when starting an ffmpeg session) Supported subs below. - - Internal Subs - - ASS (slow) - - SRT (slow) - - PGS (fast) - - External Subs - - ASS (moderate) - - SRT (moderate) +- Subtitle support. - Ability to overlay channel icon over stream - Auto deinterlace any Plex media not marked `"scanType": "progressive"` +- Can be configured to completely force Direct play. +- Can normalize video formats to prevent stream breaking. ## Useful Tips/Info @@ -52,8 +48,11 @@ EPG (Guide Information) data is stored to `.pseudotv/xmltv.xml` - Plex Pass is required to unlock Plex Live TV/DVR feature - Only one EPG source can be used with Plex server. This may cause an issue if you are adding the pseudotv tuner to a Plex server with Live TV/DVR already enabled/configured. -- PseudoTV does not watch your Plex server for media updates/changes. You must manually remove and readd your programs for any changes to take effect. Same goes for Plex server changes (changing IP, port, etc).. all media will fail.. + * There are projects like xteve that allow you to unify multiple EPG sources into a single list which Plex can use. + +- PseudoTV does not watch your Plex server for media updates/changes. You must manually remove and readd your programs for any changes to take effect. Same goes for Plex server changes (changing IP, port, etc).. all media will fail.. +- Many IPTV players (including Plex) will break after switching episodes if video / audio format is too different between. PseudoTV can be configured to use ffmpeg transcoding to prevent htis, but that costs resources. ## Installation diff --git a/index.js b/index.js index 21efdc0..6a6f37f 100644 --- a/index.js +++ b/index.js @@ -12,7 +12,8 @@ const video = require('./src/video') const HDHR = require('./src/hdhr') const xmltv = require('./src/xmltv') -const Plex = require('./src/plex') +const Plex = require('./src/plex'); +const channelCache = require('./src/channel-cache'); console.log("PseudoTV Version: " + pseudotvVersion) @@ -41,6 +42,11 @@ let xmltvInterval = { lastRefresh: null, updateXML: () => { let channels = db['channels'].find() + channels.forEach( (channel) => { + // if we are going to go through the trouble of loading the whole channel db, we might + // as well take that opportunity to reduce stream loading times... + channelCache.saveChannelConfig( channel.number, channel ); + }); channels.sort((a, b) => { return a.number < b.number ? -1 : 1 }) let xmltvSettings = db['xmltv-settings'].find()[0] xmltv.WriteXMLTV(channels, xmltvSettings).then(async () => { // Update XML @@ -110,6 +116,10 @@ function initDB(db) { let data = fs.readFileSync(path.resolve(path.join(__dirname, 'resources/generic-error-screen.png'))) fs.writeFileSync(process.env.DATABASE + '/images/generic-error-screen.png', data) } + if (!fs.existsSync(process.env.DATABASE + '/images/generic-offline-screen.png')) { + let data = fs.readFileSync(path.resolve(path.join(__dirname, 'resources/generic-offline-screen.png'))) + fs.writeFileSync(process.env.DATABASE + '/images/generic-offline-screen.png', data) + } var ffmpegRepaired = defaultSettings.repairFFmpeg(ffmpegSettings); if (ffmpegRepaired.hasBeenRepaired) { diff --git a/resources/generic-offline-screen.png b/resources/generic-offline-screen.png new file mode 100644 index 0000000..593b226 Binary files /dev/null and b/resources/generic-offline-screen.png differ diff --git a/src/api.js b/src/api.js index 0b4b004..57c6ec8 100644 --- a/src/api.js +++ b/src/api.js @@ -2,6 +2,7 @@ const express = require('express') const fs = require('fs') const defaultSettings = require('./defaultSettings') +const channelCache = require('./channel-cache') module.exports = { router: api } function api(db, xmltvInterval) { @@ -30,7 +31,9 @@ function api(db, xmltvInterval) { res.send(channels) }) router.post('/api/channels', (req, res) => { + cleanUpChannels(req.body); db['channels'].save(req.body) + channelCache.clear(); let channels = db['channels'].find() channels.sort((a, b) => { return a.number < b.number ? -1 : 1 }) res.send(channels) @@ -38,7 +41,9 @@ function api(db, xmltvInterval) { }) router.put('/api/channels', (req, res) => { + cleanUpChannel(req.body); db['channels'].update({ _id: req.body._id }, req.body) + channelCache.clear(); let channels = db['channels'].find() channels.sort((a, b) => { return a.number < b.number ? -1 : 1 }) res.send(channels) @@ -46,6 +51,7 @@ function api(db, xmltvInterval) { }) router.delete('/api/channels', (req, res) => { db['channels'].remove({ _id: req.body._id }, false) + channelCache.clear(); let channels = db['channels'].find() channels.sort((a, b) => { return a.number < b.number ? -1 : 1 }) res.send(channels) @@ -90,7 +96,7 @@ function api(db, xmltvInterval) { transcodeMediaBufferSize: 20000, maxPlayableResolution: "1920x1080", maxTranscodeResolution: "1920x1080", - videoCodecs: 'h264,hevc,mpeg2video', + videoCodecs: 'h264,hevc,mpeg2video,av1', audioCodecs: 'ac3', maxAudioChannels: '2', audioBoost: '100', @@ -183,5 +189,25 @@ function api(db, xmltvInterval) { xmltvInterval.restartInterval() } + function cleanUpProgram(program) { + if ( typeof(program.server) !== 'undefined') { + program.server = { + uri: program.server.uri, + accessToken: program.server.accessToken, + } + } + delete program.streams; + } + + function cleanUpChannel(channel) { + channel.programs.forEach( cleanUpProgram ); + channel.fillerContent.forEach( cleanUpProgram ); + channel.fallback.forEach( cleanUpProgram ); + } + + function cleanUpChannels(channels) { + channels.forEach(cleanUpChannel); + } + return router } diff --git a/src/channel-cache.js b/src/channel-cache.js new file mode 100644 index 0000000..d6b03f0 --- /dev/null +++ b/src/channel-cache.js @@ -0,0 +1,106 @@ +const SLACK = require('./constants').SLACK; + +let cache = {}; +let programPlayTimeCache = {}; +let configCache = {}; + +function getChannelConfig(db, channelId) { + //with lazy-loading + + if ( typeof(configCache[channelId]) === 'undefined') { + let channel = db['channels'].find( { number: channelId } ) + configCache[channelId] = channel; + return channel; + } else { + return configCache[channelId]; + } +} + +function saveChannelConfig(number, channel ) { + configCache[number] = [channel]; +} + +function getCurrentLineupItem(channelId, t1) { + if (typeof(cache[channelId]) === 'undefined') { + return null; + } + let recorded = cache[channelId]; + let lineupItem = JSON.parse( JSON.stringify(recorded.lineupItem) ); + let diff = t1 - recorded.t0; + if (diff <= SLACK) { + //closed the stream and opened it again let's not lose seconds for + //no reason + return lineupItem; + } + + lineupItem.start += diff; + if (typeof(lineupItem.streamDuration)!=='undefined') { + lineupItem.streamDuration -= diff; + if (lineupItem.streamDuration < SLACK) { //let's not waste time playing some loose seconds + return null; + } + } + if(lineupItem.start + SLACK > lineupItem.actualDuration) { + return null; + } + return lineupItem; +} + +function getKey(channelId, program) { + let serverKey = "!unknown!"; + if (typeof(program.server) !== 'undefined') { + if (typeof(program.server.name) !== 'undefined') { + serverKey = "plex|" + program.server.name; + } + } + let programKey = "!unknownProgram!"; + if (typeof(program.key) !== 'undefined') { + programKey = program.key; + } + return channelId + "|" + serverKey + "|" + programKey; + +} + + +function recordProgramPlayTime(channelId, lineupItem, t0) { + let remaining; + if ( typeof(lineupItem.streamDuration) !== 'undefined') { + remaining = lineupItem.streamDuration; + } else { + remaining = lineupItem.actualDuration - lineupItem.start; + } + programPlayTimeCache[ getKey(channelId, lineupItem) ] = t0 + remaining; +} + +function getProgramLastPlayTime(channelId, program) { + let v = programPlayTimeCache[ getKey(channelId, program) ]; + if (typeof(v) === 'undefined') { + return 0; + } else { + return v; + } +} + +function recordPlayback(channelId, t0, lineupItem) { + recordProgramPlayTime(channelId, lineupItem, t0); + + cache[channelId] = { + t0: t0, + lineupItem: lineupItem, + } +} + +function clear() { + //it's not necessary to clear the playback cache and it may be undesirable + configCache = {}; + cache = {}; +} + +module.exports = { + getCurrentLineupItem: getCurrentLineupItem, + recordPlayback: recordPlayback, + clear: clear, + getProgramLastPlayTime: getProgramLastPlayTime, + getChannelConfig: getChannelConfig, + saveChannelConfig: saveChannelConfig, +} \ No newline at end of file diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..bfdf8ea --- /dev/null +++ b/src/constants.js @@ -0,0 +1,3 @@ +module.exports = { + SLACK: 9999, +} \ No newline at end of file diff --git a/src/ffmpeg.js b/src/ffmpeg.js index eee282f..f7ad6ff 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -39,24 +39,38 @@ class FFMPEG extends events.EventEmitter { async spawnStream(streamUrl, streamStats, startTime, duration, enableIcon, type) { this.spawn(streamUrl, streamStats, startTime, duration, true, enableIcon, type, false); } - async spawnError(title, subtitle, streamStats, enableIcon, type) { + async spawnError(title, subtitle, duration) { if (! this.opts.enableFFMPEGTranscoding || this.opts.errorScreen == 'kill') { console.log("error: " + title + " ; " + subtitle); this.emit('error', { code: -1, cmd: `error stream disabled. ${title} ${subtitles}`} ) return; } - // since this is from an error situation, streamStats may have issues. - if ( (streamStats == null) || (typeof(streamStats) === 'undefined') ) { - streamStats = {}; + if (typeof(duration) === 'undefined') { + //set a place-holder duration + console.log("No duration found for error stream, using placeholder"); + duration = MAXIMUM_ERROR_DURATION_MS ; } - streamStats.videoWidth = this.wantedW; - streamStats.videoHeight = this.wantedH; - if ( (typeof(streamStats.duration) === 'undefined') || isNaN(streamStats.duration) || (streamStats.duration > MAXIMUM_ERROR_DURATION_MS) ) { - // it's possible that whatever issue there was when attempting to download the video from plex - // could be temporary, so it'd be better to retry after a minute - streamStats.duration = MAXIMUM_ERROR_DURATION_MS; + duration = Math.min(MAXIMUM_ERROR_DURATION_MS, duration); + let streamStats = { + videoWidth : this.wantedW, + videoHeight : this.wantedH, + duration : duration, + }; + this.spawn({ errorTitle: title , subtitle: subtitle }, streamStats, undefined, `${streamStats.duration}ms`, true, false, 'error', false) + } + async spawnOffline(duration) { + if (! this.opts.enableFFMPEGTranscoding) { + console.log("The channel has an offline period scheduled for this time slot. FFMPEG transcoding is disabled, so it is not possible to render an offline screen. Ending the stream instead"); + this.emit('end', { code: -1, cmd: `error stream disabled. ${title} ${subtitles}`} ) + return; } - this.spawn({ errorTitle: title , subtitle: subtitle }, streamStats, undefined, `${streamStats.duration}ms`, true, enableIcon, type, false) + + let streamStats = { + videoWidth : this.wantedW, + videoHeight : this.wantedH, + duration : duration, + }; + this.spawn( {errorTitle: 'offline'}, streamStats, undefined, `${duration}ms`, true, false, 'offline', false); } async spawn(streamUrl, streamStats, startTime, duration, limitRead, enableIcon, type, isConcatPlaylist) { let ffmpegArgs = [ @@ -83,7 +97,7 @@ class FFMPEG extends events.EventEmitter { // When we have an individual stream, there is a pipeline of possible // filters to apply. // - var doOverlay = (enableIcon && type === 'program'); + var doOverlay = enableIcon; var iW = streamStats.videoWidth; var iH = streamStats.videoHeight; @@ -120,7 +134,13 @@ class FFMPEG extends events.EventEmitter { } ffmpegArgs.push("-r" , "24"); - if (this.opts.errorScreen == 'static') { + if ( streamUrl.errorTitle == 'offline' ) { + ffmpegArgs.push( + '-loop', '1', + '-i', `${this.channel.offlinePicture}`, + ); + videoComplex = `;[0:0]loop=loop=-1:size=1:start=0[looped];[looped]scale=${iW}:${iH}[videoy];[videoy]realtime[videox]`; + } else if (this.opts.errorScreen == 'static') { ffmpegArgs.push( '-f', 'lavfi', '-i', `nullsrc=s=64x36`); @@ -129,7 +149,6 @@ class FFMPEG extends events.EventEmitter { ffmpegArgs.push( '-f', 'lavfi', '-i', `testsrc=size=${iW}x${iH}`, - '-pix_fmt' , 'yuv420p' ); videoComplex = `;realtime[videox]`; } else if (this.opts.errorScreen == 'text') { @@ -153,18 +172,28 @@ class FFMPEG extends events.EventEmitter { ffmpegArgs.push( '-loop', '1', '-i', `${ERROR_PICTURE_PATH}`, - '-pix_fmt' , 'yuv420p' ); videoComplex = `;[0:0]scale=${iW}:${iH}[videoy];[videoy]realtime[videox]`; } let durstr = `duration=${streamStats.duration}ms`; - if (this.opts.errorAudio == 'whitenoise') { + //silent + audioComplex = `;aevalsrc=0:${durstr}[audioy]`; + if ( streamUrl.errorTitle == 'offline' ) { + if ( + (typeof(this.channel.offlineSoundtrack) !== 'undefined') + && (this.channel.offlineSoundtrack != '' ) + ) { + ffmpegArgs.push('-i', `${this.channel.offlineSoundtrack}`); + // I don't really understand why, but you need to use this + // 'size' in order to make the soundtrack actually loop + audioComplex = `;[1:a]aloop=loop=-1:size=2147483647[audioy]`; + } + } else if (this.opts.errorAudio == 'whitenoise') { audioComplex = `;aevalsrc=-2+0.1*random(0):${durstr}[audioy]`; } else if (this.opts.errorAudio == 'sine') { audioComplex = `;sine=f=440:${durstr}[audiox];[audiox]volume=-35dB[audioy]`; - } else { //silent - audioComplex = `;aevalsrc=0:${durstr}[audioy]`; } + ffmpegArgs.push('-pix_fmt' , 'yuv420p' ); audioComplex += ';[audioy]arealtime[audiox]'; currentVideo = "[videox]"; currentAudio = "[audiox]"; @@ -273,6 +302,7 @@ class FFMPEG extends events.EventEmitter { } ffmpegArgs.push( `-c:a`, (transcodeAudio ? this.opts.audioEncoder : 'copy'), + '-map_metadata', '-1', '-movflags', '+faststart', `-muxdelay`, `0`, `-muxpreload`, `0` @@ -280,7 +310,7 @@ class FFMPEG extends events.EventEmitter { } else { //Concat stream is simpler and should always copy the codec ffmpegArgs.push( - `-probesize`, `25000000`, + `-probesize`, `100000000`, `-i`, streamUrl, `-map`, `0:v`, `-map`, `0:${audioIndex}`, diff --git a/src/helperFuncs.js b/src/helperFuncs.js index c772a06..449c77d 100644 --- a/src/helperFuncs.js +++ b/src/helperFuncs.js @@ -1,9 +1,12 @@ module.exports = { getCurrentProgramAndTimeElapsed: getCurrentProgramAndTimeElapsed, createLineup: createLineup, - isChannelIconEnabled: isChannelIconEnabled + isChannelIconEnabled: isChannelIconEnabled, } +let channelCache = require('./channel-cache'); +const SLACK = require('./constants').SLACK; + function getCurrentProgramAndTimeElapsed(date, channel) { let channelStartTime = new Date(channel.startTime) if (channelStartTime > date) @@ -14,7 +17,11 @@ function getCurrentProgramAndTimeElapsed(date, channel) { let program = channel.programs[y] if (timeElapsed - program.duration < 0) { currentProgramIndex = y - break + if ( (program.duration > 2*SLACK) && (timeElapsed > program.duration - SLACK) ) { + timeElapsed = 0; + currentProgramIndex = (y + 1) % channel.programs.length; + } + break; } else { timeElapsed -= program.duration } @@ -26,16 +33,83 @@ function getCurrentProgramAndTimeElapsed(date, channel) { return { program: channel.programs[currentProgramIndex], timeElapsed: timeElapsed, programIndex: currentProgramIndex } } -function createLineup(obj) { +function createLineup(obj, channel, isFirst) { let timeElapsed = obj.timeElapsed // Start time of a file is never consistent unless 0. Run time of an episode can vary. // When within 30 seconds of start time, just make the time 0 to smooth things out // Helps prevents loosing first few seconds of an episode upon lineup change + let activeProgram = obj.program + + let lineup = [] + + if (activeProgram.isOffline === true) { + //offline case + let remaining = activeProgram.actualDuration - timeElapsed; + //look for a random filler to play + let filler = null; + let special = null; + if (typeof(channel.fillerContent) !== 'undefined') { + if ( (channel.offlineMode === 'clip') && (channel.fallback.length != 0) ) { + special = JSON.parse(JSON.stringify(channel.fallback[0])); + } + let randomResult = pickRandomWithMaxDuration(channel, channel.fillerContent, remaining + (isFirst? (24*60*60*1000) : 0) ); + filler = randomResult.filler; + if (filler == null && (typeof(randomResult.minimumWait) !== undefined) && (remaining > randomResult.minimumWait) ) { + remaining = randomResult.minimumWait; + } + } + let isSpecial = false; + if (filler == null) { + filler = special; + isSpecial = true; + } + if (filler != null) { + let fillerstart = 0; + if (isSpecial) { + if (filler.actualDuration > remaining) { + fillerstart = filler.actualDuration - remaining; + } else { + ffillerstart = 0; + } + } else if(isFirst) { + fillerstart = Math.max(0, filler.actualDuration - remaining); + //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.actualDuration - fillerstart - 15000 - SLACK); + fillerstart += Math.floor(more * Math.random() ); + } + lineup.push({ // just add the video, starting at 0, playing the entire duration + type: 'commercial', + title: filler.title, + key: filler.key, + plexFile: filler.plexFile, + file: filler.file, + ratingKey: filler.ratingKey, + start: fillerstart, + streamDuration: Math.max(1, Math.min(filler.actualDuration - fillerstart, remaining) ), + duration: filler.actualDuration, + server: filler.server + }); + return lineup; + } + // pick the offline screen + remaining = Math.min(remaining, 10*60*1000); + //don't display the offline screen for longer than 10 minutes. Maybe the + //channel's admin might change the schedule during that time and then + //it would be better to start playing the content. + lineup.push( { + type: 'offline', + title: 'Channel Offline', + streamDuration: remaining, + duration: remaining, + start: 0 + }) + return lineup; + } if (timeElapsed < 30000) { timeElapsed = 0 } - let activeProgram = obj.program - let lineup = [] + let programStartTimes = [0, activeProgram.actualDuration * .25, activeProgram.actualDuration * .50, activeProgram.actualDuration * .75, activeProgram.actualDuration] let commercials = [[], [], [], [], []] for (let i = 0, l = activeProgram.commercials.length; i < l; i++) // Sort the commercials into their own commerical "slot" array @@ -49,6 +123,7 @@ function createLineup(obj) { foundFirstVideo = true // We found the fucker lineup.push({ type: 'commercial', + title: commercials[i][y].title, key: commercials[i][y].key, plexFile: commercials[i][y].plexFile, file: commercials[i][y].file, @@ -61,6 +136,7 @@ function createLineup(obj) { } else if (foundFirstVideo) { // Otherwise, if weve already found the starting video lineup.push({ // just add the video, starting at 0, playing the entire duration type: 'commercial', + title: commercials[i][y].title, key: commercials[i][y].key, plexFile: commercials[i][y].plexFile, file: commercials[i][y].file, @@ -79,6 +155,7 @@ function createLineup(obj) { foundFirstVideo = true lineup.push({ type: 'program', + title: activeProgram.title, key: activeProgram.key, plexFile: activeProgram.plexFile, file: activeProgram.file, @@ -94,6 +171,7 @@ function createLineup(obj) { } else { lineup.push({ type: 'program', + title: activeProgram.title, key: activeProgram.key, plexFile: activeProgram.plexFile, file: activeProgram.file, @@ -113,11 +191,72 @@ function createLineup(obj) { return lineup } +function pickRandomWithMaxDuration(channel, list, maxDuration) { + let pick1 = null; + let pick2 = null; + let n = 0; + let m = 0; + let t0 = (new Date()).getTime(); + let minimumWait = 1000000000; + const D = 24*60*60*1000; + if (typeof(channel.fillerRepeatCooldown) === 'undefined') { + channel.fillerRepeatCooldown = 30*60*1000; + } + for (let i = 0; i < list.length; i++) { + let clip = list[i]; + // a few extra milliseconds won't hurt anyone, would it? dun dun dun + if (clip.actualDuration <= maxDuration + SLACK ) { + let t1 = channelCache.getProgramLastPlayTime( channel.number, clip ); + let timeSince = ( (t1 == 0) ? D : (t0 - t1) ); + + + if (timeSince < channel.fillerRepeatCooldown - SLACK) { + let w = channel.fillerRepeatCooldown - timeSince; + if (clip.actualDuration + w <= maxDuration + SLACK) { + minimumWait = Math.min(minimumWait, w); + } + timeSince = 0; + //30 minutes is too little, don't repeat it at all + } + if (timeSince >= D) { + n += 1; + if ( Math.floor(n*Math.random()) == 0) { + pick1 = clip; + } + } else { + let adjust = Math.floor(timeSince / (60*1000)); + if (adjust > 0) { + adjust = adjust * adjust; + //weighed + m += adjust; + if ( Math.floor(m*Math.random()) < adjust) { + pick2 = clip; + } + } + } + } + } + let pick = (pick1 == null) ? pick2: pick1; + let pickTitle = "null"; + if (pick != null) { + pickTitle = pick.title; + } + + return { + filler: pick, + minimumWait : minimumWait, + } +} + function isChannelIconEnabled( ffmpegSettings, channel, type) { if (! ffmpegSettings.enableFFMPEGTranscoding || ffmpegSettings.disableChannelOverlay ) { return false; } - if ( (typeof type !== `undefined`) && (type == 'commercial') ) { + let d = channel.disableFillerOverlay; + if (typeof(d) === 'undefined') { + d = true; + } + if ( (typeof type !== `undefined`) && (type == 'commercial') && d ) { return false; } if (channel.icon === '' || !channel.overlayIcon) { diff --git a/src/offline-player.js b/src/offline-player.js new file mode 100644 index 0000000..3ab458d --- /dev/null +++ b/src/offline-player.js @@ -0,0 +1,60 @@ +/****************** + * Offline player is for special screens, like the error + * screen or the Flex Fallback screen. + * + * This module has to follow the program-player contract. + * Asynchronous call to return a stream. Then the stream + * can be used to play the program. + **/ +const EventEmitter = require('events'); +const FFMPEG = require('./ffmpeg') + +class OfflinePlayer { + constructor(error, context) { + this.context = context; + this.error = error; + this.ffmpeg = new FFMPEG(context.ffmpegSettings, context.channel); + } + + cleanUp() { + this.ffmpeg.kill(); + } + + async play() { + try { + let emitter = new EventEmitter(); + let ffmpeg = this.ffmpeg; + let lineupItem = this.context.lineupItem; + let duration = lineupItem.streamDuration - lineupItem.start; + if (this.error) { + ffmpeg.spawnError(duration); + } else { + ffmpeg.spawnOffline(duration); + } + + ffmpeg.on('data', (data) => { + emitter.emit('data', data); + }); + ffmpeg.on('end', () => { + emitter.emit('end'); + }); + ffmpeg.on('close', () => { + emitter.emit('close'); + }); + ffmpeg.on('error', (err) => { + emitter.emit('error', err); + }); + return emitter; + } catch(err) { + if (err instanceof Error) { + throw err; + } else { + throw Error("Error when attempting to play offline screen: " + JSON.stringify(err) ); + } + } + } + + +} + +module.exports = OfflinePlayer; diff --git a/src/plex-player.js b/src/plex-player.js new file mode 100644 index 0000000..a57459e --- /dev/null +++ b/src/plex-player.js @@ -0,0 +1,94 @@ +/****************** + * This module has to follow the program-player contract. + * Async call to get a stream. + * * If connection to plex or the file entry fails completely before playing + * it rejects the promise and the error is an Error() class. + * * Otherwise it returns a stream. + **/ +const PlexTranscoder = require('./plexTranscoder') +const EventEmitter = require('events'); +const helperFuncs = require('./helperFuncs') +const FFMPEG = require('./ffmpeg') + +class PlexPlayer { + + constructor(context) { + this.context = context; + this.ffmpeg = null; + this.plexTranscoder = null; + this.killed = false; + } + + cleanUp() { + this.killed = true; + if (this.plexTranscoder != null) { + this.plexTranscoder.stopUpdatingPlex(); + this.plexTranscoder = null; + } + if (this.ffmpeg != null) { + this.ffmpeg.kill(); + this.ffmpeg = null; + } + } + + async play() { + let lineupItem = this.context.lineupItem; + let ffmpegSettings = this.context.ffmpegSettings; + let db = this.context.db; + let channel = this.context.channel; + + try { + let plexSettings = db['plex-settings'].find()[0]; + let plexTranscoder = new PlexTranscoder(plexSettings, lineupItem); + this.plexTranscoder = plexTranscoder; + let enableChannelIcon = this.context.enableChannelIcon; + let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options + this.ffmpeg = ffmpeg; + let streamDuration; + if (typeof(streamDuration)!=='undefined') { + streamDuration = lineupItem.streamDuration / 1000; + } + let deinterlace = ffmpegSettings.enableFFMPEGTranscoding; //for now it will always deinterlace when transcoding is enabled but this is sub-optimal + + let stream = await plexTranscoder.getStream(deinterlace); + if (this.killed) { + return; + } + + //let streamStart = (stream.directPlay) ? plexTranscoder.currTimeS : undefined; + //let streamStart = (stream.directPlay) ? plexTranscoder.currTimeS : lineupItem.start; + let streamStart = (stream.directPlay) ? plexTranscoder.currTimeS : undefined; + let streamStats = stream.streamStats; + streamStats.duration = lineupItem.streamDuration; + + let emitter = new EventEmitter(); + //setTimeout( () => { + ffmpeg.spawnStream(stream.streamUrl, stream.streamStats, streamStart, streamDuration, enableChannelIcon, lineupItem.type); // Spawn the ffmpeg process + //}, 100); + plexTranscoder.startUpdatingPlex(); + + ffmpeg.on('data', (data) => { + emitter.emit('data', data); + }); + ffmpeg.on('end', () => { + emitter.emit('end'); + }); + ffmpeg.on('close', () => { + emitter.emit('close'); + }); + ffmpeg.on('error', (err) => { + emitter.emit('error', err); + }); + return emitter; + + } catch(err) { + if (err instanceof Error) { + throw err; + } else { + return Error("Error when playing plex program: " + JSON.stringify(err) ); + } + } + } +} + +module.exports = PlexPlayer; diff --git a/src/plexTranscoder.js b/src/plexTranscoder.js index 2949e5f..374f91f 100644 --- a/src/plexTranscoder.js +++ b/src/plexTranscoder.js @@ -34,42 +34,43 @@ class PlexTranscoder { this.log("Getting stream") this.log(` deinterlace: ${deinterlace}`) this.log(` streamPath: ${this.settings.streamPath}`) - this.log(` forceDirectPlay: ${this.settings.forceDirectPlay}`) - // direct play forced + + if (this.settings.streamPath === 'direct' || this.settings.forceDirectPlay) { + stream = {directPlay: true} + } else { + try { + this.log("Setting transcoding parameters") + this.setTranscodingArgs(stream.directPlay, true, deinterlace) + await this.getDecision(stream.directPlay); + if (this.isDirectPlay()) { + stream.directPlay = true; + stream.streamUrl = this.plexFile; + } + } catch (err) { + this.log("Error when getting decision. 1. Check Plex connection. 2. This might also be a sign that plex direct play and transcode settings are too strict and it can't find any allowed action for the selected video.") + stream.directPlay = true; + } + } + if (stream.directPlay) { this.log("Direct play forced or native paths enabled") stream.directPlay = true this.setTranscodingArgs(stream.directPlay, true, false) // Update transcode decision for session await this.getDecision(stream.directPlay); stream.streamUrl = (this.settings.streamPath === 'direct') ? this.file : this.plexFile; - } else { // Set transcoding parameters based off direct stream params - this.log("Setting transcoding parameters") - this.setTranscodingArgs(stream.directPlay, true, deinterlace) - - await this.getDecision(stream.directPlay); - - if (this.isDirectPlay()) { - this.log("Decision: File can direct play") - stream.directPlay = true - this.setTranscodingArgs(stream.directPlay, true, false) - // Update transcode decision for session - await this.getDecision(stream.directPlay); - stream.streamUrl = this.plexFile; - } else if (this.isVideoDirectStream() === false) { + } else if (this.isVideoDirectStream() === false) { this.log("Decision: File can direct play") // Change transcoding arguments to be the user chosen transcode parameters this.setTranscodingArgs(stream.directPlay, false, deinterlace) // Update transcode decision for session await this.getDecision(stream.directPlay); stream.streamUrl = `${this.transcodeUrlBase}${this.transcodingArgs}` - } else { - this.log("Decision: Direct stream. Audio is being transcoded") - stream.streamUrl = `${this.transcodeUrlBase}${this.transcodingArgs}` - } + } else { + this.log("Decision: Direct stream. Audio is being transcoded") + stream.streamUrl = `${this.transcodeUrlBase}${this.transcodingArgs}` } - stream.streamStats = this.getVideoStats(); // use correct audio stream if direct play @@ -94,7 +95,16 @@ class PlexTranscoder { let resolutionArr = resolution.split("x") - let clientProfile=`add-transcode-target(type=videoProfile&protocol=${this.settings.streamProtocol}&container=${streamContainer}&videoCodec=${this.settings.videoCodecs}&audioCodec=${this.settings.audioCodecs}&subtitleCodec=&context=streaming&replace=true)+\ + let vc = this.settings.videoCodecs; + //This codec is not currently supported by plex so requesting it to transcode will always + // cause an error. If Plex ever supports av1, remove this. I guess. + if (vc != '') { + vc += ",av1"; + } else { + vc = "av1"; + } + + let clientProfile=`add-transcode-target(type=videoProfile&protocol=${this.settings.streamProtocol}&container=${streamContainer}&videoCodec=${vc}&audioCodec=${this.settings.audioCodecs}&subtitleCodec=&context=streaming&replace=true)+\ add-transcode-target-settings(type=videoProfile&context=streaming&protocol=${this.settings.streamProtocol}&CopyMatroskaAttachments=true)+\ add-transcode-target-settings(type=videoProfile&context=streaming&protocol=${this.settings.streamProtocol}&BreakNonKeyframes=true)+\ add-limitation(scope=videoCodec&scopeName=*&type=upperBound&name=video.width&value=${resolutionArr[0]})+\ @@ -245,9 +255,6 @@ lang=en` console.log(`Error message: '${res.data.MediaContainer.transcodeDecisionText}'`) } }) - .catch((err) => { - console.log(err); - }); } getStatusUrl() { diff --git a/src/program-player.js b/src/program-player.js new file mode 100644 index 0000000..2097522 --- /dev/null +++ b/src/program-player.js @@ -0,0 +1,113 @@ +/****************** + * This module is to take a "program" and return a stream that plays the + * program. OR the promise fails which would mean that there was an error + * playing the program. + * + * The main purpose is to have an abstract interface for playing program + * objects without having to worry the source of the program object. + * A long-term goal is to be able to have sources other than plex to play + * videos. This is the first step towards that goal. + * + * Returns an event emitter that will have the 'data' or 'end' events. + * The contract is that the emitter will stream at least some media stream + * before ending. Any errors that occur after sending the first data will + * be dealt with internally and be presented as an 'end' event. + * + * If there is a timeout when receiving the initial data, or if the program + * can't load at all for some reason, an Error will be thrown. Make sure to + * deal with the thrown error. + **/ + +let OfflinePlayer = require('./offline-player'); +let PlexPlayer = require('./plex-player');; +const EventEmitter = require('events'); +const helperFuncs = require('./helperFuncs'); + + +class ProgramPlayer { + + constructor( context ) { + this.context = context; + let program = context.lineupItem; + if (program.err instanceof Error) { + console.log("About to play error stream"); + this.delegate = new OfflinePlayer(true, context); + } else if (program.type === 'offline') { + console.log("About to play offline stream"); + /* offline */ + this.delegate = new OfflinePlayer(false, context); + } else { + console.log("About to play plex stream"); + /* plex */ + this.delegate = new PlexPlayer(context); + } + this.context.enableChannelIcon = helperFuncs.isChannelIconEnabled( context.ffmpegSettings, context.channel, context.lineupItem.type); + } + + cleanUp() { + this.delegate.cleanUp(); + } + + async playDelegate() { + return await new Promise( async (accept, reject) => { + setTimeout( () => { + reject( Error("program player timed out before receiving any data.") ); + }, 30000); + + try { + let stream = await this.delegate.play(); + let first = true; + let emitter = new EventEmitter(); + stream.on("data", (data) => { + if (first) { + accept( {stream: emitter, data: data} ); + first = false; + } else { + emitter.emit("data", data); + } + }); + function end() { + reject( Error("Stream ended with no data") ); + stream.removeAllListeners("data"); + stream.removeAllListeners("end"); + stream.removeAllListeners("close"); + stream.removeAllListeners("error"); + emitter.emit("end"); + } + stream.on("error", err => { + reject( Error("Stream ended in error with no data. " + JSON.stringify(err) ) ); + end(); + }); + stream.on("end", end); + stream.on("close", end); + } catch (err) { + reject(err); + } + }) + } + async play() { + try { + return await this.playDelegate(); + } catch(err) { + if (! (err instanceof Error) ) { + err= Error("Program player had an error before receiving any data. " + JSON.stringify(err) ); + } + if (this.context.lineupItem.err instanceof Error) { + console.log(err.stack); + throw Error("Additional error when attempting to play error stream."); + } + console.log("Error when attempting to play video. Fallback to error stream: " + err.stack); + //Retry once with an error stream: + this.context.lineupItem = { + err: err, + start: this.context.lineupItem.start, + streamDuration: this.context.lineupItem.streamDuration, + } + this.delegate.cleanUp(); + this.delegate = new OfflinePlayer(true, this.context); + return await this.play(); + } + } +} + +module.exports = ProgramPlayer; diff --git a/src/svg/generic-offline.screen.svg b/src/svg/generic-offline.screen.svg new file mode 100644 index 0000000..889bc70 --- /dev/null +++ b/src/svg/generic-offline.screen.svg @@ -0,0 +1,78 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/src/video.js b/src/video.js index c1f1e4e..0a39e16 100644 --- a/src/video.js +++ b/src/video.js @@ -4,6 +4,8 @@ const FFMPEG = require('./ffmpeg') const FFMPEG_TEXT = require('./ffmpegText') const PlexTranscoder = require('./plexTranscoder') const fs = require('fs') +const ProgramPlayer = require('./program-player'); +const channelCache = require('./channel-cache') module.exports = { router: video } @@ -46,7 +48,8 @@ function video(db) { res.status(500).send("No Channel Specified") return } - let channel = db['channels'].find({ number: parseInt(req.query.channel, 10) }) + let number = parseInt(req.query.channel, 10); + let channel = channelCache.getChannelConfig(db, number); if (channel.length === 0) { res.status(500).send("Channel doesn't exist") return @@ -68,50 +71,85 @@ function video(db) { console.log(`\r\nStream starting. Channel: ${channel.number} (${channel.name})`) + let lastWrite = (new Date()).getTime(); let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options + let stopped = false; - ffmpeg.on('data', (data) => { res.write(data) }) + function stop() { + if (! stopped) { + stopped = true; + try { + res.end(); + } catch (err) {} + ffmpeg.kill(); + } + } + let watcher = () => { + let t1 = (new Date()).getTime(); + if (t1 - lastWrite >= 30000) { + console.log("Client timed out, stop stream."); + //way too long without writes, time out + stop(); + } + if (! stopped) { + setTimeout(watcher, 5000); + } + }; + setTimeout(watcher, 5000); + + + + ffmpeg.on('data', (data) => { + if (! stopped) { + lastWrite = (new Date()).getTime(); + res.write(data) + } + }) ffmpeg.on('error', (err) => { console.error("FFMPEG ERROR", err); //status was already sent - res.end(); + stop(); return; }) - ffmpeg.on('close', () => { - res.end(); - }) + ffmpeg.on('close', stop) res.on('close', () => { // on HTTP close, kill ffmpeg console.log(`\r\nStream ended. Channel: ${channel.number} (${channel.name})`); - ffmpeg.kill(); + stop(); }) ffmpeg.on('end', () => { - console.log("Recieved end of stream when playing a continuous playlist. This should never happen!"); - console.log("This either means ffmpeg could not open any valid streams, or you've watched countless hours of television without changing channels. If it is the latter I salute you.") + console.log("Video queue exhausted. Either you played 100 different clips in a row or there were technical issues that made all of the possible 100 attempts fail.") + stop(); }) let channelNum = parseInt(req.query.channel, 10) ffmpeg.spawnConcat(`http://localhost:${process.env.PORT}/playlist?channel=${channelNum}`); }) // Stream individual video to ffmpeg concat above. This is used by the server, NOT the client - router.get('/stream', (req, res) => { + router.get('/stream', async (req, res) => { // Check if channel queried is valid if (typeof req.query.channel === 'undefined') { - res.status(500).send("No Channel Specified") + res.status(400).send("No Channel Specified") return } - let channel = db['channels'].find({ number: parseInt(req.query.channel, 10) }) + + let number = parseInt(req.query.channel); + let channel = channelCache.getChannelConfig(db, number); + if (channel.length === 0) { - res.status(500).send("Channel doesn't exist") + res.status(404).send("Channel doesn't exist") return } + let isFirst = false; + if ( (typeof req.query.first !== 'undefined') && (req.query.first=='1') ) { + isFirst = true; + } channel = channel[0] let ffmpegSettings = db['ffmpeg-settings'].find()[0] - let plexSettings = db['plex-settings'].find()[0] // Check if ffmpeg path is valid if (!fs.existsSync(ffmpegSettings.ffmpegPath)) { @@ -120,97 +158,128 @@ function video(db) { return } - res.writeHead(200, { - 'Content-Type': 'video/mp2t' - }) + + // Get video lineup (array of video urls with calculated start times and durations.) - let prog = helperFuncs.getCurrentProgramAndTimeElapsed(Date.now(), channel) - let lineup = helperFuncs.createLineup(prog) - let lineupItem = lineup.shift() + let t0 = (new Date()).getTime(); + let lineupItem = channelCache.getCurrentLineupItem( channel.number, t0); + if (lineupItem == null) { + let prog = helperFuncs.getCurrentProgramAndTimeElapsed(t0, channel) - - let streamDuration = lineupItem.streamDuration / 1000; - - // Only episode in this lineup, or item is a commercial, let stream end naturally - if (lineup.length === 0 || lineupItem.type === 'commercial' || lineup.length === 1 && lineup[0].type === 'commercial') - streamDuration = undefined - - let enableChannelIcon = helperFuncs.isChannelIconEnabled( ffmpegSettings, channel, lineupItem.type); - let deinterlace = ffmpegSettings.enableFFMPEGTranscoding; //for now it will always deinterlace when transcoding is enabled but this is sub-optimal - - let plexTranscoder = new PlexTranscoder(plexSettings, lineupItem); - let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options - - var ffmpeg1Ended = false; - ffmpeg.on('data', (data) => { res.write(data) }) - - ffmpeg.on('error', (err) => { - if (ffmpeg1Ended) { - return; - } - ffmpeg1Ended = true; - plexTranscoder.stopUpdatingPlex(); - if (typeof(this.backup) !== 'undefined') { - let ffmpeg2 = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options - ffmpeg2.spawnError('Source error', `ffmpeg returned code ${err.code}`, this.backup.stream.streamStats, this.backup.enableChannelIcon, this.backup.type); // Spawn the ffmpeg process, fire this bitch up - ffmpeg2.on('data', (data) => { - try { - res.write(data) - } catch (err) { - console.log("err="+err); - } - } ); - ffmpeg2.on('error', (err) => { res.end() } ); - ffmpeg2.on('close', () => { res.send() } ); - ffmpeg2.on('end', () => { res.end() } ); - res.on('close', () => { - ffmpeg2.kill(); - }); - } else { - res.end() - } - }) - - ffmpeg.on('close', () => { - if (ffmpeg1Ended) { - return; - } - plexTranscoder.stopUpdatingPlex(); - res.end(); - }) - - ffmpeg.on('end', () => { // On finish transcode - END of program or commercial... - if (ffmpeg1Ended) { - return; - } - plexTranscoder.stopUpdatingPlex(); - res.end() - }) - - res.on('close', () => { // on HTTP close, kill ffmpeg - plexTranscoder.stopUpdatingPlex(); - ffmpeg.kill(); - }) - - plexTranscoder.getStream(deinterlace).then(stream => { - - let streamStart = (stream.directPlay) ? plexTranscoder.currTimeS : undefined; - - let streamStats = stream.streamStats; - streamStats.duration = lineupItem.streamDuration; - - this.backup = { - stream: stream, - streamStart: streamStart, - enableChannelIcon: enableChannelIcon, - type: lineupItem.type + if (prog.program.isOffline && channel.programs.length == 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 + //filler to play (if any) + let t = 365*24*60*60*1000; + prog.program = { + actualDuration: t, + duration: t, + isOffline : true, }; + } else if (prog.program.isOffline && prog.program.duration - prog.timeElapsed <= 10000) { + //it's pointless to show the offline screen for such a short time, might as well + //skip to the next program + prog.programIndex = (prog.programIndex + 1) % channel.programs.length; + prog.program = channel.programs[prog.programIndex ]; + prog.timeElapsed = 0; + } + 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) + lineupItem = lineup.shift() + } + - ffmpeg.spawnStream(stream.streamUrl, stream.streamStats, streamStart, streamDuration, enableChannelIcon, lineupItem.type); // Spawn the ffmpeg process, fire this bitch up - plexTranscoder.startUpdatingPlex(); + console.log("========================================================="); + console.log("! Start playback"); + console.log(`! Channel: ${channel.name} (${channel.number})`); + if (typeof(lineupItem) === 'undefined') { + lineupItem.title = 'Unknown'; + } + console.log(`! Title: ${lineupItem.title}`); + if ( typeof(lineupItem.streamDuration) === 'undefined') { + console.log(`! From : ${lineupItem.start}`); + } else { + console.log(`! From : ${lineupItem.start} to: ${lineupItem.start + lineupItem.streamDuration}`); + } + console.log("========================================================="); + + channelCache.recordPlayback(channel.number, t0, lineupItem); + + let playerContext = { + lineupItem : lineupItem, + ffmpegSettings : ffmpegSettings, + channel: channel, + db: db, + } + + let player = new ProgramPlayer(playerContext); + let stopped = false; + let stop = () => { + if (!stopped) { + stopped = true; + player.cleanUp(); + player = null; + res.end(); + } + }; + var playerObj = null; + try { + playerObj = await player.play(); + } catch (err) { + console.log("Error when attempting to play video: " +err.stack); + try { + res.status(500).send("Unable to start playing video.").end(); + } catch (err2) { + console.log(err2.stack); + } + stop(); + return; + } + let lastWrite = (new Date()).getTime(); + let watcher = () => { + let t1 = (new Date()).getTime(); + if (t1 - lastWrite >= 30000) { + console.log("Demux ffmpeg timed out, stop stream."); + //way too long without writes, time out + stop(); + } + if (! stopped) { + setTimeout(watcher, 5000); + } + }; + setTimeout(watcher, 5000); + + let stream = playerObj.stream; + res.writeHead(200, { + 'Content-Type': 'video/mp2t' }); - }) + + res.write(playerObj.data); + + stream.on("data", (data) => { + try { + if (! stopped) { + lastWrite = (new Date()).getTime(); + res.write(data); + } + } catch (err) { + console.log("I/O Error: " + err.stack); + stop(); + } + }); + stream.on("end", () => { + stop(); + }); + res.on("close", () => { + console.log("Client Closed"); + stop(); + }); + }); + router.get('/playlist', (req, res) => { res.type('text') @@ -221,7 +290,7 @@ function video(db) { } let channelNum = parseInt(req.query.channel, 10) - let channel = db['channels'].find({ number: channelNum }) + let channel = channelCache.getChannelConfig(db, channelNum ); if (channel.length === 0) { res.status(500).send("Channel doesn't exist") return @@ -233,8 +302,10 @@ function video(db) { var data = "ffconcat version 1.0\n" - for (var i = 0; i < maxStreamsToPlayInARow; i++) + data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}&first=1'\n` + for (var i = 0; i < maxStreamsToPlayInARow - 1; i++) { data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}'\n` + } res.send(data) }) diff --git a/src/xmltv.js b/src/xmltv.js index 36f98e2..5fb8edc 100644 --- a/src/xmltv.js +++ b/src/xmltv.js @@ -7,11 +7,12 @@ module.exports = { WriteXMLTV: WriteXMLTV } function WriteXMLTV(channels, xmlSettings) { return new Promise((resolve, reject) => { let date = new Date() - var ws = fs.createWriteStream(xmlSettings.file) - var xw = new XMLWriter(true, (str, enc) => ws.write(str, enc)) + let ws = fs.createWriteStream(xmlSettings.file) + let xw = new XMLWriter(true, (str, enc) => ws.write(str, enc)) ws.on('close', () => { resolve() }) ws.on('error', (err) => { reject(err) }) _writeDocStart(xw) + async function middle() { if (channels.length === 0) { // Write Dummy PseudoTV Channel if no channel exists _writeChannels(xw, [{ number: 1, name: "PseudoTV", icon: "https://raw.githubusercontent.com/DEFENDORe/pseudotv/master/resources/pseudotv.png" }]) let program = { @@ -24,15 +25,19 @@ function WriteXMLTV(channels, xmlSettings) { start: date, stop: new Date(date.valueOf() + xmlSettings.cache * 60 * 60 * 1000) } - _writeProgramme(xw, program) + await _writeProgramme(xw, program) } else { _writeChannels(xw, channels) - for (var i = 0; i < channels.length; i++) - _writePrograms(xw, channels[i], date, xmlSettings.cache) + for (let i = 0; i < channels.length; i++) { + await _writePrograms(xw, channels[i], date, xmlSettings.cache) + } } - + } + middle().then( () => { _writeDocEnd(xw, ws) - ws.close() + }).catch( (err) => { + console.error("Error", err); + }).then( () => ws.close() ); }) } @@ -47,7 +52,7 @@ function _writeDocEnd(xw, ws) { } function _writeChannels(xw, channels) { - for (var i = 0; i < channels.length; i++) { + for (let i = 0; i < channels.length; i++) { xw.startElement('channel') xw.writeAttribute('id', channels[i].number) xw.startElement('display-name') @@ -63,7 +68,7 @@ function _writeChannels(xw, channels) { } } -function _writePrograms(xw, channel, date, cache) { +async function _writePrograms(xw, channel, date, cache) { let prog = helperFuncs.getCurrentProgramAndTimeElapsed(date, channel) let cutoff = new Date((date.valueOf() - prog.timeElapsed) + (cache * 60 * 60 * 1000)) let temp = new Date(date.valueOf() - prog.timeElapsed) @@ -71,6 +76,7 @@ function _writePrograms(xw, channel, date, cache) { return let i = prog.programIndex for (; temp < cutoff;) { + await _throttle(); //let's not block for this process let program = { program: channel.programs[i], channel: channel.number, @@ -85,7 +91,11 @@ function _writePrograms(xw, channel, date, cache) { } } -function _writeProgramme(xw, program) { +async function _writeProgramme(xw, program) { + if (program.program.isOffline === true) { + //do not write anything for the offline period + return; + } // Programme xw.startElement('programme') xw.writeAttribute('start', _createXMLTVDate(program.start)) @@ -136,4 +146,9 @@ function _writeProgramme(xw, program) { } function _createXMLTVDate(d) { return d.toISOString().substring(0,19).replace(/[-T:]/g,"") + " +0000"; +} +function _throttle() { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); } \ No newline at end of file diff --git a/web/app.js b/web/app.js index 5bb4cd8..d032d0c 100644 --- a/web/app.js +++ b/web/app.js @@ -15,6 +15,7 @@ app.directive('xmltvSettings', require('./directives/xmltv-settings')) app.directive('hdhrSettings', require('./directives/hdhr-settings')) app.directive('plexLibrary', require('./directives/plex-library')) app.directive('programConfig', require('./directives/program-config')) +app.directive('offlineConfig', require('./directives/offline-config')) app.directive('channelConfig', require('./directives/channel-config')) app.controller('settingsCtrl', require('./controllers/settings')) diff --git a/web/directives/channel-config.js b/web/directives/channel-config.js index 0551d22..bf19f11 100644 --- a/web/directives/channel-config.js +++ b/web/directives/channel-config.js @@ -9,18 +9,26 @@ module.exports = function ($timeout, $location) { onDone: "=onDone" }, link: function (scope, element, attrs) { + scope.showHelp = false; scope.millisecondsOffset = 0; if (typeof scope.channel === 'undefined' || scope.channel == null) { scope.channel = {} scope.channel.programs = [] + scope.channel.fillerContent = [] + scope.channel.fillerRepeatCooldown = 30 * 60 * 1000; + scope.channel.fallback = []; scope.isNewChannel = true scope.channel.icon = `${$location.protocol()}://${location.host}/images/pseudotv.png` + scope.channel.disableFillerOverlay = true; scope.channel.iconWidth = 120 scope.channel.iconDuration = 60 scope.channel.iconPosition = "2" scope.channel.startTime = new Date() scope.channel.startTime.setMilliseconds(0) scope.channel.startTime.setSeconds(0) + scope.channel.offlinePicture = `${$location.protocol()}://${location.host}/images/generic-offline-screen.png` + scope.channel.offlineSoundtrack = '' + scope.channel.offlineMode = "pic"; if (scope.channel.startTime.getMinutes() < 30) scope.channel.startTime.setMinutes(0) else @@ -53,6 +61,26 @@ module.exports = function ($timeout, $location) { x += d; } } + if (typeof(scope.channel.fillerRepeatCooldown) === 'undefined') { + scope.channel.fillerRepeatCooldown = 30 * 60 * 1000; + } + if (typeof(scope.channel.offlinePicture)==='undefined') { + scope.channel.offlinePicture = `${$location.protocol()}://${location.host}/images/generic-offline-screen.png` + scope.channel.offlineSoundtrack = ''; + } + if (typeof(scope.channel.fillerContent)==='undefined') { + scope.channel.fillerContent = []; + } + if (typeof(scope.channel.fallback)==='undefined') { + scope.channel.fallback = []; + scope.channel.offlineMode = "pic"; + } + if (typeof(scope.channel.offlineMode)==='undefined') { + scope.channel.offlineMode = 'pic'; + } + if (typeof(scope.channel.disableFillerOverlay) === 'undefined') { + scope.channel.disableFillerOverlay = true; + } scope.millisecondsOffset = (t - offset) % 1000; scope.channel.startTime = new Date(t - offset - scope.millisecondsOffset); // move runningProgram to index 0 @@ -67,10 +95,44 @@ module.exports = function ($timeout, $location) { scope._selectedProgram = null updateChannelDuration() } + scope.updateChannelFromOfflineResult = (program) => { + scope.channel.offlineMode = program.channelOfflineMode; + scope.channel.offlinePicture = program.channelPicture; + scope.channel.offlineSoundtrack = program.channelSound; + scope.channel.fillerRepeatCooldown = program.repeatCooldown * 60000; + scope.channel.fillerContent = JSON.parse( angular.toJson(program.filler) ); + scope.channel.fallback = JSON.parse( angular.toJson(program.fallback) ); + scope.channel.disableFillerOverlay = program.disableOverlay; + } + scope.finishedOfflineEdit = (program) => { + let editedProgram = scope.channel.programs[scope.selectedProgram]; + let duration = program.durationSeconds * 1000; + scope.updateChannelFromOfflineResult(program); + editedProgram.duration = duration; + editedProgram.actualDuration = duration; + editedProgram.isOffline = true; + scope._selectedOffline = null + updateChannelDuration() + } + scope.finishedAddingOffline = (result) => { + let duration = result.durationSeconds * 1000; + let program = { + duration: duration, + actualDuration: duration, + isOffline: true + } + scope.updateChannelFromOfflineResult(result); + scope.channel.programs.push( program ); + scope._selectedOffline = null + scope._addingOffline = null; + updateChannelDuration() + } + scope.$watch('channel.startTime', () => { updateChannelDuration() }) scope.sortShows = () => { + scope.removeOffline(); let shows = {} let movies = [] let newProgs = [] @@ -106,6 +168,47 @@ module.exports = function ($timeout, $location) { scope.channel.programs = newProgs.concat(movies) updateChannelDuration() } + scope.sortByDate = () => { + scope.removeOffline(); + scope.channel.programs.sort( (a,b) => { + let aHas = ( typeof(a.date) !== 'undefined' ); + let bHas = ( typeof(b.date) !== 'undefined' ); + if (!aHas && !bHas) { + return 0; + } else if (! aHas) { + return 1; + } else if (! bHas) { + return -1; + } + if (a.date < b.date ) { + return -1; + } else if (a.date > b.date) { + return 1; + } else { + let aHasSeason = ( typeof(a.season) !== 'undefined' ); + let bHasSeason = ( typeof(b.season) !== 'undefined' ); + if (! aHasSeason && ! bHasSeason) { + return 0; + } else if (! aHasSeason) { + return 1; + } else if (! bHasSeason) { + return -1; + } + if (a.season < b.season) { + return -1; + } else if (a.season > b.season) { + return 1; + } else if (a.episode < b.episode) { + return -1; + } else if (a.episode > b.episode) { + return 1; + } else { + return 0; + } + } + }); + updateChannelDuration() + } scope.removeDuplicates = () => { let tmpProgs = {} let progs = scope.channel.programs @@ -123,6 +226,131 @@ module.exports = function ($timeout, $location) { } scope.channel.programs = newProgs } + scope.removeOffline = () => { + let tmpProgs = [] + let progs = scope.channel.programs + for (let i = 0, l = progs.length; i < l; i++) { + if (progs[i].isOffline !== true) { + tmpProgs.push(progs[i]); + } + } + scope.channel.programs = tmpProgs + updateChannelDuration() + } + scope.nightChannel = (a, b) => { + let o =(new Date()).getTimezoneOffset() * 60 * 1000; + let m = 24*60*60*1000; + a = (m + a * 60 * 60 * 1000 + o) % m; + b = (m + b * 60 * 60 * 1000 + o) % m; + if (b < a) { + b += m; + } + b -= a; + let progs = []; + let t = scope.channel.startTime.getTime(); + function pos(x) { + if (x % m < a) { + return m + x % m - a; + } else { + return x % m - a; + } + } + t -= pos(t); + scope.channel.startTime = new Date(t); + 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) ) { + //time to pad + let d = m - p; + progs.push( + { + duration: d, + actualDuration: d, + isOffline: true, + } + ) + t += d; + p = 0; + } + progs.push( scope.channel.programs[i] ); + t += scope.channel.programs[i].duration; + } + if (pos(t) != 0) { + let d = m - pos(t); + progs.push( + { + duration: d, + actualDuration: d, + isOffline: true, + } + ) + } + scope.channel.programs = progs; + updateChannelDuration(); + } + scope.addBreaks = (afterMinutes, minDurationSeconds, maxDurationSeconds) => { + let after = afterMinutes * 60 * 1000 + 5000; //allow some seconds of excess + let minDur = minDurationSeconds; + let maxDur = maxDurationSeconds; + let progs = []; + 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) { + tired = 0; + } else { + if (tired + prog.actualDuration >= after) { + tired = 0; + let dur = 1000 * (minDur + Math.floor( (maxDur - minDur) * Math.random() ) ); + progs.push( { + isOffline : true, + duration: dur, + actualDuration: dur, + }); + } + tired += prog.actualDuration; + } + if (i < l) { + progs.push(prog); + } + } + scope.channel.programs = progs; + updateChannelDuration(); + } + scope.padTimes = (paddingMod) => { + let mod = paddingMod * 60 * 1000; + if (mod == 0) { + mod = 60*60*1000; + } + scope.removeOffline(); + let progs = []; + let t = scope.channel.startTime.getTime(); + t = t - t % mod; + scope.millisecondsOffset = 0; + scope.channel.startTime = new Date(t); + function addPad(force) { + let m = t % mod; + let r = (mod - t % mod) % mod; + if ( (force && (m != 0)) || ((m >= 15*1000) && (r >= 15*1000)) ) { + // (If the difference is less than 30 seconds, it's + // not worth padding it + progs.push( { + duration : r, + actualDuration : r, + isOffline : true, + }); + t += r; + } + } + for (let i = 0, l = scope.channel.programs.length; i < l; i++) { + let prog = scope.channel.programs[i]; + progs.push(prog); + t += prog.actualDuration; + addPad(i == l - 1); + } + scope.channel.programs = progs; + updateChannelDuration(); + } scope.blockShuffle = (blockCount, randomize) => { if (typeof blockCount === 'undefined' || blockCount == null) return @@ -183,10 +411,31 @@ module.exports = function ($timeout, $location) { cyclicShuffle(scope.channel.programs); updateChannelDuration(); } + scope.equalizeShows = () => { + scope.removeDuplicates(); + scope.channel.programs = equalizeShows(scope.channel.programs); + updateChannelDuration(); + } + scope.wipeSchedule = () => { wipeSchedule(scope.channel.programs); updateChannelDuration(); } + scope.makeOfflineFromChannel = (duration) => { + return { + channelOfflineMode: scope.channel.offlineMode, + channelPicture: scope.channel.offlinePicture, + channelSound: scope.channel.offlineSoundtrack, + repeatCooldown : Math.floor(scope.channel.fillerRepeatCooldown / 60000), + filler: JSON.parse( angular.toJson(scope.channel.fillerContent) ), + fallback: JSON.parse( angular.toJson(scope.channel.fallback) ), + durationSeconds: duration, + disableOverlay : scope.channel.disableFillerOverlay, + } + } + scope.addOffline = () => { + scope._addingOffline = scope.makeOfflineFromChannel(10*60); + } function getRandomInt(min, max) { min = Math.ceil(min) @@ -208,6 +457,56 @@ module.exports = function ($timeout, $location) { array.splice(0, array.length) return array; } + function equalizeShows(array) { + let shows = {}; + let progs = []; + let nonShows = []; + for (let i = 0; i < array.length; i++) { + vid = array[i]; + if (vid.type === 'episode' && vid.season != 0) { + if ( typeof(shows[vid.showTitle]) === 'undefined') { + shows[vid.showTitle] = { + total: 0, + episodes: [] + } + } + shows[vid.showTitle].total += vid.actualDuration; + shows[vid.showTitle].episodes.push(vid); + } else { + nonShows.push(vid); + } + } + let maxDuration = 0; + Object.keys(shows).forEach(function(key,index) { + maxDuration = Math.max( maxDuration, shows[key].total ); + }); + let F = 2; + let good = true; + Object.keys(shows).forEach(function(key,index) { + let amount = Math.floor( (maxDuration*F) / shows[key].total); + good = (good && (amount % F == 0) ); + }); + if (good) { + F = 1; + } + for(let i = 0; i < F; i++) { + for (let j = 0; j < nonShows.length; j++) { + progs.push( JSON.parse( angular.toJson(nonShows[j]) ) ); + } + } + Object.keys(shows).forEach(function(key,index) { + let amount = Math.floor( (maxDuration*F) / shows[key].total); + let episodes = shows[key].episodes; + if (amount % F != 0) { + } + for (let i = 0; i < amount; i++) { + for (let j = 0; j < episodes.length; j++) { + progs.push( JSON.parse( angular.toJson(episodes[j]) ) ); + } + } + }); + return progs; + } function cyclicShuffle(array) { let shows = {}; let next = {}; @@ -215,7 +514,7 @@ module.exports = function ($timeout, $location) { // some precalculation, useful to stop the shuffle from being quadratic... for (let i = 0; i < array.length; i++) { var vid = array[i]; - if (vid.type != 'movie' && vid.season != 0) { + if (vid.type === 'episode' && vid.season != 0) { let countKey = { title: vid.showTitle, s: vid.season, @@ -323,13 +622,73 @@ module.exports = function ($timeout, $location) { updateChannelDuration() } scope.selectProgram = (index) => { - scope.selectedProgram = index - scope._selectedProgram = JSON.parse(angular.toJson(scope.channel.programs[index])) + scope.selectedProgram = index; + let program = scope.channel.programs[index]; + + if(program.isOffline) { + scope._selectedOffline = scope.makeOfflineFromChannel( Math.round( (program.duration + 500) / 1000 ) ); + } else { + scope._selectedProgram = JSON.parse(angular.toJson(program)); + } } scope.removeItem = (x) => { scope.channel.programs.splice(x, 1) updateChannelDuration() } + scope.paddingOptions = [ + { id: 30, description: ":00, :30" }, + { id: 15, description: ":00, :15, :30, :45" }, + { id: 60, description: ":00" }, + { id: 20, description: ":00, :20, :40" }, + { id: 10, description: ":00, :10, :20, ..., :50" }, + { id: 5, description: ":00, :05, :10, ..., :55" }, + ] + scope.breakAfterOptions = [ + { id: -1, description: "After" }, + { id: 5, description: "5 minutes" }, + { id: 10, description: "10 minutes" }, + { id: 15, description: "15 minutes" }, + { id: 20, description: "20 minutes" }, + { id: 25, description: "25 minutes" }, + { id: 30, description: "30 minutes" }, + { id: 60, description: "1 hour" }, + { id: 90, description: "90 minutes" }, + { id: 120, description: "2 hours" }, + ] + scope.breakAfter = -1; + scope.minBreakSize = -1; + scope.maxBreakSize = -1; + let breakSizeOptions = [ + { id: 30, description: "30 seconds" }, + { id: 45, description: "45 seconds" }, + { id: 60, description: "60 seconds" }, + { id: 90, description: "90 seconds" }, + { id: 120, description: "2 minutes" }, + { 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" }, + ] + scope.minBreakSizeOptions = [ + { id: -1, description: "Min Duration" }, + ] + scope.minBreakSizeOptions = scope.minBreakSizeOptions.concat(breakSizeOptions); + scope.maxBreakSizeOptions = [ + { id: -1, description: "Max Duration" }, + ] + scope.maxBreakSizeOptions = scope.maxBreakSizeOptions.concat(breakSizeOptions); + + scope.nightStartHours = [ { id: -1, description: "Start" } ]; + scope.nightEndHours = [ { id: -1, description: "End" } ]; + scope.nightStart = -1; + scope.nightEnd = -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.paddingMod = 30; } } } diff --git a/web/directives/offline-config.js b/web/directives/offline-config.js new file mode 100644 index 0000000..df6b49f --- /dev/null +++ b/web/directives/offline-config.js @@ -0,0 +1,60 @@ +module.exports = function ($timeout) { + return { + restrict: 'E', + templateUrl: 'templates/offline-config.html', + replace: true, + scope: { + title: "@offlineTitle", + program: "=program", + visible: "=visible", + onDone: "=onDone" + }, + link: function (scope, element, attrs) { + scope.showPlexLibrary = false; + scope.showFallbackPlexLibrary = false; + scope.finished = (prog) => { + if ( + prog.channelOfflineMode != 'pic' + && (prog.fallback.length == 0) + ) { + scope.error = { fallback: 'Either add a fallback clip or change the fallback mode to Picture.' } + } + if (isNaN(prog.durationSeconds) || prog.durationSeconds < 0 ) { + scope.error = { duration: 'Duration must be a positive integer' } + } + if (scope.error != null) { + $timeout(() => { + scope.error = null + }, 3500) + return + } + + scope.onDone(JSON.parse(angular.toJson(prog))) + scope.program = null + } + scope.importPrograms = (selectedPrograms) => { + for (let i = 0, l = selectedPrograms.length; i < l; i++) { + selectedPrograms[i].commercials = [] + } + scope.program.filler = scope.program.filler.concat(selectedPrograms); + } + + scope.importFallback = (selectedPrograms) => { + for (let i = 0, l = selectedPrograms.length; i < l && i < 1; i++) { + selectedPrograms[i].commercials = [] + } + scope.program.fallback = []; + if (selectedPrograms.length > 0) { + scope.program.fallback = [ selectedPrograms[0] ]; + } + } + + + scope.durationString = (duration) => { + var date = new Date(0); + date.setSeconds( Math.floor(duration / 1000) ); // specify value for SECONDS here + return date.toISOString().substr(11, 8); + } + } + }; +} diff --git a/web/directives/plex-library.js b/web/directives/plex-library.js index b48153c..ec0c605 100644 --- a/web/directives/plex-library.js +++ b/web/directives/plex-library.js @@ -6,18 +6,30 @@ module.exports = function (plex, pseudotv, $timeout) { scope: { onFinish: "=onFinish", height: "=height", - visible: "=visible" + visible: "=visible", + limit: "@limit", }, link: function (scope, element, attrs) { + if ( typeof(scope.limit) == 'undefined') { + scope.limit = 1000000000; + } scope.selection = [] scope.selectServer = function (server) { scope.plexServer = server updateLibrary(server) } scope._onFinish = (s) => { - scope.onFinish(s) - scope.selection = [] - scope.visible = false + if (s.length > scope.limit) { + if (scope.limit == 1) { + scope.error = "Please select only one clip."; + } else { + scope.error = `Please select at most ${scope.limit} clips.`; + } + } else { + scope.onFinish(s) + scope.selection = [] + scope.visible = false + } } scope.selectItem = (item) => { return new Promise((resolve, reject) => { diff --git a/web/public/templates/channel-config.html b/web/public/templates/channel-config.html index 0944ba6..7f32997 100644 --- a/web/public/templates/channel-config.html +++ b/web/public/templates/channel-config.html @@ -84,7 +84,7 @@ @@ -95,7 +95,52 @@
-

"Block Shuffle" and "Sort TV Shows" will push any movies to the end of the channel.

+

+ Tools to modify the schedule. + + {{ showHelp ? "Hide Help" : "Help" }} + +

+
+
Block Shuffle
+

Alternates TV shows in blocks of episodes. You can pick the number of episodes per show in each block and if the order of shows in each block should be randomized. Movies are moved to the bottom.

+ +
Random Shuffle
+

Completely randomizes the order of programs.

+ +
Cyclic Shuffle
+

Like Random Shuffle, but tries to preserve the sequence of episodes for each TV shows. If a TV show has multiple instances of its episodes, they are also cycled appropriately.

+ +
Sort TV Shows
+

Sorts the list by TV Show and the episodes in each TV show by their season/episode number. + Movies are moved to the bottom of the schedule. +

+ +
Sort Release Dates
+

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.

+
Remove Duplicates
+

Removes repeated videos.

+ +
Equalize Shows
+

Attempts to make the amount of time dedicated to each TV show as equal as possible without breaking sequences. Episodes might be replicated multiple times. Movies and specials are ignored.

+ + +
Add Flex
+

Adds a "Flex" Time Slot. Flex Time periods don't appear in the TV guide and can be configured to play a fallback screen and/or random "filler" content (e.g "commercials", trailers, prerolls, countdowns, music videos, channel bumpers, etc.)

+ +
Remove Flex
+

Removes any Flex periods from the schedule.

+ +
Pad Times
+

Adds Flex breaks after each TV episode or movie to ensure that the program starts at one of the allowed minute marks. For example, you can use this to ensure that all your programs start at either XX:00 times or XX:30 times. Removes any existing Flex periods before adding the new ones.

+ +
Restrict Hours
+

The channel's regular programming between the specified hours. Flex time will fill up the remaining hours.

+ +
Add Breaks
+

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

+
+
@@ -123,15 +168,79 @@
- +
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+
+
+
+ + +
+ +
+
+
+ + +
Add programs to this channel by selecting media from your Plex library

-
No programs are currently scheduled
+
No programs are currently scheduled + +
+
  • @@ -139,12 +248,15 @@
    {{x.start.toLocaleString()}}
    {{x.stop.toLocaleString()}}
  • -
    - {{x.commercials.length}} +
    + {{x.isOffline? channel.fillerContent.length: x.commercials.length}}
    -
    +
    {{ x.type === 'episode' ? x.showTitle + ' - S' + x.season.toString().padStart(2, '0') + 'E' + x.episode.toString().padStart(2, '0') : x.title}}
    +
    + Flex +
    @@ -164,5 +276,7 @@
    + + \ No newline at end of file diff --git a/web/public/templates/offline-config.html b/web/public/templates/offline-config.html new file mode 100644 index 0000000..8b96a87 --- /dev/null +++ b/web/public/templates/offline-config.html @@ -0,0 +1,134 @@ +
    + + + +
    \ No newline at end of file diff --git a/web/public/templates/plex-library.html b/web/public/templates/plex-library.html index adb81bf..2a47d17 100644 --- a/web/public/templates/plex-library.html +++ b/web/public/templates/plex-library.html @@ -112,6 +112,7 @@ +
    {{error}}
    -
    -

    If stream changes video codec, audio codec, or audio channels upon episode change, you will experience playback issues.

    -
    +
    +
    +

    If stream changes video codec, audio codec, or audio channels upon episode change, you will experience playback issues unless ffmpeg transcoding and normalization are also enabled.

    +
    +
    + +
    +
    Video Options