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.

This commit is contained in:
Jordan Koehn 2020-06-18 21:53:28 -04:00
parent 4019feba01
commit 52052bf91d
9 changed files with 230 additions and 103 deletions

View File

@ -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: ''
})
}

View File

@ -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)

View File

@ -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`,

View File

@ -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
}
}

View File

@ -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

View File

@ -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')

View File

@ -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"},

View File

@ -55,6 +55,29 @@
</h6>
<hr>
<div class="row" >
<div class="col-sm-3">
<div class="form-group">
<input id="debugLogging" type="checkbox" ng-model="settings.debugLogging"/>
<label for="debugLogging">Debug logging</label>
</div>
<div class="form-group">
<label>Paths</label>
<select ng-model="settings.streamPath"
ng-options="o.id as o.description for o in pathOptions" />
</div>
</div>
<div class="col-sm-3">
<div class="form-group">
<input id="updatePlayStatus" type="checkbox" ng-model="settings.updatePlayStatus" ria-describedby="updatePlayStatusHelp"/>
<label for="updatePlayStatus">Send play status to Plex</label>
<small id="updatePlayStatusHelp" class="form-text text-muted">Note: This affects the "on deck" for your plex account.</small>
</div>
</div>
<div class="col-sm-6">
<p class="text-center text-danger">If stream changes video codec, audio codec, or audio channels upon episode change, you will experience playback issues.</p>
</div>
</div>
<div class="row" ng-hide="hideIfNotPlexPath()">
<div class="col-sm-6">
<h6 style="font-weight: bold">Video Options</h6>
<div class="form-group">
@ -76,7 +99,8 @@
<h6 style="font-weight: bold">Audio Options</h6>
<div class="form-group">
<label>Supported Audio Formats</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.audioCodecs" />
<input type="text" class="form-control form-control-sm" ng-model="settings.audioCodecs" ria-describedby="audioCodecsHelp" />
<small id="audioCodecsHelp" class="form-text text-muted">Comma separated list. Some possible values are 'ac3,aac,mp3'.</small>
</div>
<div class="form-group">
<label>Maximum Audio Channels</label>
@ -92,7 +116,7 @@
</div>
</div>
</div>
<div class="row">
<div class="row" ng-hide="hideIfNotPlexPath()">
<div class="col-sm-6">
<h6 style="font-weight: bold">Miscellaneous Options</h6>
<div class="form-group">
@ -111,16 +135,15 @@
<label>Transcode Media Buffer Size</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.transcodeMediaBufferSize" />
</div>
<div class="form-group">
<input id="updatePlayStatus" type="checkbox" ng-model="settings.updatePlayStatus" ria-describedby="updatePlayStatusHelp"/>
<label for="updatePlayStatus">Send play status to Plex when possible</label>
<small id="updatePlayStatusHelp" class="form-text text-muted">Note: This affects the "on deck" for your plex account.</small>
</div>
<div class="form-group">
<label>Stream Protocol</label>
<select ng-model="settings.streamProtocol"
ng-options="o.id as o.description for o in streamProtocols" />
</div>
<div class="form-group">
<input id="updatePlayStatus" type="checkbox" ng-model="settings.forceDirectPlay" />
<label for="updatePlayStatus">Force Direct Play</label>
</div>
</div>
<div class="col-sm-6">
<h6 style="font-weight: bold">Subtitle Options</h6>
@ -134,5 +157,16 @@
</div>
</div>
</div>
<div class="row" ng-hide="hideIfNotDirectPath()">
<div class="col-sm-6">
<h6 style="font-weight: bold">Path Replacements</h6>
<div class="form-group">
<label>Original Plex path to replace:</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.pathReplace" />
</div>
<div class="form-group">
<label>Replace Plex path with:</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.pathReplaceWith" />
</div>
</div>
</div>

View File

@ -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;