From 52052bf91d54b398f28d12d60fa950e6e6843012 Mon Sep 17 00:00:00 2001 From: Jordan Koehn Date: Thu, 18 Jun 2020 21:53:28 -0400 Subject: [PATCH] add plex options to pick between plex paths and direct paths. Change ffmpeg concat to always read in at fastest rate possible. /stream endpoint will now always spawn a seperate ffmpeg process now, no longer redirects sometimes. Plex transcoder has ability to update play status regardless of direct/plex paths. Add debug logging. --- index.js | 11 +- src/api.js | 11 +- src/ffmpeg.js | 17 ++- src/helperFuncs.js | 10 +- src/plexTranscoder.js | 144 ++++++++++++++++-------- src/video.js | 76 ++++++------- web/directives/plex-settings.js | 10 ++ web/public/templates/plex-settings.html | 50 ++++++-- web/services/plex.js | 4 + 9 files changed, 230 insertions(+), 103 deletions(-) diff --git a/index.js b/index.js index 0be591b..4e8a08b 100644 --- a/index.js +++ b/index.js @@ -120,6 +120,8 @@ function initDB(db) { if (plexSettings.length === 0) { db['plex-settings'].save({ + streamPath: 'plex', + debugLogging: true, directStreamBitrate: '40000', transcodeBitrate: '3000', mediaBufferSize: 1000, @@ -127,13 +129,16 @@ function initDB(db) { maxPlayableResolution: "1920x1080", maxTranscodeResolution: "1920x1080", videoCodecs: 'h264,hevc,mpeg2video', - audioCodecs: 'ac3,aac,mp3', - maxAudioChannels: '6', + audioCodecs: 'ac3', + maxAudioChannels: '2', audioBoost: '100', enableSubtitles: false, subtitleSize: '100', updatePlayStatus: false, - streamProtocol: 'http' + streamProtocol: 'http', + forceDirectPlay: false, + pathReplace: '', + pathReplaceWith: '' }) } diff --git a/src/api.js b/src/api.js index 158cc5f..5bff62b 100644 --- a/src/api.js +++ b/src/api.js @@ -89,6 +89,8 @@ function api(db, xmltvInterval) { }) router.post('/api/plex-settings', (req, res) => { // RESET db['plex-settings'].update({ _id: req.body._id }, { + streamPath: 'plex', + debugLogging: true, directStreamBitrate: '40000', transcodeBitrate: '3000', mediaBufferSize: 1000, @@ -96,13 +98,16 @@ function api(db, xmltvInterval) { maxPlayableResolution: "1920x1080", maxTranscodeResolution: "1920x1080", videoCodecs: 'h264,hevc,mpeg2video', - audioCodecs: 'ac3,aac,mp3', - maxAudioChannels: '6', + audioCodecs: 'ac3', + maxAudioChannels: '2', audioBoost: '100', enableSubtitles: false, subtitleSize: '100', updatePlayStatus: false, - streamProtocol: 'http' + streamProtocol: 'http', + forceDirectPlay: false, + pathReplace: '', + pathReplaceWith: '' }) let plex = db['plex-settings'].find()[0] res.send(plex) diff --git a/src/ffmpeg.js b/src/ffmpeg.js index 0f7965c..22d3d27 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -9,13 +9,24 @@ class FFMPEG extends events.EventEmitter { this.channel = channel this.ffmpegPath = opts.ffmpegPath } - async spawn(streamUrl, streamStats, duration, enableIcon, type, isConcatPlaylist) { + async spawnConcat(streamUrl) { + this.spawn(streamUrl, undefined, undefined, undefined, false, false, undefined, true) + } + async spawnStream(streamUrl, streamStats, startTime, duration, enableIcon, type) { + this.spawn(streamUrl, streamStats, startTime, duration, true, enableIcon, type, false) + } + async spawn(streamUrl, streamStats, startTime, duration, limitRead, enableIcon, type, isConcatPlaylist) { let ffmpegArgs = [`-threads`, this.opts.threads, - `-re`, `-fflags`, `+genpts+discardcorrupt+igndts`]; - if (duration > 0) + if (limitRead === true) + ffmpegArgs.push(`-re`) + + if (typeof duration !== 'undefined') ffmpegArgs.push(`-t`, duration) + + if (typeof startTime !== 'undefined') + ffmpegArgs.push(`-ss`, startTime) if (isConcatPlaylist == true) ffmpegArgs.push(`-f`, `concat`, diff --git a/src/helperFuncs.js b/src/helperFuncs.js index a972aff..cc5a665 100644 --- a/src/helperFuncs.js +++ b/src/helperFuncs.js @@ -50,6 +50,8 @@ function createLineup(obj) { lineup.push({ type: 'commercial', key: commercials[i][y].key, + plexFile: commercials[i][y].plexFile, + file: commercials[i][y].file, ratingKey: commercials[i][y].ratingKey, start: timeElapsed, // start time will be the time elapsed, cause this is the first video streamDuration: commercials[i][y].duration - timeElapsed, // stream duration set accordingly @@ -60,6 +62,8 @@ function createLineup(obj) { lineup.push({ // just add the video, starting at 0, playing the entire duration type: 'commercial', key: commercials[i][y].key, + plexFile: commercials[i][y].plexFile, + file: commercials[i][y].file, ratingKey: commercials[i][y].ratingKey, start: 0, streamDuration: commercials[i][y].actualDuration, @@ -76,6 +80,8 @@ function createLineup(obj) { lineup.push({ type: 'program', key: activeProgram.key, + plexFile: activeProgram.plexFile, + file: activeProgram.file, ratingKey: activeProgram.ratingKey, start: progTimeElapsed + timeElapsed, // add the duration of already played program chunks to the timeElapsed streamDuration: (programStartTimes[i + 1] - programStartTimes[i]) - timeElapsed, @@ -89,6 +95,8 @@ function createLineup(obj) { lineup.push({ type: 'program', key: activeProgram.key, + plexFile: activeProgram.plexFile, + file: activeProgram.file, ratingKey: activeProgram.ratingKey, start: programStartTimes[i], streamDuration: (programStartTimes[i + 1] - programStartTimes[i]), @@ -109,4 +117,4 @@ function isChannelIconEnabled(enableChannelOverlay, icon, overlayIcon, type) { if (typeof type === `undefined`) return enableChannelOverlay == true && icon !== '' && overlayIcon return enableChannelOverlay == true && icon !== '' && overlayIcon -} \ No newline at end of file +} diff --git a/src/plexTranscoder.js b/src/plexTranscoder.js index 4d804db..fb41b51 100644 --- a/src/plexTranscoder.js +++ b/src/plexTranscoder.js @@ -7,7 +7,13 @@ class PlexTranscoder { this.settings = settings + this.log("Plex transcoder initiated") + this.log("Debug logging enabled") + this.key = lineupItem.key + this.plexFile = `${lineupItem.server.uri}${lineupItem.plexFile}?X-Plex-Token=${lineupItem.server.accessToken}` + this.file = lineupItem.file.replace(settings.pathReplace, settings.pathReplaceWith) + this.transcodeUrlBase = `${lineupItem.server.uri}/video/:/transcode/universal/start.m3u8?` this.ratingKey = lineupItem.ratingKey this.currTimeMs = lineupItem.start this.currTimeS = this.currTimeMs / 1000 @@ -23,35 +29,61 @@ class PlexTranscoder { } async getStream(deinterlace) { - let stream = {} - stream.streamUrl = await this.getStreamUrl(deinterlace); - stream.streamStats = this.getVideoStats(); - return stream; - } + let stream = {directPlay: false} - async getStreamUrl(deinterlace) { - // Set transcoding parameters based off direct stream params - this.setTranscodingArgs(true, deinterlace) + this.log("Getting stream") + this.log(` deinterlace: ${deinterlace}`) + this.log(` streamPath: ${this.settings.streamPath}`) + this.log(` forceDirectPlay: ${this.settings.forceDirectPlay}`) - await this.getDecision(); - const videoIsDirectStream = this.isVideoDirectStream(); + // direct play forced + if (this.settings.streamPath === 'direct' || this.settings.forceDirectPlay) { + 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) - // 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(); + 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) { + 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}` + } } - return `${this.server.uri}/video/:/transcode/universal/start.m3u8?${this.transcodingArgs}` + stream.streamStats = this.getVideoStats(); + + this.log(stream) + + return stream } - setTranscodingArgs(directStream, deinterlace) { - let resolution = (directStream == true) ? this.settings.maxPlayableResolution : this.settings.maxTranscodeResolution - let bitrate = (directStream == true) ? this.settings.directStreamBitrate : this.settings.transcodeBitrate - let mediaBufferSize = (directStream == true) ? this.settings.mediaBufferSize : this.settings.transcodeMediaBufferSize - let subtitles = (this.settings.enableSubtitles == true) ? "burn" : "none" // subtitle options: burn, none, embedded, sidecar + setTranscodingArgs(directPlay, directStream, deinterlace) { + let resolution = (directStream) ? this.settings.maxPlayableResolution : this.settings.maxTranscodeResolution + let bitrate = (directStream) ? this.settings.directStreamBitrate : this.settings.transcodeBitrate + let mediaBufferSize = (directStream) ? this.settings.mediaBufferSize : this.settings.transcodeMediaBufferSize + let subtitles = (this.settings.enableSubtitles) ? "burn" : "none" // subtitle options: burn, none, embedded, sidecar let streamContainer = "mpegts" // Other option is mkv, mkv has the option of copying it's subs for later processing + let isDirectPlay = (directPlay) ? '1' : '0' let videoQuality=`100` // Not sure how this applies, maybe this works if maxVideoBitrate is not set let profileName=`Generic` // Blank profile, everything is specified through X-Plex-Client-Profile-Extra @@ -93,7 +125,7 @@ path=${this.key}&\ mediaIndex=0&\ partIndex=0&\ fastSeek=1&\ -directPlay=0&\ +directPlay=${isDirectPlay}&\ directStream=1&\ directStreamAudio=1&\ copyts=1&\ @@ -111,52 +143,69 @@ lang=en` isVideoDirectStream() { try { - return this.decisionJson["MediaContainer"]["Metadata"][0]["Media"][0]["Part"][0]["Stream"][0]["decision"] == "copy"; + return this.getVideoStats().videoDecision === "copy"; } catch (e) { console.log("Error at decision:" + e); return false; } } - getResolutionHeight() { - return this.decisionJson["MediaContainer"]["Metadata"][0]["Media"][0]["Part"][0]["Stream"][0]["height"]; + isDirectPlay() { + try { + return this.getVideoStats().videoDecision === "copy" && this.getVideoStats().audioDecision === "copy"; + } catch (e) { + console.log("Error at decision:" + e); + return false; + } } getVideoStats() { let ret = {} - let streams = this.decisionJson["MediaContainer"]["Metadata"][0]["Media"][0]["Part"][0]["Stream"] + try { + let streams = this.decisionJson.MediaContainer.Metadata[0].Media[0].Part[0].Stream - streams.forEach(function (stream) { - // Video - if (stream["streamType"] == "1") { - ret.videoCodec = stream["codec"]; - ret.videoWidth = stream["width"]; - ret.videoHeight = stream["height"]; - ret.videoFramerate = Math.round(stream["frameRate"]); - // Rounding framerate avoids scenarios where - // 29.9999999 & 30 don't match. - } - // Audio. Only look at stream being used - if (stream["streamType"] == "2" && stream["selected"] == "1") { - ret.audioChannels = stream["channels"]; - ret.audioCodec = stream["codec"]; - } - }) + streams.forEach(function (stream) { + // Video + if (stream["streamType"] == "1") { + ret.videoCodec = stream.codec; + ret.videoWidth = stream.width; + ret.videoHeight = stream.height; + ret.videoFramerate = Math.round(stream["frameRate"]); + // Rounding framerate avoids scenarios where + // 29.9999999 & 30 don't match. + ret.videoDecision = (typeof stream.decision === 'undefined') ? 'copy' : stream.decision; + } + // Audio. Only look at stream being used + if (stream["streamType"] == "2" && stream["selected"] == "1") { + ret.audioChannels = stream["channels"]; + ret.audioCodec = stream["codec"]; + ret.audioDecision = (typeof stream.decision === 'undefined') ? 'copy' : stream.decision; + } + }) + } catch (e) { + console.log("Error at decision:" + e); + } + + this.log("Current video stats:") + this.log(ret) return ret } - async getDecision() { + async getDecision(directPlay) { await axios.get(`${this.server.uri}/video/:/transcode/universal/decision?${this.transcodingArgs}`, { headers: { Accept: 'application/json' } }) .then((res) => { this.decisionJson = res.data; + this.log("Recieved transcode decision:") + this.log(res.data) + // Print error message if transcode not possible // TODO: handle failure better let transcodeDecisionCode = res.data.MediaContainer.transcodeDecisionCode - if (transcodeDecisionCode != "1001") { + if (!(directPlay || transcodeDecisionCode == "1001")) { console.log(`IMPORTANT: Recieved transcode decision code ${transcodeDecisionCode}! Expected code 1001.`) console.log(`Error message: '${res.data.MediaContainer.transcodeDecisionText}'`) } @@ -208,6 +257,7 @@ X-Plex-Token=${this.server.accessToken}`; } updatePlex() { + this.log("Updating plex status") axios.post(this.getStatusUrl()); this.currTimeMs += this.updateInterval; if (this.currTimeMs > this.duration) { @@ -215,6 +265,12 @@ X-Plex-Token=${this.server.accessToken}`; } this.currTimeS = this.duration / 1000; } + + log(message) { + if (this.settings.debugLogging) { + console.log(message) + } + } } module.exports = PlexTranscoder diff --git a/src/video.js b/src/video.js index f2e9d44..815a48f 100644 --- a/src/video.js +++ b/src/video.js @@ -94,7 +94,7 @@ function video(db) { }) let channelNum = parseInt(req.query.channel, 10) - ffmpeg.spawn(`http://localhost:${process.env.PORT}/playlist?channel=${channelNum}`, `undefined`, -1, false, `undefined`, true); + 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) => { @@ -120,6 +120,10 @@ 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) @@ -129,50 +133,40 @@ function video(db) { // 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 - - let plexTranscoder = new PlexTranscoder(plexSettings, lineupItem); + streamDuration = undefined let deinterlace = enableChannelIcon = helperFuncs.isChannelIconEnabled(ffmpegSettings.enableChannelOverlay, channel.icon, channel.overlayIcon) + let plexTranscoder = new PlexTranscoder(plexSettings, lineupItem); + + let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options + + ffmpeg.on('data', (data) => { res.write(data) }) + + ffmpeg.on('error', (err) => { + console.error("FFMPEG ERROR", err); + res.status(500).send("FFMPEG ERROR"); + return; + }) + + ffmpeg.on('close', () => { + res.send(); + }) + + ffmpeg.on('end', () => { // On finish transcode - END of program or commercial... + plexTranscoder.stopUpdatingPlex(); + res.end() + }) + + res.on('close', () => { // on HTTP close, kill ffmpeg + ffmpeg.kill(); + }) + plexTranscoder.getStream(deinterlace).then(stream => { - let streamUrl = stream.streamUrl - let streamStats = stream.streamStats - // Not time limited & no transcoding required. Pass plex transcode url directly - if (!enableChannelIcon && streamDuration === -1) { - res.redirect(streamUrl); - } else { // ffmpeg needed limit time or insert channel icon - res.writeHead(200, { - 'Content-Type': 'video/mp2t' - }) - - let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options - - ffmpeg.on('data', (data) => { res.write(data) }) - - ffmpeg.on('error', (err) => { - console.error("FFMPEG ERROR", err); - res.status(500).send("FFMPEG ERROR"); - return; - }) - - ffmpeg.on('close', () => { - res.send(); - }) - - ffmpeg.on('end', () => { // On finish transcode - END of program or commercial... - plexTranscoder.stopUpdatingPlex(); - res.end() - }) - - res.on('close', () => { // on HTTP close, kill ffmpeg - ffmpeg.kill(); - }) - - ffmpeg.spawn(streamUrl, streamStats, streamDuration, enableChannelIcon, lineupItem.type, false); // Spawn the ffmpeg process, fire this bitch up - plexTranscoder.startUpdatingPlex(); - } - }); + let streamStart = (stream.directPlay) ? plexTranscoder.currTimeS : undefined; + ffmpeg.spawnStream(stream.streamUrl, stream.streamStats, streamStart, streamDuration, enableChannelIcon, lineupItem.type); // Spawn the ffmpeg process, fire this bitch up + plexTranscoder.startUpdatingPlex(); + }); }) router.get('/playlist', (req, res) => { res.type('text') diff --git a/web/directives/plex-settings.js b/web/directives/plex-settings.js index ca2a464..a02b5d5 100644 --- a/web/directives/plex-settings.js +++ b/web/directives/plex-settings.js @@ -53,6 +53,16 @@ module.exports = function (plex, pseudotv, $timeout) { scope.settings = _settings }) } + scope.pathOptions=[ + {id:"plex",description:"Plex"}, + {id:"direct",description:"Direct"} + ]; + scope.hideIfNotPlexPath = () => { + return scope.settings.streamPath != 'plex' + }; + scope.hideIfNotDirectPath = () => { + return scope.settings.streamPath != 'direct' + }; scope.maxAudioChannelsOptions=[ {id:"1",description:"1.0"}, {id:"2",description:"2.0"}, diff --git a/web/public/templates/plex-settings.html b/web/public/templates/plex-settings.html index 2176cae..633ee74 100644 --- a/web/public/templates/plex-settings.html +++ b/web/public/templates/plex-settings.html @@ -55,6 +55,29 @@
+
+
+ + +
+
+ + + + Note: This affects the "on deck" for your plex account. +
+
+
+

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

+
+
+
Video Options
@@ -76,7 +99,8 @@
Audio Options
- + + Comma separated list. Some possible values are 'ac3,aac,mp3'.
@@ -92,7 +116,7 @@
-
+
Miscellaneous Options
@@ -111,16 +135,15 @@
-
- - - Note: This affects the "on deck" for your plex account. -
+ +
Subtitle Options
@@ -134,5 +157,16 @@
- +
+
+
Path Replacements
+
+ + +
+
+ + +
+
diff --git a/web/services/plex.js b/web/services/plex.js index c3bb25b..a4dc096 100644 --- a/web/services/plex.js +++ b/web/services/plex.js @@ -146,6 +146,10 @@ module.exports = function ($http, $window, $interval) { date: res.Metadata[i].originallyAvailableAt, year: res.Metadata[i].year, } + if (program.type === 'episode' || program.type === 'movie') { + program.plexFile = `${res.Metadata[i].Media[0].Part[0].key}` + program.file = `${res.Metadata[i].Media[0].Part[0].file}` + } if (program.type === 'episode') { //Make sure that video files that contain multiple episodes are only listed once: var anyNewFile = false;