Handle stream stream concatination using ffmpeg concat demuxer (ffmpeg 4.2+ required).

Change docker container to use alpine linux, comes with ffmpeg 4.2+ easier.
Encode commercials to be same video codec as programs when channel icons enabled. Resolution/framerate could still be problematic.
This commit is contained in:
Jordan Koehn 2020-06-03 11:50:27 -04:00
parent 9cb4b53424
commit 0fe29e7598
11 changed files with 288 additions and 180 deletions

View File

@ -1,8 +1,6 @@
FROM node:12.16
RUN apt-get -y update && \
apt-get -y upgrade && \
apt-get install -y ffmpeg && \
rm -rf /var/lib/apt/lists/*
FROM node:12.18-alpine3.12
# Should be ffmpeg v4.2.3
RUN apk add --no-cache ffmpeg && ffmpeg -version
WORKDIR /home/node/app
COPY package*.json ./
RUN npm install

View File

@ -113,8 +113,7 @@ function initDB(db) {
videoResolutionHeight: 'unchanged',
videoBitrate: 10000,
videoBufSize: 2000,
enableAutoPlay: true,
breakStreamOnCodecChange: true,
concatMuxDelay: '0',
logFfmpeg: true
})
}

7
package-lock.json generated
View File

@ -3200,7 +3200,7 @@
"follow-redirects": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz",
"integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==",
"integrity": "sha1-e3qfmuov3/NnhqlP9kPtB/T/Xio=",
"requires": {
"debug": "=3.1.0"
},
@ -7246,6 +7246,11 @@
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM="
},
"uuid": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.1.0.tgz",
"integrity": "sha512-CI18flHDznR0lq54xBycOVmphdCYnQLKn8abKn7PXUiKUGdEd+/l9LWNJmugXel4hXq7S+RMNl34ecyC9TntWg=="
},
"validate-npm-package-license": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",

View File

@ -70,8 +70,7 @@ function api(db, xmltvInterval) {
videoResolutionHeight: 'unchanged',
videoBitrate: 10000,
videoBufSize: 2000,
enableAutoPlay: true,
breakStreamOnCodecChange: true,
concatMuxDelay: '0',
logFfmpeg: true
})
let ffmpeg = db['ffmpeg-settings'].find()[0]

View File

@ -9,14 +9,23 @@ class FFMPEG extends events.EventEmitter {
this.channel = channel
this.ffmpegPath = opts.ffmpegPath
}
async spawn(streamUrl, duration, enableIcon, videoResolution) {
async spawn(streamUrl, streamStats, duration, enableIcon, type, isConcatPlaylist) {
let ffmpegArgs = [`-threads`, this.opts.threads,
`-t`, duration,
`-re`,
`-fflags`, `+genpts`,
`-i`, streamUrl];
`-re`,
`-fflags`, `+genpts+discardcorrupt+igndts`];
if (duration > 0)
ffmpegArgs.push(`-t`, duration)
if (isConcatPlaylist == true)
ffmpegArgs.push(`-f`, `concat`,
`-safe`, `0`,
`-protocol_whitelist`, `file,http,tcp,https,tcp,tls`)
if (enableIcon == true) {
ffmpegArgs.push(`-i`, streamUrl)
// Overlay icon
if (enableIcon && type === 'program') {
if (process.env.DEBUG) console.log('Channel Icon Overlay Enabled')
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)
@ -27,39 +36,48 @@ class FFMPEG extends events.EventEmitter {
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)) {
if (this.opts.videoResolutionHeight != "unchanged" && streamStats.videoHeight != `undefined` && parseInt(this.opts.videoResolutionHeight, 10) < parseInt(streamStats.videoHeight, 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]`
}
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`);
}
`-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`,
`-muxdelay`, `0`,
`-muxpreload`, `0`);
} else if (enableIcon && streamStats.videoCodec != this.opts.videoEncoder) { // Encode commercial if video codec does not match
ffmpegArgs.push(`-map`, `0`,
`-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`,
`-c:a`, `copy`,
`-muxdelay`, `0`,
`-muxpreload`, `0`);
} else
ffmpegArgs.push(`-map`, `0`,
`-c`, `copy`,
`-muxdelay`, this.opts.concatMuxDelay,
`-muxpreload`, this.opts.concatMuxDelay);
ffmpegArgs.push(`-metadata`,
`service_provider="PseudoTV"`,
`-metadata`,
`service_name="${this.channel.name}"`,
`-f`,
`mpegts`,
`-output_ts_offset`,
`0`,
`-muxdelay`,
`0`,
`-muxpreload`,
`0`,
`pipe:1`);
`service_provider="PseudoTV"`,
`-metadata`,
`service_name="${this.channel.name}`,
`-f`, `mpegts`,
`pipe:1`)
this.ffmpeg = spawn(this.ffmpegPath, ffmpegArgs)
this.ffmpeg.stdout.on('data', (chunk) => {

View File

@ -19,8 +19,10 @@ function getCurrentProgramAndTimeElapsed(date, channel) {
timeElapsed -= program.duration
}
}
if (currentProgramIndex === -1)
throw new Error("No program found; find algorithm fucked up")
return { program: channel.programs[currentProgramIndex], timeElapsed: timeElapsed, programIndex: currentProgramIndex }
}
@ -104,5 +106,7 @@ function createLineup(obj) {
}
function isChannelIconEnabled(enableChannelOverlay, icon, overlayIcon, type) {
return enableChannelOverlay == true && icon !== '' && overlayIcon && type === 'program'
if (typeof type === `undefined`)
return enableChannelOverlay == true && icon !== '' && overlayIcon
return enableChannelOverlay == true && icon !== '' && overlayIcon
}

View File

@ -22,6 +22,13 @@ class PlexTranscoder {
this.playState = "stopped"
}
async getStream(deinterlace) {
let stream = {}
stream.streamUrl = await this.getStreamUrl(deinterlace);
stream.streamStats = this.getVideoStats();
return stream;
}
async getStreamUrl(deinterlace) {
// Set transcoding parameters based off direct stream params
this.setTranscodingArgs(true, deinterlace)
@ -45,7 +52,7 @@ class PlexTranscoder {
let mediaBufferSize = (directStream == true) ? this.settings.mediaBufferSize : this.settings.transcodeMediaBufferSize
let subtitles = (this.settings.enableSubtitles == true) ? "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 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 profileName=`Generic` // Blank profile, everything is specified through X-Plex-Client-Profile-Extra
@ -54,6 +61,7 @@ class PlexTranscoder {
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)+\
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]})+\
add-limitation(scope=videoCodec&scopeName=*&type=upperBound&name=video.height&value=${resolutionArr[1]})`
@ -110,36 +118,25 @@ lang=en`
return this.decisionJson["MediaContainer"]["Metadata"][0]["Media"][0]["Part"][0]["Stream"][0]["height"];
}
getVideoStats(channelIconEnabled, ffmpegEncoderName) {
let ret = []
getVideoStats() {
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"])
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.push(stream["channels"], stream["codec"])
if (stream["streamType"] == "2" && stream["selected"] == "1") {
ret.audioChannels = stream["channels"];
ret.audioCodec = stream["codec"];
}
})
return ret
@ -151,6 +148,14 @@ lang=en`
})
.then((res) => {
this.decisionJson = res.data;
// Print error message if transcode not possible
// TODO: handle failure better
let transcodeDecisionCode = res.data.MediaContainer.transcodeDecisionCode
if (transcodeDecisionCode != "1001") {
console.log(`IMPORTANT: Recieved transcode decision code ${transcodeDecisionCode}! Expected code 1001.`)
console.log(`Error message: '${res.data.MediaContainer.transcodeDecisionText}'`)
}
})
.catch((err) => {
console.log(err);

View File

@ -39,7 +39,7 @@ function video(db) {
console.log(`\r\nStream ended. Channel: 1 (PseudoTV)`)
})
})
// Continuously stream video to client. Leverage ffmpeg concat for piecing together videos
router.get('/video', (req, res) => {
// Check if channel queried is valid
if (typeof req.query.channel === 'undefined') {
@ -53,10 +53,62 @@ function video(db) {
}
channel = channel[0]
// 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]
// Check if ffmpeg path is valid
if (!fs.existsSync(ffmpegSettings.ffmpegPath)) {
res.status(500).send("FFMPEG path is invalid. The file (executable) doesn't exist.")
console.error("The FFMPEG Path is invalid. Please check your configuration.")
return
}
res.writeHead(200, {
'Content-Type': 'video/mp2t'
})
console.log(`\r\nStream starting. Channel: ${channel.number} (${channel.name})`)
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();
})
res.on('close', () => { // on HTTP close, kill ffmpeg
console.log(`\r\nStream ended. Channel: ${channel.number} (${channel.name})`);
ffmpeg.kill();
})
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.")
})
let channelNum = parseInt(req.query.channel, 10)
ffmpeg.spawn(`http://localhost:${process.env.PORT}/playlist?channel=${channelNum}`, `undefined`, -1, false, `undefined`, true);
})
// Stream individual video to ffmpeg concat above. This is used by the server, NOT the client
router.get('/stream', (req, res) => {
// Check if channel queried is valid
if (typeof req.query.channel === 'undefined') {
res.status(500).send("No Channel Specified")
return
}
let channel = db['channels'].find({ number: parseInt(req.query.channel, 10) })
if (channel.length === 0) {
res.status(500).send("Channel doesn't exist")
return
}
channel = channel[0]
let ffmpegSettings = db['ffmpeg-settings'].find()[0]
let plexSettings = db['plex-settings'].find()[0]
@ -67,79 +119,86 @@ function video(db) {
return
}
console.log(`\r\nStream starting. Channel: ${channel.number} (${channel.name})`)
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) => {
plexTranscoder.stopUpdatingPlex();
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();
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;
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();
}
})
res.on('close', () => { // on HTTP close, kill ffmpeg
plexTranscoder.stopUpdatingPlex();
console.log(`\r\nStream ended. Channel: ${channel.number} (${channel.name})`);
ffmpeg.kill();
})
// 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 streamDuration = lineupItem.streamDuration / 1000;
plexTranscoder.getStreamUrl(deinterlace).then(streamUrl => ffmpeg.spawn(streamUrl, streamDuration, enableChannelIcon, plexTranscoder.getResolutionHeight())); // Spawn the ffmpeg process, fire this bitch up
plexTranscoder.startUpdatingPlex();
// 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);
let deinterlace = enableChannelIcon = helperFuncs.isChannelIconEnabled(ffmpegSettings.enableChannelOverlay, channel.icon, channel.overlayIcon)
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();
}
});
})
router.get('/playlist', (req, res) => {
res.type('text')
// Check if channel queried is valid
if (typeof req.query.channel === 'undefined') {
res.status(500).send("No Channel Specified")
return
}
let channelNum = parseInt(req.query.channel, 10)
let channel = db['channels'].find({ number: channelNum })
if (channel.length === 0) {
res.status(500).send("Channel doesn't exist")
return
}
// Maximum number of streams to concatinate beyond channel starting
// If someone passes this number then they probably watch too much television
let maxStreamsToPlayInARow = 100;
var data = "#ffconcat version 1.0\n"
for (var i = 0; i < maxStreamsToPlayInARow; i++)
data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}'\n`
res.send(data)
})
return router
}

View File

@ -35,6 +35,14 @@
{id:"2160",description:"3840x2160"},
{id:"unchanged",description:"Same as source"}
];
scope.muxDelayOptions=[
{id:"0",description:"0 Seconds"},
{id:"1",description:"1 Seconds"},
{id:"2",description:"2 Seconds"},
{id:"3",description:"3 Seconds"},
{id:"4",description:"4 Seconds"},
{id:"5",description:"5 Seconds"}
];
}
}
}

View File

@ -9,17 +9,26 @@
</button>
</h5>
<h6>FFMPEG Executable Path (eg: C:\ffmpeg\bin\ffmpeg.exe || /usr/bin/ffmpeg)</h6>
<input type="text" class="form-control form-control-sm" ng-model="settings.ffmpegPath"/>
<input type="text" class="form-control form-control-sm" ria-describedby="ffmpegHelp" ng-model="settings.ffmpegPath"/>
<small id="ffmpegHelp" class="form-text text-muted">FFMPEG version 4.2+ required. Check by running '{{settings.ffmpegPath}} -version' from the command line</small>
<hr/>
<h6>Miscellaneous Options</h6>
<div class="row">
<div class="col-sm-4">
<label>Threads</label>
<input type="number" class="form-control form-control-sm" ng-model="settings.threads"/>
<div class="form-group">
<div class="form-group">
<label>Threads</label>
<input type="number" class="form-control form-control-sm" ng-model="settings.threads"/>
</div>
</div>
</div>
<div class="col-sm-4">
<label>Log FFMPEG</label><br/>
<input id="logFfmpeg" type="checkbox" ng-model="settings.logFfmpeg"/> <label for="logFfmpeg">Log FFMPEG to console</label>
<div class="form-group">
<label>Logging</label>
<br>
<input id="logFfmpeg" type="checkbox" ng-model="settings.logFfmpeg"/>
<label for="logFfmpeg">Log FFMPEG to console</label>
</div>
</div>
</div>
<hr/>
@ -31,38 +40,37 @@
<small id="enableChannelOverlayHelp" class="form-text text-muted">Note: This transcoding is done by PseudoTV, not Plex.</small>
</div>
<div class="form-group">
<input id="enableAutoPlay" type="checkbox" ng-model="settings.enableAutoPlay"/>
<label for="enableAutoPlay">Play next episode automatically</label>
</div>
<div class="form-group" ng-hide="hideIfNotAutoPlay()">
<input id="breakStreamOnCodecChange" type="checkbox" ng-model="settings.breakStreamOnCodecChange" ria-describedby="breakStreamOnCodecChangeHelp"/>
<label for="breakStreamOnCodecChange">Break stream if codec changes</label>
<small id="enableChannelOverlayHelp" class="form-text text-muted">Clients typically cannot handle resolution, video/audio codec, framerate, or audio channels changing between programs/commercials</small>
<label>Video Buffer</label>
<select ng-model="settings.concatMuxDelay" ria-describedby="concatMuxDelayHelp"
ng-options="o.id as o.description for o in muxDelayOptions" />
<small id="concatMuxDelayHelp" class="form-text text-muted">Note: If you experience playback issues upon stream start, try increasing this.</small>
</div>
</div>
<div class="col-sm-6" ng-hide="hideIfNotEnableChannelOverlay()">
<div class="form-group">
<label>Video Encoder</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.videoEncoder" ria-describedby="videoEncoderHelp"/>
<small id="videoEncoderHelp" class="form-text text-muted">Some possible values are:</small>
<small id="videoEncoderHelp" class="form-text text-muted">Intel Quick Sync: h264_qsv, mpeg2_qsv</small>
<small id="videoEncoderHelp" class="form-text text-muted">NVIDIA: GPU: h264_nvenc</small>
<small id="videoEncoderHelp" class="form-text text-muted">MPEG2: mpeg2video (default)</small>
<small id="videoEncoderHelp" class="form-text text-muted">H264: libx264</small>
<small id="videoEncoderHelp" class="form-text text-muted">MacOS: h264_videotoolbox</small>
</div>
<div class="form-group">
<label>Video Bitrate (k)</label>
<input type="number" class="form-control form-control-sm" ng-model="settings.videoBitrate"/>
</div>
<div class="form-group">
<label>Max Video Resolution</label>
<select ng-model="settings.videoResolutionHeight"
ng-options="o.id as o.description for o in resolutionOptions" />
</div>
<div class="form-group">
<label>Video Buffer Size (k)</label>
<input type="number" class="form-control form-control-sm" ng-model="settings.videoBufSize"/>
<div class="col-sm-6">
<div ng-hide="hideIfNotEnableChannelOverlay()">
<div class="form-group">
<label>Video Encoder</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.videoEncoder" ria-describedby="videoEncoderHelp"/>
<small id="videoEncoderHelp" class="form-text text-muted">Some possible values are:</small>
<small id="videoEncoderHelp" class="form-text text-muted">Intel Quick Sync: h264_qsv, mpeg2_qsv</small>
<small id="videoEncoderHelp" class="form-text text-muted">NVIDIA: GPU: h264_nvenc</small>
<small id="videoEncoderHelp" class="form-text text-muted">MPEG2: mpeg2video (default)</small>
<small id="videoEncoderHelp" class="form-text text-muted">H264: libx264</small>
<small id="videoEncoderHelp" class="form-text text-muted">MacOS: h264_videotoolbox</small>
</div>
<div class="form-group">
<label>Video Bitrate (k)</label>
<input type="number" class="form-control form-control-sm" ng-model="settings.videoBitrate"/>
</div>
<div class="form-group">
<label>Max Video Resolution</label>
<select ng-model="settings.videoResolutionHeight"
ng-options="o.id as o.description for o in resolutionOptions" />
</div>
<div class="form-group">
<label>Video Buffer Size (k)</label>
<input type="number" class="form-control form-control-sm" ng-model="settings.videoBufSize"/>
</div>
</div>
</div>
</div>

View File

@ -36,6 +36,11 @@
</button>
</td>
</tr>
<tr ng-if="servers.length !== 0">
<td colspan="5">
<p class="text-center text-danger">Edit server values in plex-servers.json</p>
</td>
</tr>
</table>
<hr>
@ -102,7 +107,7 @@
</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</label>
<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">