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:
parent
9cb4b53424
commit
0fe29e7598
@ -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
|
||||
|
||||
3
index.js
3
index.js
@ -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
7
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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);
|
||||
|
||||
211
src/video.js
211
src/video.js
@ -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
|
||||
}
|
||||
|
||||
@ -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"}
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user