diff --git a/index.js b/index.js index 61a63a4..fd9d6e4 100644 --- a/index.js +++ b/index.js @@ -31,7 +31,7 @@ if (!fs.existsSync(process.env.DATABASE)) if(!fs.existsSync(path.join(process.env.DATABASE, 'images'))) fs.mkdirSync(path.join(process.env.DATABASE, 'images')) -db.connect(process.env.DATABASE, ['channels', 'plex-servers', 'ffmpeg-settings', 'xmltv-settings', 'hdhr-settings']) +db.connect(process.env.DATABASE, ['channels', 'plex-servers', 'ffmpeg-settings', 'plex-settings', 'xmltv-settings', 'hdhr-settings']) initDB(db) @@ -94,6 +94,7 @@ app.listen(process.env.PORT, () => { function initDB(db) { let ffmpegSettings = db['ffmpeg-settings'].find() + let plexSettings = db['plex-settings'].find() if (!fs.existsSync(process.env.DATABASE + '/font.ttf')) { let data = fs.readFileSync(path.resolve(path.join(__dirname, 'resources/font.ttf'))) fs.writeFileSync(process.env.DATABASE + '/font.ttf', data) @@ -106,63 +107,33 @@ function initDB(db) { if (ffmpegSettings.length === 0) { db['ffmpeg-settings'].save({ ffmpegPath: '/usr/bin/ffmpeg', - videoStreamMode: 'transcodeVideo', - audioStreamMode: 'transcodeAudio', - reduceAudioTranscodes: true, - offset: 0, + enableChannelOverlay: false, threads: 4, - videoEncoder: 'libx264', - videoResolution: '1280x720', - videoFrameRate: 30, + videoEncoder: 'mpeg2video', + videoResolutionHeight: 'unchanged', videoBitrate: 10000, - videoBufSize: 1000, - audioBitrate: 192, - audioChannels: 2, - audioRate: 48000, - audioEncoder: 'ac3', - oneChAudioBitrate: 156, - oneChAudioRate: 48000, - oneChAudioEncoder: 'ac3', - twoChAudioBitrate: 192, - twoChAudioRate: 48000, - twoChAudioEncoder: 'ac3', - fivePointOneChAudioBitrate: 336, - fivePointOneChAudioRate: 48000, - fivePointOneChAudioEncoder: 'ac3', - sixPointOneChAudioBitrate: 350, - sixPointOneChAudioRate: 48000, - sixPointOneChAudioEncoder: 'ac3', - transcodeSixPointOneAudioToFivePointOne: false, - logFfmpeg: false, - args: `-threads 4 --ss STARTTIME --re --i INPUTFILE --t DURATION --map VIDEOSTREAM --map AUDIOSTREAM --c:v libx264 --c:a ac3 --ac 2 --ar 48000 --b:a 192k --b:v 10000k --s 1280x720 --r 30 --flags cgop+ilme --sc_threshold 1000000000 --minrate:v 10000k --maxrate:v 10000k --bufsize:v 1000k --metadata service_provider="PseudoTV" --metadata CHANNELNAME --f mpegts --output_ts_offset TSOFFSET --muxdelay 0 --muxpreload 0 -OUTPUTFILE` + videoBufSize: 2000, + enableAutoPlay: true, + breakStreamOnCodecChange: true, + logFfmpeg: true }) } + + if (plexSettings.length === 0) { + db['plex-settings'].save({ + directStreamBitrate: '40000', + transcodeBitrate: '3000', + maxPlayableResolution: "1920x1080", + maxTranscodeResolution: "1920x1080", + enableHEVC: true, + audioCodecs: 'ac3,aac,mp3', + maxAudioChannels: '6', + enableSubtitles: false, + subtitleSize: '100', + updatePlayStatus: false + }) + } + let xmltvSettings = db['xmltv-settings'].find() if (xmltvSettings.length === 0) { db['xmltv-settings'].save({ diff --git a/package.json b/package.json index 10ecba7..478520d 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "body-parser": "^1.19.0", "diskdb": "^0.1.17", "express": "^4.17.1", + "uuid": "^8.0.0", + "node-fetch": "^2.6.0", "node-ssdp": "^4.0.0", "request": "^2.88.2", "xml-writer": "^1.7.0" diff --git a/src/api.js b/src/api.js index ce5f76b..230318b 100644 --- a/src/api.js +++ b/src/api.js @@ -64,65 +64,47 @@ function api(db, xmltvInterval) { router.post('/api/ffmpeg-settings', (req, res) => { // RESET db['ffmpeg-settings'].update({ _id: req.body._id }, { ffmpegPath: req.body.ffmpegPath, - videoStreamMode: 'transcodeVideo', - audioStreamMode: 'transcodeAudio', - reduceAudioTranscodes: true, - offset: 0, + enableChannelOverlay: false, threads: 4, - videoEncoder: 'libx264', - videoResolution: '1280x720', - videoFrameRate: 30, + videoEncoder: 'mpeg2video', + videoResolutionHeight: 'unchanged', videoBitrate: 10000, - videoBufSize: 1000, - audioBitrate: 192, - audioChannels: 2, - audioRate: 48000, - audioEncoder: 'ac3', - oneChAudioBitrate: 156, - oneChAudioRate: 48000, - oneChAudioEncoder: 'ac3', - twoChAudioBitrate: 192, - twoChAudioRate: 48000, - twoChAudioEncoder: 'ac3', - fivePointOneChAudioBitrate: 336, - fivePointOneChAudioRate: 48000, - fivePointOneChAudioEncoder: 'ac3', - sixPointOneChAudioBitrate: 350, - sixPointOneChAudioRate: 48000, - sixPointOneChAudioEncoder: 'ac3', - transcodeSixPointOneAudioToFivePointOne: false, - logFfmpeg: false, - args: `-threads 4 --ss STARTTIME --re --i INPUTFILE --t DURATION --map VIDEOSTREAM --map AUDIOSTREAM --c:v libx264 --c:a ac3 --ac 2 --ar 48000 --b:a 192k --b:v 10000k --s 1280x720 --r 30 --flags cgop+ilme --sc_threshold 1000000000 --minrate:v 10000k --maxrate:v 10000k --bufsize:v 1000k --metadata service_provider="PseudoTV" --metadata CHANNELNAME --f mpegts --output_ts_offset TSOFFSET --muxdelay 0 --muxpreload 0 -OUTPUTFILE` + videoBufSize: 2000, + enableAutoPlay: true, + breakStreamOnCodecChange: true, + logFfmpeg: true }) let ffmpeg = db['ffmpeg-settings'].find()[0] res.send(ffmpeg) }) + + // PLEX SETTINGS + router.get('/api/plex-settings', (req, res) => { + let plex = db['plex-settings'].find()[0] + res.send(plex) + }) + router.put('/api/plex-settings', (req, res) => { + db['plex-settings'].update({ _id: req.body._id }, req.body) + let plex = db['plex-settings'].find()[0] + res.send(plex) + }) + router.post('/api/plex-settings', (req, res) => { // RESET + db['plex-settings'].update({ _id: req.body._id }, { + directStreamBitrate: '40000', + transcodeBitrate: '3000', + maxPlayableResolution: "1920x1080", + maxTranscodeResolution: "1920x1080", + enableHEVC: true, + audioCodecs: 'ac3,aac,mp3', + maxAudioChannels: '6', + enableSubtitles: false, + subtitleSize: '100', + updatePlayStatus: false + }) + let plex = db['plex-settings'].find()[0] + res.send(plex) + }) + router.get('/api/xmltv-last-refresh', (req, res) => { res.send(JSON.stringify({ value: xmltvInterval.lastUpdated.valueOf() })) }) @@ -201,4 +183,4 @@ OUTPUTFILE` } return router -} \ No newline at end of file +} diff --git a/src/ffmpeg.js b/src/ffmpeg.js index 165b27e..12fc48f 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -5,188 +5,64 @@ const fs = require('fs') class FFMPEG extends events.EventEmitter { constructor(opts, channel) { super() - this.offset = 0 - this.args = [] this.opts = opts this.channel = channel this.ffmpegPath = opts.ffmpegPath - let lines = opts.args.split('\n') - for (let i = 0, l = lines.length; i < l; i++) { - let x = lines[i].indexOf(' ') - if (x === -1) - this.args.push(lines[i]) - else { - this.args.push(lines[i].substring(0, x)) - this.args.push(lines[i].substring(x + 1, lines[i].length)) - } - } } - // This is used to generate ass subtitles from text subs to be used with the ass filter in ffmpeg. - createSubsFromStream(file, startTime, duration, streamIndex, output) { - if (process.env.DEBUG) console.log('Generating .ass subtitles') - let exe = spawn(this.ffmpegPath, [ - '-threads', this.opts.threads, - '-ss', startTime, - '-i', file, - '-t', duration, - '-map', `0:${streamIndex}`, - '-f', 'ass', - output - ]) - return new Promise((resolve, reject) => { - if (this.opts.logFfmpeg) { - exe.stderr.on('data', (chunk) => { - process.stderr.write(chunk) - }) - } - exe.on('close', (code) => { - if (code === 0) { - if (process.env.DEBUG) console.log('Successfully generated .ass subtitles') - resolve() - } else { - console.log('Failed generating .ass subtitles.') - reject() - } - }) - }) - } - async spawn(lineupItem) { - let videoIndex = lineupItem.opts.videoIndex - let audioIndex = lineupItem.opts.audioIndex - let subtitleIndex = lineupItem.opts.subtitleIndex - let uniqSubFileName = Date.now().valueOf().toString() + async spawn(streamUrl, duration, enableIcon, videoResolution) { + let ffmpegArgs = [`-threads`, this.opts.threads]; - for (let i = 0, l = lineupItem.streams.length; i < l; i++) { - if (videoIndex === '-1' && lineupItem.streams[i].streamType === 1) - if (lineupItem.streams[i].default) - videoIndex = i - if (audioIndex === '-1' && lineupItem.streams[i].streamType === 2) - if (lineupItem.streams[i].default || lineupItem.streams[i].selected) - audioIndex = i - if (subtitleIndex === '-1' && lineupItem.streams[i].streamType === 3) - if (lineupItem.streams[i].default || lineupItem.streams[i].forced) - subtitleIndex = i - } + if (duration > 0) + ffmpegArgs.push(`-t`, duration); - // if for some reason we didn't find a default track, let ffmpeg decide.. - if (videoIndex === '-1') - videoIndex = 'v' - if (audioIndex === '-1') - audioIndex = 'a' + ffmpegArgs.push(`-re`, `-i`, streamUrl); - let sub = (subtitleIndex === '-1' || subtitleIndex === '-2') ? null : lineupItem.streams[subtitleIndex] + if (enableIcon == true) { + if (process.env.DEBUG) console.log('Channel Icon Overlay Enabled') - let tmpargs = JSON.parse(JSON.stringify(this.args)) - let startTime = tmpargs.indexOf('STARTTIME') - let dur = tmpargs.indexOf('DURATION') - let input = tmpargs.indexOf('INPUTFILE') - let vidStream = tmpargs.indexOf('VIDEOSTREAM') - let output = tmpargs.indexOf('OUTPUTFILE') - let tsoffset = tmpargs.indexOf('TSOFFSET') - let audStream = tmpargs.indexOf('AUDIOSTREAM') - let chanName = tmpargs.indexOf('CHANNELNAME') - - tmpargs[startTime] = lineupItem.start / 1000 - tmpargs[dur] = lineupItem.duration / 1000 - tmpargs[input] = lineupItem.file - tmpargs[audStream] = `0:${audioIndex}` - tmpargs[chanName] = `service_name="${this.channel.name}"` - tmpargs[tsoffset] = this.offset - tmpargs[output] = 'pipe:1' - - if (this.opts.videoStreamMode == "transcodeVideo") { - let iconOverlay = `[0:${videoIndex}]null` - let deinterlace = 'null' let posAry = [ '20:20', 'W-w-20:20', '20:H-h-20', 'W-w-20:H-h-20'] // top-left, top-right, bottom-left, bottom-right (with 20px padding) let icnDur = '' + if (this.channel.iconDuration > 0) icnDur = `:enable='between(t,0,${this.channel.iconDuration})'` - if (this.channel.icon !== '' && this.channel.overlayIcon && lineupItem.type === 'program') { - iconOverlay = `[1:v]scale=${this.channel.iconWidth}:-1[icn];[0:${videoIndex}][icn]overlay=${posAry[this.channel.iconPosition]}${icnDur}` - if (process.env.DEBUG) console.log('Channel Icon Overlay Enabled') + + let iconOverlay = `[1:v]scale=${this.channel.iconWidth}:-1[icn];[0:v][icn]overlay=${posAry[this.channel.iconPosition]}${icnDur}[outv]` + // Only scale video if specified, don't upscale video + if (this.opts.videoResolutionHeight != "unchanged" && parseInt(this.opts.videoResolutionHeight, 10) < parseInt(videoResolution, 10)) { + iconOverlay = `[0:v]scale=-2:${this.opts.videoResolutionHeight}[scaled];[1:v]scale=${this.channel.iconWidth}:-1[icn];[scaled][icn]overlay=${posAry[this.channel.iconPosition]}${icnDur}[outv]` } - if (videoIndex !== 'v') { - if (typeof lineupItem.streams[videoIndex].scanType === 'undefined' || lineupItem.streams[videoIndex].scanType !== 'progressive') { - deinterlace = 'yadif' - if (process.env.DEBUG) console.log('Deinterlacing Video') - } - } - - if (sub === null || lineupItem.type === 'commercial') { // No subs or icon overlays for Commercials - tmpargs[vidStream] = '[v]' - tmpargs.splice(vidStream - 1, 0, "-filter_complex", `${iconOverlay}[v1];[v1]${deinterlace}[v]`) - console.log("No Subtitles") - } else if (sub.codec === 'pgs') { // If program has PGS subs - tmpargs[vidStream] = '[v]' - if (typeof sub.index === 'undefined') { // If external subs - console.log("PGS SUBS (external) - Not implemented..") - tmpargs.splice(vidStream - 1, 0, "-filter_complex", `${iconOverlay}[v1];[v1]${deinterlace}[v]`) - } else { // Otherwise, internal/embeded pgs subs - console.log("PGS SUBS (embeded)") - tmpargs.splice(vidStream - 1, 0, "-filter_complex", `${iconOverlay}[v2];[v2]${deinterlace}[v1];[v1][0:${sub.index}]overlay[v]`) - } - } else if (sub.codec === 'srt' || sub.codec === 'ass') { - tmpargs[vidStream] = '[v]' - if (typeof sub.index === 'undefined') { - console.log("SRT SUBS (external)") - await this.createSubsFromStream(sub.key, lineupItem.start / 1000, lineupItem.duration / 1000, 0, `${process.env.DATABASE}/${uniqSubFileName}.ass`) - tmpargs.splice(vidStream - 1, 0, "-filter_complex", `${iconOverlay}[v1];[v1]${deinterlace},ass=${process.env.DATABASE}/${uniqSubFileName}.ass[v]`) - } else { - console.log("SRT SUBS (embeded) - This may take a few seconds..") - await this.createSubsFromStream(lineupItem.file, lineupItem.start / 1000, lineupItem.duration / 1000, sub.index, `${process.env.DATABASE}/${uniqSubFileName}.ass`) - tmpargs.splice(vidStream - 1, 0, "-filter_complex", `${iconOverlay}[v1];[v1]${deinterlace},ass=${process.env.DATABASE}/${uniqSubFileName}.ass[v]`) - } - } else { // Can't do VobSub's as plex only hosts the .idx file, there is no access to the .sub file.. Who the fuck uses VobSubs anyways.. SRT/ASS FTW - tmpargs[vidStream] = '[v]' - tmpargs.splice(vidStream - 1, 0, "-filter_complex", `${iconOverlay}[v1];[v1]${deinterlace}[v]`) - console.log("No Compatible Subtitles") - } - - if (this.channel.icon !== '' && this.channel.overlayIcon && lineupItem.type === 'program') // Add the channel icon to ffmpeg input if enabled - tmpargs.splice(vidStream - 1, 0, '-i', this.channel.icon) + ffmpegArgs.push(`-i`, `${this.channel.icon}`, + `-filter_complex`, iconOverlay, + `-map`, `[outv]`, + `-c:v`, this.opts.videoEncoder, + `-flags`, `cgop+ilme`, + `-sc_threshold`, `1000000000`, + `-b:v`, `${this.opts.videoBitrate}k`, + `-minrate:v`, `${this.opts.videoBitrate}k`, + `-maxrate:v`, `${this.opts.videoBitrate}k`, + `-bufsize:v`, `${this.opts.videoBufSize}k`, + `-map`, `0:a`, + `-c:a`, `copy`); + } else { + ffmpegArgs.push(`-c`, `copy`); } - if (this.opts.audioStreamMode === "transcodeAudioBestMatch") { - let audChannels = lineupItem.streams[audioIndex].channels - let audCodec = lineupItem.streams[audioIndex].codec - let audSettingsIndex = tmpargs.indexOf('AUDIOBESTMATCHSETTINGS') + ffmpegArgs.push(`-metadata`, + `service_provider="PseudoTV"`, + `-metadata`, + `service_name="${this.channel.name}"`, + `-f`, + `mpegts`, + `-output_ts_offset`, + `0`, + `-muxdelay`, + `0`, + `-muxpreload`, + `0`, + `pipe:1`); - console.log("Transcoding audio by best match") - console.log(" Audio Channels: " + audChannels) - console.log(" Audio Codec: " + audCodec) - - // don't transcode audio if audio format is known to work - if (this.opts.reduceAudioTranscodes && (audCodec === "aac" || audCodec === "ac3") && audChannels <= 6) { - tmpargs.splice(audSettingsIndex, 1, "copy") - console.log(" Decision: Copied existing audio") - } else if (audChannels === 7 && this.opts.transcodeSixPointOneAudioToFivePointOne === false) { - if ((audCodec === "aac" || audCodec === "ac3") && this.opts.reduceAudioTranscodes) { - tmpargs.splice(audSettingsIndex, 1, "copy") - console.log(" Decision: Copied existing audio") - } else { - tmpargs.splice(audSettingsIndex, 1, this.opts.sixPointOneChAudioEncoder, "-ac", "7", "-ar", this.opts.sixPointOneChAudioRate, "-b:a", this.opts.sixPointOneChAudioBitrate + "k") - console.log(" Decision: Transcoding audio to 6.1 - " + this.opts.sixPointOneChAudioEncoder) - } - } else if (audChannels >= 6) { - if ((audCodec === "aac" || audCodec === "ac3") && this.opts.reduceAudioTranscodes) { - tmpargs.splice(audSettingsIndex, 1, audCodec, "-ac", "6") - console.log(" Decision: Transcoding audio to 5.1 - " + audCodec) - } else { - tmpargs.splice(audSettingsIndex, 1, this.opts.fivePointOneChAudioEncoder, "-ac", "6", "-ar", this.opts.fivePointOneChAudioRate, "-b:a", this.opts.fivePointOneChAudioBitrate + "k") - console.log(" Decision: Transcoding audio to 5.1 - " + this.opts.fivePointOneChAudioEncoder) - } - } else if (audChannels === 1) { - tmpargs.splice(audSettingsIndex, 1, this.opts.oneChAudioEncoder, "-ac", "1", "-ar", this.opts.oneChAudioRate, "-b:a", this.opts.oneChAudioBitrate + "k") - console.log(" Decision: Transcoding audio to 1.0 - " + this.opts.onePointOneChAudioEncoder) - } else { // Last transcoding scenario, just go to 2 channel audio, boost center channel if specified - tmpargs.splice(audSettingsIndex, 1, this.opts.twoChAudioEncoder, "-ac", "2", "-ar", this.opts.twoChAudioRate, "-b:a", this.opts.twoChAudioBitrate + "k") - console.log(" Decision: Transcoding audio to 2.0 - " + this.opts.twoChAudioEncoder) - } - } - - this.offset += lineupItem.duration / 1000 - this.ffmpeg = spawn(this.ffmpegPath, tmpargs) + this.ffmpeg = spawn(this.ffmpegPath, ffmpegArgs) this.ffmpeg.stdout.on('data', (chunk) => { this.emit('data', chunk) }) @@ -196,8 +72,6 @@ class FFMPEG extends events.EventEmitter { }) } this.ffmpeg.on('close', (code) => { - if (fs.existsSync(`${process.env.DATABASE}/${uniqSubFileName}.ass`)) - fs.unlinkSync(`${process.env.DATABASE}/${uniqSubFileName}.ass`) if (code === null) this.emit('close', code) else if (code === 0) @@ -205,7 +79,7 @@ class FFMPEG extends events.EventEmitter { else if (code === 255) this.emit('close', code) else - this.emit('error', { code: code, cmd: `${this.opts.ffmpegPath} ${tmpargs.join(' ')}` }) + this.emit('error', { code: code, cmd: `${this.opts.ffmpegPath} ${ffmpegArgs.join(' ')}` }) }) } kill() { diff --git a/src/helperFuncs.js b/src/helperFuncs.js index 7919366..c1b39d5 100644 --- a/src/helperFuncs.js +++ b/src/helperFuncs.js @@ -1,6 +1,7 @@ module.exports = { getCurrentProgramAndTimeElapsed: getCurrentProgramAndTimeElapsed, - createLineup: createLineup + createLineup: createLineup, + isChannelIconEnabled: isChannelIconEnabled } function getCurrentProgramAndTimeElapsed(date, channel) { @@ -25,6 +26,12 @@ function getCurrentProgramAndTimeElapsed(date, channel) { function createLineup(obj) { 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 + 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] @@ -40,23 +47,25 @@ function createLineup(obj) { foundFirstVideo = true // We found the fucker lineup.push({ type: 'commercial', - file: commercials[i][y].file, - streams: commercials[i][y].streams, + key: commercials[i][y].key, + ratingKey: commercials[i][y].ratingKey, start: timeElapsed, // start time will be the time elapsed, cause this is the first video - duration: commercials[i][y].duration - timeElapsed, // duration set accordingly - opts: commercials[i][y].opts + streamDuration: commercials[i][y].duration - timeElapsed, // stream duration set accordingly + duration: commercials[i][y].duration, + server: commercials[i][y].server }) } 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', - file: commercials[i][y].file, - streams: commercials[i][y].streams, + key: commercials[i][y].key, + ratingKey: commercials[i][y].ratingKey, start: 0, - duration: commercials[i][y].duration, - opts: commercials[i][y].opts + streamDuration: commercials[i][y].actualDuration, + duration: commercials[i][y].actualDuration, + server: commercials[i][y].server }) } else { // Otherwise, this bitch has already been played.. Reduce the time elapsed by its duration - timeElapsed -= commercials[i][y].duration + timeElapsed -= commercials[i][y].actualDuration } } if (i < l - 1) { // The last commercial slot is END, so dont write a program.. @@ -64,23 +73,25 @@ function createLineup(obj) { foundFirstVideo = true lineup.push({ type: 'program', - file: activeProgram.file, - streams: activeProgram.streams, + key: activeProgram.key, + ratingKey: activeProgram.ratingKey, start: progTimeElapsed + timeElapsed, // add the duration of already played program chunks to the timeElapsed - duration: (programStartTimes[i + 1] - programStartTimes[i]) - timeElapsed, - opts: activeProgram.opts + streamDuration: (programStartTimes[i + 1] - programStartTimes[i]) - timeElapsed, + duration: activeProgram.actualDuration, + server: activeProgram.server }) } else if (foundFirstVideo) { if (lineup[lineup.length - 1].type === 'program') { // merge consecutive programs.. - lineup[lineup.length - 1].duration += (programStartTimes[i + 1] - programStartTimes[i]) + lineup[lineup.length - 1].streamDuration += (programStartTimes[i + 1] - programStartTimes[i]) } else { lineup.push({ type: 'program', - file: activeProgram.file, - streams: activeProgram.streams, + key: activeProgram.key, + ratingKey: activeProgram.ratingKey, start: programStartTimes[i], - duration: (programStartTimes[i + 1] - programStartTimes[i]), - opts: activeProgram.opts + streamDuration: (programStartTimes[i + 1] - programStartTimes[i]), + duration: activeProgram.actualDuration, + server: activeProgram.server }) } } else { @@ -90,4 +101,8 @@ function createLineup(obj) { } } return lineup +} + +function isChannelIconEnabled(enableChannelOverlay, icon, overlayIcon, type) { + return enableChannelOverlay == true && icon !== '' && overlayIcon && type === 'program' } \ No newline at end of file diff --git a/src/plexTranscoder.js b/src/plexTranscoder.js new file mode 100644 index 0000000..83615cf --- /dev/null +++ b/src/plexTranscoder.js @@ -0,0 +1,211 @@ +const { v4: uuidv4 } = require('uuid'); +const fetch = require('node-fetch'); + +class PlexTranscoder { + constructor(settings, lineupItem) { + this.session = uuidv4() + + this.settings = settings + + this.key = lineupItem.key + this.ratingKey = lineupItem.ratingKey + this.currTimeMs = lineupItem.start + this.currTimeS = this.currTimeMs / 1000 + this.duration = lineupItem.duration + this.server = lineupItem.server + + this.transcodingArgs = undefined + this.decisionJson = undefined + + this.updateInterval = 30000 + this.updatingPlex = undefined + this.playState = "stopped" + } + + async getStreamUrl(deinterlace) { + // Set transcoding parameters based off direct stream params + this.setTranscodingArgs(true, deinterlace) + + await this.getDecision(); + const videoIsDirectStream = this.isVideoDirectStream(); + + // Change transcoding arguments to be the user chosen transcode parameters + if (videoIsDirectStream == false) { + this.setTranscodingArgs(false, deinterlace) + // Update transcode decision for session + await this.getDecision(); + } + + return `${this.server.protocol}://${this.server.host}:${this.server.port}/video/:/transcode/universal/start.m3u8?${this.transcodingArgs}` + } + + setTranscodingArgs(directStream, deinterlace) { + let resolution = (directStream == true) ? this.settings.maxPlayableResolution : this.settings.maxTranscodeResolution + let bitrate = (directStream == true) ? this.settings.directStreamBitrate : this.settings.transcodeBitrate + let videoCodecs = (this.settings.enableHEVC == true) ? "h264,hevc" : "h264" + let subtitles = (this.settings.enableSubtitles == true) ? "burn" : "none" // subtitle options: burn, none, embedded, sidecar + + let videoQuality=`100` // Not sure how this applies, maybe this works if maxVideoBitrate is not set + let audioBoost=`100` // only applies when downmixing to stereo I believe, add option later? + let mediaBufferSize=`30720` // Not sure what this should be set to + let profileName=`Generic` // Blank profile, everything is specified through X-Plex-Client-Profile-Extra + + let resolutionArr = resolution.split("x") + + let clientProfileHLS=`add-transcode-target(type=videoProfile&protocol=hls&container=mpegts&videoCodec=${videoCodecs}&audioCodec=${this.settings.audioCodecs}&subtitleCodec=&context=streaming&replace=true)+\ +add-transcode-target-settings(type=videoProfile&context=streaming&protocol=hls&CopyMatroskaAttachments=true)+\ +add-limitation(scope=videoCodec&scopeName=*&type=upperBound&name=video.width&value=${resolutionArr[0]})+\ +add-limitation(scope=videoCodec&scopeName=*&type=upperBound&name=video.height&value=${resolutionArr[1]})` + + // Set transcode settings per audio codec + this.settings.audioCodecs.split(",").forEach(function (codec) { + clientProfileHLS+=`+add-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=hls&audioCodec=${codec})` + if (codec == "mp3") { + clientProfileHLS+=`+add-limitation(scope=videoAudioCodec&scopeName=${codec}type=upperBound&name=audio.channels&value=2)` + } else { + clientProfileHLS+=`+add-limitation(scope=videoAudioCodec&scopeName=${codec}type=upperBound&name=audio.channels&value=${this.settings.maxAudioChannels})` + } + }.bind(this)); + + // deinterlace video if specified, only useful if overlaying channel logo later + if (deinterlace == true) { + clientProfileHLS+=`+add-limitation(scope=videoCodec&scopeName=*&type=notMatch&name=video.scanType&value=interlaced)` + } + + let clientProfileHLS_enc=encodeURIComponent(clientProfileHLS) + this.transcodingArgs=`X-Plex-Platform=${profileName}&\ +X-Plex-Client-Platform=${profileName}&\ +X-Plex-Client-Profile-Name=${profileName}&\ +X-Plex-Platform=${profileName}&\ +X-Plex-Token=${this.server.token}&\ +X-Plex-Client-Profile-Extra=${clientProfileHLS_enc}&\ +protocol=hls&\ +Connection=keep-alive&\ +hasMDE=1&\ +path=${this.key}&\ +mediaIndex=0&\ +partIndex=0&\ +fastSeek=1&\ +directPlay=0&\ +directStream=1&\ +directStreamAudio=1&\ +copyts=1&\ +audioBoost=${audioBoost}&\ +mediaBufferSize=${mediaBufferSize}&\ +session=${this.session}&\ +offset=${this.currTimeS}&\ +subtitles=${subtitles}&\ +subtitleSize=${this.settings.subtitleSize}&\ +maxVideoBitrate=${bitrate}&\ +videoQuality=${videoQuality}&\ +videoResolution=${resolution}&\ +lang=en` + } + + isVideoDirectStream() { + return this.decisionJson["MediaContainer"]["Metadata"][0]["Media"][0]["Part"][0]["Stream"][0]["decision"] == "copy"; + } + + getResolutionHeight() { + return this.decisionJson["MediaContainer"]["Metadata"][0]["Media"][0]["Part"][0]["Stream"][0]["height"]; + } + + getVideoStats(channelIconEnabled, ffmpegEncoderName) { + let ret = [] + let streams = this.decisionJson["MediaContainer"]["Metadata"][0]["Media"][0]["Part"][0]["Stream"] + + streams.forEach(function (stream) { + // Video + if (stream["streamType"] == "1") { + ret.push(stream["width"], + stream["height"], + Math.round(stream["frameRate"])) + // Rounding framerate avoids scenarios where + // 29.9999999 & 30 don't match. Probably close enough + // to continue the stream as is. + + // Implies future transcoding + if (channelIconEnabled == true) + if (ffmpegEncoderName.includes('mpeg2')) + ret.push("mpeg2video") + else if (ffmpegEncoderName.includes("264")) + ret.push("h264") + else if (ffmpegEncoderName.includes("hevc") || ffmpegEncoderName.includes("265")) + ret.push("hevc") + else + ret.push("unknown") + else + ret.push(stream["codec"]) + } + // Audio. Only look at stream being used + if (stream["streamType"] == "2" && stream["selected"] == "1") + ret.push(stream["channels"], stream["codec"]) + }) + + return ret + } + + async getDecision() { + const response = await fetch(`${this.server.protocol}://${this.server.host}:${this.server.port}/video/:/transcode/universal/decision?${this.transcodingArgs}`, { + method: 'GET', headers: { + Accept: 'application/json' + } + }); + this.decisionJson = await response.json(); + } + + getStatusUrl() { + let profileName=`Generic`; + + let containerKey=`/video/:/transcode/universal/decision?${this.transcodingArgs}`; + let containerKey_enc=encodeURIComponent(containerKey); + + let statusUrl=`${this.server.protocol}://${this.server.host}:${this.server.port}/:/timeline?\ +containerKey=${containerKey_enc}&\ +ratingKey=${this.ratingKey}&\ +state=${this.playState}&\ +key=${this.key}&\ +time=${this.currTimeMs}&\ +duration=${this.duration}&\ +X-Plex-Platform=${profileName}&\ +X-Plex-Client-Platform=${profileName}&\ +X-Plex-Client-Profile-Name=${profileName}&\ +X-Plex-Device-Name=PseudoTV-Plex&\ +X-Plex-Device=PseudoTV-Plex&\ +X-Plex-Client-Identifier=${this.session}&\ +X-Plex-Platform=${profileName}&\ +X-Plex-Token=${this.server.token}`; + + return statusUrl; + } + + startUpdatingPlex() { + if (this.settings.updatePlayStatus == true) { + this.playState = "playing"; + this.updatePlex(); // do initial update + this.updatingPlex = setInterval(this.updatePlex.bind(this), this.updateInterval); + } + } + + stopUpdatingPlex() { + if (this.settings.updatePlayStatus == true) { + clearInterval(this.updatingPlex); + this.playState = "stopped"; + this.updatePlex(); + } + } + + updatePlex() { + let postUrl = this.getStatusUrl(); + fetch(postUrl, { + method: 'POST' + }); + this.currTimeMs += this.updateInterval; + if (this.currTimeMs > this.duration) { + this.currTimeMs = this.duration; + } + this.currTimeS = this.duration / 1000; + } +} + +module.exports = PlexTranscoder diff --git a/src/video.js b/src/video.js index c92fde5..d0a6bd0 100644 --- a/src/video.js +++ b/src/video.js @@ -2,6 +2,7 @@ const express = require('express') const helperFuncs = require('./helperFuncs') const FFMPEG = require('./ffmpeg') const FFMPEG_TEXT = require('./ffmpegText') +const PlexTranscoder = require('./plexTranscoder') const fs = require('fs') module.exports = { router: video } @@ -55,7 +56,9 @@ function video(db) { // 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 ffmpegSettings = db['ffmpeg-settings'].find()[0] + let plexSettings = db['plex-settings'].find()[0] // Check if ffmpeg path is valid if (!fs.existsSync(ffmpegSettings.ffmpegPath)) { @@ -66,35 +69,84 @@ function video(db) { console.log(`\r\nStream starting. Channel: ${channel.number} (${channel.name})`) - let ffmpeg = new FFMPEG(ffmpegSettings, channel) // Set the transcoder options + let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options + let plexTranscoder = new PlexTranscoder(plexSettings, lineupItem); + + let enableChannelIcon = helperFuncs.isChannelIconEnabled(ffmpegSettings.enableChannelOverlay, channel.icon, channel.overlayIcon, lineupItem.type) + let deinterlace = enableChannelIcon // Tell plex to deinterlace video if channel overlay is enabled ffmpeg.on('data', (data) => { res.write(data) }) ffmpeg.on('error', (err) => { - console.error("FFMPEG ERROR", err) - res.status(500).send("FFMPEG ERROR") - return + plexTranscoder.stopUpdatingPlex(); + console.error("FFMPEG ERROR", err); + res.status(500).send("FFMPEG ERROR"); + return; }) ffmpeg.on('close', () => { - res.send() + res.send(); }) ffmpeg.on('end', () => { // On finish transcode - END of program or commercial... - if (lineup.length === 0) { // refresh the expired program/lineup - prog = helperFuncs.getCurrentProgramAndTimeElapsed(Date.now(), channel) - lineup = helperFuncs.createLineup(prog) + plexTranscoder.stopUpdatingPlex(); + if (ffmpegSettings.enableAutoPlay == true) { + oldVideoStats = plexTranscoder.getVideoStats(enableChannelIcon, ffmpegSettings.videoEncoder); + + if (lineup.length === 0) { // refresh the expired program/lineup + prog = helperFuncs.getCurrentProgramAndTimeElapsed(Date.now(), channel); + lineup = helperFuncs.createLineup(prog); + } + lineupItem = lineup.shift(); + + enableChannelIcon = helperFuncs.isChannelIconEnabled(ffmpegSettings.enableChannelOverlay, channel.icon, channel.overlayIcon, lineupItem.type) + deinterlace = enableChannelIcon + + 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 = -1 + + plexTranscoder = new PlexTranscoder(plexSettings, lineupItem); + + plexTranscoder.getStreamUrl(deinterlace).then( + function(streamUrl) { + newVideoStats = plexTranscoder.getVideoStats(enableChannelIcon); + // Start stream if stats are the same. Changing codecs mid stream is not good + if (ffmpegSettings.breakStreamOnCodecChange == false || oldVideoStats.length == newVideoStats.length + && oldVideoStats.every(function(u, i) { + return u === newVideoStats[i]; + }) + ) { + ffmpeg.spawn(streamUrl, streamDuration, enableChannelIcon, plexTranscoder.getResolutionHeight()); + plexTranscoder.startUpdatingPlex(); + } else { + console.log(`\r\nEnding Stream, video or audio format has changed. Channel: ${channel.number} (${channel.name})`); + console.log(` Old Stream: ${oldVideoStats}`); + console.log(` New Stream: ${newVideoStats}`); + ffmpeg.kill(); + } + }); + } else { + console.log(`\r\nEnding Stream, autoplay is disabled. Channel: ${channel.number} (${channel.name})`); + ffmpeg.kill(); } - ffmpeg.spawn(lineup.shift(), prog.program) // Spawn the next ffmpeg process }) res.on('close', () => { // on HTTP close, kill ffmpeg - ffmpeg.kill() - console.log(`\r\nStream ended. Channel: ${channel.number} (${channel.name})`) + plexTranscoder.stopUpdatingPlex(); + console.log(`\r\nStream ended. Channel: ${channel.number} (${channel.name})`); + ffmpeg.kill(); }) - ffmpeg.spawn(lineup.shift(), prog.program) // Spawn the ffmpeg process, fire this bitch up + 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 = -1 + + plexTranscoder.getStreamUrl(deinterlace).then(streamUrl => ffmpeg.spawn(streamUrl, streamDuration, enableChannelIcon, plexTranscoder.getResolutionHeight())); // Spawn the ffmpeg process, fire this bitch up + plexTranscoder.startUpdatingPlex(); }) return router } diff --git a/web/directives/ffmpeg-settings.js b/web/directives/ffmpeg-settings.js index e8d44f1..8ea4459 100644 --- a/web/directives/ffmpeg-settings.js +++ b/web/directives/ffmpeg-settings.js @@ -8,95 +8,33 @@ link: function (scope, element, attrs) { pseudotv.getFfmpegSettings().then((settings) => { scope.settings = settings - if (typeof scope.settings.args === 'undefined') - scope.createArgString() }) scope.updateSettings = (settings) => { pseudotv.updateFfmpegSettings(settings).then((_settings) => { scope.settings = _settings - if (typeof scope.settings.args === 'undefined') - scope.createArgString() }) } scope.resetSettings = (settings) => { pseudotv.resetFfmpegSettings(settings).then((_settings) => { scope.settings = _settings - if (typeof scope.settings.args === 'undefined') - scope.createArgString() }) } - scope.createArgString = () => { - if (scope.settings.videoStreamMode == 'transcodeVideo') { - scope.settings.videoArgs = `-c:v ${ scope.settings.videoEncoder } --b:v ${ scope.settings.videoBitrate }k --s ${ scope.settings.videoResolution } --r ${ scope.settings.videoFrameRate } --flags cgop+ilme --sc_threshold 1000000000 --minrate:v ${ scope.settings.videoBitrate }k --maxrate:v ${ scope.settings.videoBitrate }k --bufsize:v ${ scope.settings.videoBufSize }k --flags cgop+ilme --sc_threshold 1000000000 --minrate:v ${ scope.settings.videoBitrate }k --maxrate:v ${ scope.settings.videoBitrate }k --bufsize:v ${ scope.settings.videoBufSize }k` - } else { - scope.settings.videoArgs = `-c:v copy` - } - - if (scope.settings.audioStreamMode == 'transcodeAudio') { - scope.settings.audioArgs = `-c:a ${ scope.settings.audioEncoder } --ac ${ scope.settings.audioChannels } --ar ${ scope.settings.audioRate } --b:a ${ scope.settings.audioBitrate }k` - } else if (scope.settings.audioStreamMode == 'transcodeAudioBestMatch') { - scope.settings.audioArgs = `-c:a AUDIOBESTMATCHSETTINGS` - } else { - scope.settings.audioArgs = `-c:a copy` - } - - scope.settings.args = `-threads ${ scope.settings.threads } --ss STARTTIME --t DURATION --re --i INPUTFILE${ scope.settings.deinterlace ? `\n-vf yadif` : `` } --map VIDEOSTREAM --map AUDIOSTREAM -${scope.settings.videoArgs} -${scope.settings.audioArgs} --metadata service_provider="PseudoTV" --metadata CHANNELNAME --f mpegts --output_ts_offset TSOFFSET --muxdelay 0 --muxpreload 0 -OUTPUTFILE` - } - scope.videoStreamOptions=[ - {id:"transcodeVideo",description:"Transcode"}, - {id:"directStreamVideo",description:"Direct Stream"} + scope.hideIfNotEnableChannelOverlay = () => { + return scope.settings.enableChannelOverlay != true + }; + scope.hideIfNotAutoPlay = () => { + return scope.settings.enableAutoPlay != true + }; + scope.resolutionOptions=[ + {id:"420",description:"420x420"}, + {id:"320",description:"576x320"}, + {id:"480",description:"720x480"}, + {id:"768",description:"1024x768"}, + {id:"720",description:"1280x720"}, + {id:"1080",description:"1920x1080"}, + {id:"2160",description:"3840x2160"}, + {id:"unchanged",description:"Same as source"} ]; - scope.hideIfNotTranscodeVideo = () => { - return scope.settings.videoStreamMode != 'transcodeVideo' - }; - scope.hideIfNotDirectStreamVideo = () => { - return scope.settings.videoStreamMode != 'directStreamVideo' - }; - scope.audioStreamOptions=[ - {id:"transcodeAudio",description:"Transcode"}, - {id:"transcodeAudioBestMatch",description:"Transcode based on source channels"}, - {id:"directStreamAudio",description:"Direct Stream"} - ]; - scope.hideIfNotTranscodeAudio2ch = () => { - return scope.settings.audioStreamMode != 'transcodeAudio' - }; - scope.hideIfNotTranscodeAudioBestMatch = () => { - return scope.settings.audioStreamMode != 'transcodeAudioBestMatch' - }; - scope.hideIfNotDirectStreamAudio = () => { - return scope.settings.audioStreamMode != 'directStreamAudio' - }; } } } \ No newline at end of file diff --git a/web/directives/plex-settings.js b/web/directives/plex-settings.js index fcfde5a..f4c8fcf 100644 --- a/web/directives/plex-settings.js +++ b/web/directives/plex-settings.js @@ -56,6 +56,38 @@ module.exports = function (plex, pseudotv, $timeout) { scope.toggleVisiblity = function () { scope.visible = !scope.visible } + pseudotv.getPlexSettings().then((settings) => { + scope.settings = settings + }) + scope.updateSettings = (settings) => { + pseudotv.updatePlexSettings(settings).then((_settings) => { + scope.settings = _settings + }) + } + scope.resetSettings = (settings) => { + pseudotv.resetPlexSettings(settings).then((_settings) => { + scope.settings = _settings + }) + } + scope.maxAudioChannelsOptions=[ + {id:"1",description:"1.0"}, + {id:"2",description:"2.0"}, + {id:"3",description:"2.1"}, + {id:"4",description:"4.0"}, + {id:"5",description:"5.0"}, + {id:"6",description:"5.1"}, + {id:"7",description:"6.1"}, + {id:"8",description:"7.1"} + ]; + scope.resolutionOptions=[ + {id:"420x420",description:"420x420"}, + {id:"576x320",description:"576x320"}, + {id:"720x480",description:"720x480"}, + {id:"1024x768",description:"1024x768"}, + {id:"1280x720",description:"1280x720"}, + {id:"1920x1080",description:"1920x1080"}, + {id:"3840x2160",description:"3840x2160"} + ]; } }; } \ No newline at end of file diff --git a/web/directives/program-config.js b/web/directives/program-config.js index 5012dd0..1194c8e 100644 --- a/web/directives/program-config.js +++ b/web/directives/program-config.js @@ -45,4 +45,4 @@ module.exports = function ($timeout) { } } }; -} \ No newline at end of file +} diff --git a/web/public/templates/ffmpeg-settings.html b/web/public/templates/ffmpeg-settings.html index 7278b84..26c0a9a 100644 --- a/web/public/templates/ffmpeg-settings.html +++ b/web/public/templates/ffmpeg-settings.html @@ -15,7 +15,7 @@
- +

@@ -25,111 +25,45 @@
-
Video Stream Mode
- + + Note: This transcoding is done by PseudoTV, not Plex. +
+
+ + +
+
+ + + Clients typically cannot handle resolution, video/audio codec, framerate, or audio channels changing between programs/commercials +
-
-
Video Options
- - - - - - - - - - -
-
- Notice: Under no circumstances, will video transcode. This means subtitles and channel logos are not supported. +
+
+ + + Some possible values are: + Intel Quick Sync: h264_qsv, mpeg2_qsv + NVIDIA: GPU: h264_nvenc + MPEG2: mpeg2video (default) + H264: libx264 + MacOS: h264_videotoolbox +
+
+ + +
+
+ + +
-
-
-
-
Audio Stream Mode
- - - - - - - -
-
- Notice: If a audio codec is not supported by your player you may run into issues (no audio, video won't play, etc). -
-
- Notice: In this mode audio with more channels than 6.1 will be transcoded down to 5.1. -
-
-
-
-
- - - (5.1 channels or less & AAC/AC3) -
-
-
-
-
-
1.0 Audio Options
- - - - - - -
-
-
2.0 Audio Options
- - - - - - -
-
-
-
-
-
5.1 Audio Options
- - - - - - -
-
-
6.1 Audio Options
- - -
- - - - - - -
-
-
-
-
Raw FFMPEG Arguments Modifying options above will reset the raw arguments. Therefore edit these last...
- -
-
-
\ No newline at end of file +
diff --git a/web/public/templates/plex-settings.html b/web/public/templates/plex-settings.html index 480f3f9..36e4b4d 100644 --- a/web/public/templates/plex-settings.html +++ b/web/public/templates/plex-settings.html @@ -76,4 +76,77 @@ - \ No newline at end of file +
+
Plex Transcoder Settings + + + +
+
+
+
+
Video Options
+
+ + +
+
+ + +
+
+
+
Audio Options
+
+ + +
+
+ + +
+
+ + +
+
+ + + Note: This affects the "on deck" for your plex account. +
+
+
+
Subtitle Options
+
+ + +
+
+ + +
+
+
+ + diff --git a/web/public/templates/program-config.html b/web/public/templates/program-config.html index 64fdc55..bb5610a 100644 --- a/web/public/templates/program-config.html +++ b/web/public/templates/program-config.html @@ -76,33 +76,6 @@ -
-
Streams
-
-
-
- -
-
-
- -
-
-
- -
-
-
Commercials