Implement plex transcoder api into PseudoTV-Plex. Add more specific settings for ffmpeg/plex. Remove some options which are no longer valid/possible.

This commit is contained in:
Jordan Koehn 2020-05-23 13:21:47 -04:00
parent cc6df415bf
commit 1848a9c432
15 changed files with 596 additions and 523 deletions

View File

@ -31,7 +31,7 @@ if (!fs.existsSync(process.env.DATABASE))
if(!fs.existsSync(path.join(process.env.DATABASE, 'images')))
fs.mkdirSync(path.join(process.env.DATABASE, 'images'))
db.connect(process.env.DATABASE, ['channels', 'plex-servers', 'ffmpeg-settings', 'xmltv-settings', 'hdhr-settings'])
db.connect(process.env.DATABASE, ['channels', 'plex-servers', 'ffmpeg-settings', 'plex-settings', 'xmltv-settings', 'hdhr-settings'])
initDB(db)
@ -94,6 +94,7 @@ app.listen(process.env.PORT, () => {
function initDB(db) {
let ffmpegSettings = db['ffmpeg-settings'].find()
let plexSettings = db['plex-settings'].find()
if (!fs.existsSync(process.env.DATABASE + '/font.ttf')) {
let data = fs.readFileSync(path.resolve(path.join(__dirname, 'resources/font.ttf')))
fs.writeFileSync(process.env.DATABASE + '/font.ttf', data)
@ -106,63 +107,33 @@ function initDB(db) {
if (ffmpegSettings.length === 0) {
db['ffmpeg-settings'].save({
ffmpegPath: '/usr/bin/ffmpeg',
videoStreamMode: 'transcodeVideo',
audioStreamMode: 'transcodeAudio',
reduceAudioTranscodes: true,
offset: 0,
enableChannelOverlay: false,
threads: 4,
videoEncoder: 'libx264',
videoResolution: '1280x720',
videoFrameRate: 30,
videoEncoder: 'mpeg2video',
videoResolutionHeight: 'unchanged',
videoBitrate: 10000,
videoBufSize: 1000,
audioBitrate: 192,
audioChannels: 2,
audioRate: 48000,
audioEncoder: 'ac3',
oneChAudioBitrate: 156,
oneChAudioRate: 48000,
oneChAudioEncoder: 'ac3',
twoChAudioBitrate: 192,
twoChAudioRate: 48000,
twoChAudioEncoder: 'ac3',
fivePointOneChAudioBitrate: 336,
fivePointOneChAudioRate: 48000,
fivePointOneChAudioEncoder: 'ac3',
sixPointOneChAudioBitrate: 350,
sixPointOneChAudioRate: 48000,
sixPointOneChAudioEncoder: 'ac3',
transcodeSixPointOneAudioToFivePointOne: false,
logFfmpeg: false,
args: `-threads 4
-ss STARTTIME
-re
-i INPUTFILE
-t DURATION
-map VIDEOSTREAM
-map AUDIOSTREAM
-c:v libx264
-c:a ac3
-ac 2
-ar 48000
-b:a 192k
-b:v 10000k
-s 1280x720
-r 30
-flags cgop+ilme
-sc_threshold 1000000000
-minrate:v 10000k
-maxrate:v 10000k
-bufsize:v 1000k
-metadata service_provider="PseudoTV"
-metadata CHANNELNAME
-f mpegts
-output_ts_offset TSOFFSET
-muxdelay 0
-muxpreload 0
OUTPUTFILE`
videoBufSize: 2000,
enableAutoPlay: true,
breakStreamOnCodecChange: true,
logFfmpeg: true
})
}
if (plexSettings.length === 0) {
db['plex-settings'].save({
directStreamBitrate: '40000',
transcodeBitrate: '3000',
maxPlayableResolution: "1920x1080",
maxTranscodeResolution: "1920x1080",
enableHEVC: true,
audioCodecs: 'ac3,aac,mp3',
maxAudioChannels: '6',
enableSubtitles: false,
subtitleSize: '100',
updatePlayStatus: false
})
}
let xmltvSettings = db['xmltv-settings'].find()
if (xmltvSettings.length === 0) {
db['xmltv-settings'].save({

View File

@ -21,6 +21,8 @@
"body-parser": "^1.19.0",
"diskdb": "^0.1.17",
"express": "^4.17.1",
"uuid": "^8.0.0",
"node-fetch": "^2.6.0",
"node-ssdp": "^4.0.0",
"request": "^2.88.2",
"xml-writer": "^1.7.0"

View File

@ -64,65 +64,47 @@ function api(db, xmltvInterval) {
router.post('/api/ffmpeg-settings', (req, res) => { // RESET
db['ffmpeg-settings'].update({ _id: req.body._id }, {
ffmpegPath: req.body.ffmpegPath,
videoStreamMode: 'transcodeVideo',
audioStreamMode: 'transcodeAudio',
reduceAudioTranscodes: true,
offset: 0,
enableChannelOverlay: false,
threads: 4,
videoEncoder: 'libx264',
videoResolution: '1280x720',
videoFrameRate: 30,
videoEncoder: 'mpeg2video',
videoResolutionHeight: 'unchanged',
videoBitrate: 10000,
videoBufSize: 1000,
audioBitrate: 192,
audioChannels: 2,
audioRate: 48000,
audioEncoder: 'ac3',
oneChAudioBitrate: 156,
oneChAudioRate: 48000,
oneChAudioEncoder: 'ac3',
twoChAudioBitrate: 192,
twoChAudioRate: 48000,
twoChAudioEncoder: 'ac3',
fivePointOneChAudioBitrate: 336,
fivePointOneChAudioRate: 48000,
fivePointOneChAudioEncoder: 'ac3',
sixPointOneChAudioBitrate: 350,
sixPointOneChAudioRate: 48000,
sixPointOneChAudioEncoder: 'ac3',
transcodeSixPointOneAudioToFivePointOne: false,
logFfmpeg: false,
args: `-threads 4
-ss STARTTIME
-re
-i INPUTFILE
-t DURATION
-map VIDEOSTREAM
-map AUDIOSTREAM
-c:v libx264
-c:a ac3
-ac 2
-ar 48000
-b:a 192k
-b:v 10000k
-s 1280x720
-r 30
-flags cgop+ilme
-sc_threshold 1000000000
-minrate:v 10000k
-maxrate:v 10000k
-bufsize:v 1000k
-metadata service_provider="PseudoTV"
-metadata CHANNELNAME
-f mpegts
-output_ts_offset TSOFFSET
-muxdelay 0
-muxpreload 0
OUTPUTFILE`
videoBufSize: 2000,
enableAutoPlay: true,
breakStreamOnCodecChange: true,
logFfmpeg: true
})
let ffmpeg = db['ffmpeg-settings'].find()[0]
res.send(ffmpeg)
})
// PLEX SETTINGS
router.get('/api/plex-settings', (req, res) => {
let plex = db['plex-settings'].find()[0]
res.send(plex)
})
router.put('/api/plex-settings', (req, res) => {
db['plex-settings'].update({ _id: req.body._id }, req.body)
let plex = db['plex-settings'].find()[0]
res.send(plex)
})
router.post('/api/plex-settings', (req, res) => { // RESET
db['plex-settings'].update({ _id: req.body._id }, {
directStreamBitrate: '40000',
transcodeBitrate: '3000',
maxPlayableResolution: "1920x1080",
maxTranscodeResolution: "1920x1080",
enableHEVC: true,
audioCodecs: 'ac3,aac,mp3',
maxAudioChannels: '6',
enableSubtitles: false,
subtitleSize: '100',
updatePlayStatus: false
})
let plex = db['plex-settings'].find()[0]
res.send(plex)
})
router.get('/api/xmltv-last-refresh', (req, res) => {
res.send(JSON.stringify({ value: xmltvInterval.lastUpdated.valueOf() }))
})
@ -201,4 +183,4 @@ OUTPUTFILE`
}
return router
}
}

View File

@ -5,188 +5,64 @@ const fs = require('fs')
class FFMPEG extends events.EventEmitter {
constructor(opts, channel) {
super()
this.offset = 0
this.args = []
this.opts = opts
this.channel = channel
this.ffmpegPath = opts.ffmpegPath
let lines = opts.args.split('\n')
for (let i = 0, l = lines.length; i < l; i++) {
let x = lines[i].indexOf(' ')
if (x === -1)
this.args.push(lines[i])
else {
this.args.push(lines[i].substring(0, x))
this.args.push(lines[i].substring(x + 1, lines[i].length))
}
}
}
// This is used to generate ass subtitles from text subs to be used with the ass filter in ffmpeg.
createSubsFromStream(file, startTime, duration, streamIndex, output) {
if (process.env.DEBUG) console.log('Generating .ass subtitles')
let exe = spawn(this.ffmpegPath, [
'-threads', this.opts.threads,
'-ss', startTime,
'-i', file,
'-t', duration,
'-map', `0:${streamIndex}`,
'-f', 'ass',
output
])
return new Promise((resolve, reject) => {
if (this.opts.logFfmpeg) {
exe.stderr.on('data', (chunk) => {
process.stderr.write(chunk)
})
}
exe.on('close', (code) => {
if (code === 0) {
if (process.env.DEBUG) console.log('Successfully generated .ass subtitles')
resolve()
} else {
console.log('Failed generating .ass subtitles.')
reject()
}
})
})
}
async spawn(lineupItem) {
let videoIndex = lineupItem.opts.videoIndex
let audioIndex = lineupItem.opts.audioIndex
let subtitleIndex = lineupItem.opts.subtitleIndex
let uniqSubFileName = Date.now().valueOf().toString()
async spawn(streamUrl, duration, enableIcon, videoResolution) {
let ffmpegArgs = [`-threads`, this.opts.threads];
for (let i = 0, l = lineupItem.streams.length; i < l; i++) {
if (videoIndex === '-1' && lineupItem.streams[i].streamType === 1)
if (lineupItem.streams[i].default)
videoIndex = i
if (audioIndex === '-1' && lineupItem.streams[i].streamType === 2)
if (lineupItem.streams[i].default || lineupItem.streams[i].selected)
audioIndex = i
if (subtitleIndex === '-1' && lineupItem.streams[i].streamType === 3)
if (lineupItem.streams[i].default || lineupItem.streams[i].forced)
subtitleIndex = i
}
if (duration > 0)
ffmpegArgs.push(`-t`, duration);
// if for some reason we didn't find a default track, let ffmpeg decide..
if (videoIndex === '-1')
videoIndex = 'v'
if (audioIndex === '-1')
audioIndex = 'a'
ffmpegArgs.push(`-re`, `-i`, streamUrl);
let sub = (subtitleIndex === '-1' || subtitleIndex === '-2') ? null : lineupItem.streams[subtitleIndex]
if (enableIcon == true) {
if (process.env.DEBUG) console.log('Channel Icon Overlay Enabled')
let tmpargs = JSON.parse(JSON.stringify(this.args))
let startTime = tmpargs.indexOf('STARTTIME')
let dur = tmpargs.indexOf('DURATION')
let input = tmpargs.indexOf('INPUTFILE')
let vidStream = tmpargs.indexOf('VIDEOSTREAM')
let output = tmpargs.indexOf('OUTPUTFILE')
let tsoffset = tmpargs.indexOf('TSOFFSET')
let audStream = tmpargs.indexOf('AUDIOSTREAM')
let chanName = tmpargs.indexOf('CHANNELNAME')
tmpargs[startTime] = lineupItem.start / 1000
tmpargs[dur] = lineupItem.duration / 1000
tmpargs[input] = lineupItem.file
tmpargs[audStream] = `0:${audioIndex}`
tmpargs[chanName] = `service_name="${this.channel.name}"`
tmpargs[tsoffset] = this.offset
tmpargs[output] = 'pipe:1'
if (this.opts.videoStreamMode == "transcodeVideo") {
let iconOverlay = `[0:${videoIndex}]null`
let deinterlace = 'null'
let posAry = [ '20:20', 'W-w-20:20', '20:H-h-20', 'W-w-20:H-h-20'] // top-left, top-right, bottom-left, bottom-right (with 20px padding)
let icnDur = ''
if (this.channel.iconDuration > 0)
icnDur = `:enable='between(t,0,${this.channel.iconDuration})'`
if (this.channel.icon !== '' && this.channel.overlayIcon && lineupItem.type === 'program') {
iconOverlay = `[1:v]scale=${this.channel.iconWidth}:-1[icn];[0:${videoIndex}][icn]overlay=${posAry[this.channel.iconPosition]}${icnDur}`
if (process.env.DEBUG) console.log('Channel Icon Overlay Enabled')
let iconOverlay = `[1:v]scale=${this.channel.iconWidth}:-1[icn];[0:v][icn]overlay=${posAry[this.channel.iconPosition]}${icnDur}[outv]`
// Only scale video if specified, don't upscale video
if (this.opts.videoResolutionHeight != "unchanged" && parseInt(this.opts.videoResolutionHeight, 10) < parseInt(videoResolution, 10)) {
iconOverlay = `[0:v]scale=-2:${this.opts.videoResolutionHeight}[scaled];[1:v]scale=${this.channel.iconWidth}:-1[icn];[scaled][icn]overlay=${posAry[this.channel.iconPosition]}${icnDur}[outv]`
}
if (videoIndex !== 'v') {
if (typeof lineupItem.streams[videoIndex].scanType === 'undefined' || lineupItem.streams[videoIndex].scanType !== 'progressive') {
deinterlace = 'yadif'
if (process.env.DEBUG) console.log('Deinterlacing Video')
}
}
if (sub === null || lineupItem.type === 'commercial') { // No subs or icon overlays for Commercials
tmpargs[vidStream] = '[v]'
tmpargs.splice(vidStream - 1, 0, "-filter_complex", `${iconOverlay}[v1];[v1]${deinterlace}[v]`)
console.log("No Subtitles")
} else if (sub.codec === 'pgs') { // If program has PGS subs
tmpargs[vidStream] = '[v]'
if (typeof sub.index === 'undefined') { // If external subs
console.log("PGS SUBS (external) - Not implemented..")
tmpargs.splice(vidStream - 1, 0, "-filter_complex", `${iconOverlay}[v1];[v1]${deinterlace}[v]`)
} else { // Otherwise, internal/embeded pgs subs
console.log("PGS SUBS (embeded)")
tmpargs.splice(vidStream - 1, 0, "-filter_complex", `${iconOverlay}[v2];[v2]${deinterlace}[v1];[v1][0:${sub.index}]overlay[v]`)
}
} else if (sub.codec === 'srt' || sub.codec === 'ass') {
tmpargs[vidStream] = '[v]'
if (typeof sub.index === 'undefined') {
console.log("SRT SUBS (external)")
await this.createSubsFromStream(sub.key, lineupItem.start / 1000, lineupItem.duration / 1000, 0, `${process.env.DATABASE}/${uniqSubFileName}.ass`)
tmpargs.splice(vidStream - 1, 0, "-filter_complex", `${iconOverlay}[v1];[v1]${deinterlace},ass=${process.env.DATABASE}/${uniqSubFileName}.ass[v]`)
} else {
console.log("SRT SUBS (embeded) - This may take a few seconds..")
await this.createSubsFromStream(lineupItem.file, lineupItem.start / 1000, lineupItem.duration / 1000, sub.index, `${process.env.DATABASE}/${uniqSubFileName}.ass`)
tmpargs.splice(vidStream - 1, 0, "-filter_complex", `${iconOverlay}[v1];[v1]${deinterlace},ass=${process.env.DATABASE}/${uniqSubFileName}.ass[v]`)
}
} else { // Can't do VobSub's as plex only hosts the .idx file, there is no access to the .sub file.. Who the fuck uses VobSubs anyways.. SRT/ASS FTW
tmpargs[vidStream] = '[v]'
tmpargs.splice(vidStream - 1, 0, "-filter_complex", `${iconOverlay}[v1];[v1]${deinterlace}[v]`)
console.log("No Compatible Subtitles")
}
if (this.channel.icon !== '' && this.channel.overlayIcon && lineupItem.type === 'program') // Add the channel icon to ffmpeg input if enabled
tmpargs.splice(vidStream - 1, 0, '-i', this.channel.icon)
ffmpegArgs.push(`-i`, `${this.channel.icon}`,
`-filter_complex`, iconOverlay,
`-map`, `[outv]`,
`-c:v`, this.opts.videoEncoder,
`-flags`, `cgop+ilme`,
`-sc_threshold`, `1000000000`,
`-b:v`, `${this.opts.videoBitrate}k`,
`-minrate:v`, `${this.opts.videoBitrate}k`,
`-maxrate:v`, `${this.opts.videoBitrate}k`,
`-bufsize:v`, `${this.opts.videoBufSize}k`,
`-map`, `0:a`,
`-c:a`, `copy`);
} else {
ffmpegArgs.push(`-c`, `copy`);
}
if (this.opts.audioStreamMode === "transcodeAudioBestMatch") {
let audChannels = lineupItem.streams[audioIndex].channels
let audCodec = lineupItem.streams[audioIndex].codec
let audSettingsIndex = tmpargs.indexOf('AUDIOBESTMATCHSETTINGS')
ffmpegArgs.push(`-metadata`,
`service_provider="PseudoTV"`,
`-metadata`,
`service_name="${this.channel.name}"`,
`-f`,
`mpegts`,
`-output_ts_offset`,
`0`,
`-muxdelay`,
`0`,
`-muxpreload`,
`0`,
`pipe:1`);
console.log("Transcoding audio by best match")
console.log(" Audio Channels: " + audChannels)
console.log(" Audio Codec: " + audCodec)
// don't transcode audio if audio format is known to work
if (this.opts.reduceAudioTranscodes && (audCodec === "aac" || audCodec === "ac3") && audChannels <= 6) {
tmpargs.splice(audSettingsIndex, 1, "copy")
console.log(" Decision: Copied existing audio")
} else if (audChannels === 7 && this.opts.transcodeSixPointOneAudioToFivePointOne === false) {
if ((audCodec === "aac" || audCodec === "ac3") && this.opts.reduceAudioTranscodes) {
tmpargs.splice(audSettingsIndex, 1, "copy")
console.log(" Decision: Copied existing audio")
} else {
tmpargs.splice(audSettingsIndex, 1, this.opts.sixPointOneChAudioEncoder, "-ac", "7", "-ar", this.opts.sixPointOneChAudioRate, "-b:a", this.opts.sixPointOneChAudioBitrate + "k")
console.log(" Decision: Transcoding audio to 6.1 - " + this.opts.sixPointOneChAudioEncoder)
}
} else if (audChannels >= 6) {
if ((audCodec === "aac" || audCodec === "ac3") && this.opts.reduceAudioTranscodes) {
tmpargs.splice(audSettingsIndex, 1, audCodec, "-ac", "6")
console.log(" Decision: Transcoding audio to 5.1 - " + audCodec)
} else {
tmpargs.splice(audSettingsIndex, 1, this.opts.fivePointOneChAudioEncoder, "-ac", "6", "-ar", this.opts.fivePointOneChAudioRate, "-b:a", this.opts.fivePointOneChAudioBitrate + "k")
console.log(" Decision: Transcoding audio to 5.1 - " + this.opts.fivePointOneChAudioEncoder)
}
} else if (audChannels === 1) {
tmpargs.splice(audSettingsIndex, 1, this.opts.oneChAudioEncoder, "-ac", "1", "-ar", this.opts.oneChAudioRate, "-b:a", this.opts.oneChAudioBitrate + "k")
console.log(" Decision: Transcoding audio to 1.0 - " + this.opts.onePointOneChAudioEncoder)
} else { // Last transcoding scenario, just go to 2 channel audio, boost center channel if specified
tmpargs.splice(audSettingsIndex, 1, this.opts.twoChAudioEncoder, "-ac", "2", "-ar", this.opts.twoChAudioRate, "-b:a", this.opts.twoChAudioBitrate + "k")
console.log(" Decision: Transcoding audio to 2.0 - " + this.opts.twoChAudioEncoder)
}
}
this.offset += lineupItem.duration / 1000
this.ffmpeg = spawn(this.ffmpegPath, tmpargs)
this.ffmpeg = spawn(this.ffmpegPath, ffmpegArgs)
this.ffmpeg.stdout.on('data', (chunk) => {
this.emit('data', chunk)
})
@ -196,8 +72,6 @@ class FFMPEG extends events.EventEmitter {
})
}
this.ffmpeg.on('close', (code) => {
if (fs.existsSync(`${process.env.DATABASE}/${uniqSubFileName}.ass`))
fs.unlinkSync(`${process.env.DATABASE}/${uniqSubFileName}.ass`)
if (code === null)
this.emit('close', code)
else if (code === 0)
@ -205,7 +79,7 @@ class FFMPEG extends events.EventEmitter {
else if (code === 255)
this.emit('close', code)
else
this.emit('error', { code: code, cmd: `${this.opts.ffmpegPath} ${tmpargs.join(' ')}` })
this.emit('error', { code: code, cmd: `${this.opts.ffmpegPath} ${ffmpegArgs.join(' ')}` })
})
}
kill() {

View File

@ -1,6 +1,7 @@
module.exports = {
getCurrentProgramAndTimeElapsed: getCurrentProgramAndTimeElapsed,
createLineup: createLineup
createLineup: createLineup,
isChannelIconEnabled: isChannelIconEnabled
}
function getCurrentProgramAndTimeElapsed(date, channel) {
@ -25,6 +26,12 @@ function getCurrentProgramAndTimeElapsed(date, channel) {
function createLineup(obj) {
let timeElapsed = obj.timeElapsed
// Start time of a file is never consistent unless 0. Run time of an episode can vary.
// When within 30 seconds of start time, just make the time 0 to smooth things out
// Helps prevents loosing first few seconds of an episode upon lineup change
if (timeElapsed < 30000) {
timeElapsed = 0
}
let activeProgram = obj.program
let lineup = []
let programStartTimes = [0, activeProgram.actualDuration * .25, activeProgram.actualDuration * .50, activeProgram.actualDuration * .75, activeProgram.actualDuration]
@ -40,23 +47,25 @@ function createLineup(obj) {
foundFirstVideo = true // We found the fucker
lineup.push({
type: 'commercial',
file: commercials[i][y].file,
streams: commercials[i][y].streams,
key: commercials[i][y].key,
ratingKey: commercials[i][y].ratingKey,
start: timeElapsed, // start time will be the time elapsed, cause this is the first video
duration: commercials[i][y].duration - timeElapsed, // duration set accordingly
opts: commercials[i][y].opts
streamDuration: commercials[i][y].duration - timeElapsed, // stream duration set accordingly
duration: commercials[i][y].duration,
server: commercials[i][y].server
})
} else if (foundFirstVideo) { // Otherwise, if weve already found the starting video
lineup.push({ // just add the video, starting at 0, playing the entire duration
type: 'commercial',
file: commercials[i][y].file,
streams: commercials[i][y].streams,
key: commercials[i][y].key,
ratingKey: commercials[i][y].ratingKey,
start: 0,
duration: commercials[i][y].duration,
opts: commercials[i][y].opts
streamDuration: commercials[i][y].actualDuration,
duration: commercials[i][y].actualDuration,
server: commercials[i][y].server
})
} else { // Otherwise, this bitch has already been played.. Reduce the time elapsed by its duration
timeElapsed -= commercials[i][y].duration
timeElapsed -= commercials[i][y].actualDuration
}
}
if (i < l - 1) { // The last commercial slot is END, so dont write a program..
@ -64,23 +73,25 @@ function createLineup(obj) {
foundFirstVideo = true
lineup.push({
type: 'program',
file: activeProgram.file,
streams: activeProgram.streams,
key: activeProgram.key,
ratingKey: activeProgram.ratingKey,
start: progTimeElapsed + timeElapsed, // add the duration of already played program chunks to the timeElapsed
duration: (programStartTimes[i + 1] - programStartTimes[i]) - timeElapsed,
opts: activeProgram.opts
streamDuration: (programStartTimes[i + 1] - programStartTimes[i]) - timeElapsed,
duration: activeProgram.actualDuration,
server: activeProgram.server
})
} else if (foundFirstVideo) {
if (lineup[lineup.length - 1].type === 'program') { // merge consecutive programs..
lineup[lineup.length - 1].duration += (programStartTimes[i + 1] - programStartTimes[i])
lineup[lineup.length - 1].streamDuration += (programStartTimes[i + 1] - programStartTimes[i])
} else {
lineup.push({
type: 'program',
file: activeProgram.file,
streams: activeProgram.streams,
key: activeProgram.key,
ratingKey: activeProgram.ratingKey,
start: programStartTimes[i],
duration: (programStartTimes[i + 1] - programStartTimes[i]),
opts: activeProgram.opts
streamDuration: (programStartTimes[i + 1] - programStartTimes[i]),
duration: activeProgram.actualDuration,
server: activeProgram.server
})
}
} else {
@ -90,4 +101,8 @@ function createLineup(obj) {
}
}
return lineup
}
function isChannelIconEnabled(enableChannelOverlay, icon, overlayIcon, type) {
return enableChannelOverlay == true && icon !== '' && overlayIcon && type === 'program'
}

211
src/plexTranscoder.js Normal file
View File

@ -0,0 +1,211 @@
const { v4: uuidv4 } = require('uuid');
const fetch = require('node-fetch');
class PlexTranscoder {
constructor(settings, lineupItem) {
this.session = uuidv4()
this.settings = settings
this.key = lineupItem.key
this.ratingKey = lineupItem.ratingKey
this.currTimeMs = lineupItem.start
this.currTimeS = this.currTimeMs / 1000
this.duration = lineupItem.duration
this.server = lineupItem.server
this.transcodingArgs = undefined
this.decisionJson = undefined
this.updateInterval = 30000
this.updatingPlex = undefined
this.playState = "stopped"
}
async getStreamUrl(deinterlace) {
// Set transcoding parameters based off direct stream params
this.setTranscodingArgs(true, deinterlace)
await this.getDecision();
const videoIsDirectStream = this.isVideoDirectStream();
// Change transcoding arguments to be the user chosen transcode parameters
if (videoIsDirectStream == false) {
this.setTranscodingArgs(false, deinterlace)
// Update transcode decision for session
await this.getDecision();
}
return `${this.server.protocol}://${this.server.host}:${this.server.port}/video/:/transcode/universal/start.m3u8?${this.transcodingArgs}`
}
setTranscodingArgs(directStream, deinterlace) {
let resolution = (directStream == true) ? this.settings.maxPlayableResolution : this.settings.maxTranscodeResolution
let bitrate = (directStream == true) ? this.settings.directStreamBitrate : this.settings.transcodeBitrate
let videoCodecs = (this.settings.enableHEVC == true) ? "h264,hevc" : "h264"
let subtitles = (this.settings.enableSubtitles == true) ? "burn" : "none" // subtitle options: burn, none, embedded, sidecar
let videoQuality=`100` // Not sure how this applies, maybe this works if maxVideoBitrate is not set
let audioBoost=`100` // only applies when downmixing to stereo I believe, add option later?
let mediaBufferSize=`30720` // Not sure what this should be set to
let profileName=`Generic` // Blank profile, everything is specified through X-Plex-Client-Profile-Extra
let resolutionArr = resolution.split("x")
let clientProfileHLS=`add-transcode-target(type=videoProfile&protocol=hls&container=mpegts&videoCodec=${videoCodecs}&audioCodec=${this.settings.audioCodecs}&subtitleCodec=&context=streaming&replace=true)+\
add-transcode-target-settings(type=videoProfile&context=streaming&protocol=hls&CopyMatroskaAttachments=true)+\
add-limitation(scope=videoCodec&scopeName=*&type=upperBound&name=video.width&value=${resolutionArr[0]})+\
add-limitation(scope=videoCodec&scopeName=*&type=upperBound&name=video.height&value=${resolutionArr[1]})`
// Set transcode settings per audio codec
this.settings.audioCodecs.split(",").forEach(function (codec) {
clientProfileHLS+=`+add-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=hls&audioCodec=${codec})`
if (codec == "mp3") {
clientProfileHLS+=`+add-limitation(scope=videoAudioCodec&scopeName=${codec}type=upperBound&name=audio.channels&value=2)`
} else {
clientProfileHLS+=`+add-limitation(scope=videoAudioCodec&scopeName=${codec}type=upperBound&name=audio.channels&value=${this.settings.maxAudioChannels})`
}
}.bind(this));
// deinterlace video if specified, only useful if overlaying channel logo later
if (deinterlace == true) {
clientProfileHLS+=`+add-limitation(scope=videoCodec&scopeName=*&type=notMatch&name=video.scanType&value=interlaced)`
}
let clientProfileHLS_enc=encodeURIComponent(clientProfileHLS)
this.transcodingArgs=`X-Plex-Platform=${profileName}&\
X-Plex-Client-Platform=${profileName}&\
X-Plex-Client-Profile-Name=${profileName}&\
X-Plex-Platform=${profileName}&\
X-Plex-Token=${this.server.token}&\
X-Plex-Client-Profile-Extra=${clientProfileHLS_enc}&\
protocol=hls&\
Connection=keep-alive&\
hasMDE=1&\
path=${this.key}&\
mediaIndex=0&\
partIndex=0&\
fastSeek=1&\
directPlay=0&\
directStream=1&\
directStreamAudio=1&\
copyts=1&\
audioBoost=${audioBoost}&\
mediaBufferSize=${mediaBufferSize}&\
session=${this.session}&\
offset=${this.currTimeS}&\
subtitles=${subtitles}&\
subtitleSize=${this.settings.subtitleSize}&\
maxVideoBitrate=${bitrate}&\
videoQuality=${videoQuality}&\
videoResolution=${resolution}&\
lang=en`
}
isVideoDirectStream() {
return this.decisionJson["MediaContainer"]["Metadata"][0]["Media"][0]["Part"][0]["Stream"][0]["decision"] == "copy";
}
getResolutionHeight() {
return this.decisionJson["MediaContainer"]["Metadata"][0]["Media"][0]["Part"][0]["Stream"][0]["height"];
}
getVideoStats(channelIconEnabled, ffmpegEncoderName) {
let ret = []
let streams = this.decisionJson["MediaContainer"]["Metadata"][0]["Media"][0]["Part"][0]["Stream"]
streams.forEach(function (stream) {
// Video
if (stream["streamType"] == "1") {
ret.push(stream["width"],
stream["height"],
Math.round(stream["frameRate"]))
// Rounding framerate avoids scenarios where
// 29.9999999 & 30 don't match. Probably close enough
// to continue the stream as is.
// Implies future transcoding
if (channelIconEnabled == true)
if (ffmpegEncoderName.includes('mpeg2'))
ret.push("mpeg2video")
else if (ffmpegEncoderName.includes("264"))
ret.push("h264")
else if (ffmpegEncoderName.includes("hevc") || ffmpegEncoderName.includes("265"))
ret.push("hevc")
else
ret.push("unknown")
else
ret.push(stream["codec"])
}
// Audio. Only look at stream being used
if (stream["streamType"] == "2" && stream["selected"] == "1")
ret.push(stream["channels"], stream["codec"])
})
return ret
}
async getDecision() {
const response = await fetch(`${this.server.protocol}://${this.server.host}:${this.server.port}/video/:/transcode/universal/decision?${this.transcodingArgs}`, {
method: 'GET', headers: {
Accept: 'application/json'
}
});
this.decisionJson = await response.json();
}
getStatusUrl() {
let profileName=`Generic`;
let containerKey=`/video/:/transcode/universal/decision?${this.transcodingArgs}`;
let containerKey_enc=encodeURIComponent(containerKey);
let statusUrl=`${this.server.protocol}://${this.server.host}:${this.server.port}/:/timeline?\
containerKey=${containerKey_enc}&\
ratingKey=${this.ratingKey}&\
state=${this.playState}&\
key=${this.key}&\
time=${this.currTimeMs}&\
duration=${this.duration}&\
X-Plex-Platform=${profileName}&\
X-Plex-Client-Platform=${profileName}&\
X-Plex-Client-Profile-Name=${profileName}&\
X-Plex-Device-Name=PseudoTV-Plex&\
X-Plex-Device=PseudoTV-Plex&\
X-Plex-Client-Identifier=${this.session}&\
X-Plex-Platform=${profileName}&\
X-Plex-Token=${this.server.token}`;
return statusUrl;
}
startUpdatingPlex() {
if (this.settings.updatePlayStatus == true) {
this.playState = "playing";
this.updatePlex(); // do initial update
this.updatingPlex = setInterval(this.updatePlex.bind(this), this.updateInterval);
}
}
stopUpdatingPlex() {
if (this.settings.updatePlayStatus == true) {
clearInterval(this.updatingPlex);
this.playState = "stopped";
this.updatePlex();
}
}
updatePlex() {
let postUrl = this.getStatusUrl();
fetch(postUrl, {
method: 'POST'
});
this.currTimeMs += this.updateInterval;
if (this.currTimeMs > this.duration) {
this.currTimeMs = this.duration;
}
this.currTimeS = this.duration / 1000;
}
}
module.exports = PlexTranscoder

View File

@ -2,6 +2,7 @@ const express = require('express')
const helperFuncs = require('./helperFuncs')
const FFMPEG = require('./ffmpeg')
const FFMPEG_TEXT = require('./ffmpegText')
const PlexTranscoder = require('./plexTranscoder')
const fs = require('fs')
module.exports = { router: video }
@ -55,7 +56,9 @@ function video(db) {
// Get video lineup (array of video urls with calculated start times and durations.)
let prog = helperFuncs.getCurrentProgramAndTimeElapsed(Date.now(), channel)
let lineup = helperFuncs.createLineup(prog)
let lineupItem = lineup.shift()
let ffmpegSettings = db['ffmpeg-settings'].find()[0]
let plexSettings = db['plex-settings'].find()[0]
// Check if ffmpeg path is valid
if (!fs.existsSync(ffmpegSettings.ffmpegPath)) {
@ -66,35 +69,84 @@ function video(db) {
console.log(`\r\nStream starting. Channel: ${channel.number} (${channel.name})`)
let ffmpeg = new FFMPEG(ffmpegSettings, channel) // Set the transcoder options
let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options
let plexTranscoder = new PlexTranscoder(plexSettings, lineupItem);
let enableChannelIcon = helperFuncs.isChannelIconEnabled(ffmpegSettings.enableChannelOverlay, channel.icon, channel.overlayIcon, lineupItem.type)
let deinterlace = enableChannelIcon // Tell plex to deinterlace video if channel overlay is enabled
ffmpeg.on('data', (data) => { res.write(data) })
ffmpeg.on('error', (err) => {
console.error("FFMPEG ERROR", err)
res.status(500).send("FFMPEG ERROR")
return
plexTranscoder.stopUpdatingPlex();
console.error("FFMPEG ERROR", err);
res.status(500).send("FFMPEG ERROR");
return;
})
ffmpeg.on('close', () => {
res.send()
res.send();
})
ffmpeg.on('end', () => { // On finish transcode - END of program or commercial...
if (lineup.length === 0) { // refresh the expired program/lineup
prog = helperFuncs.getCurrentProgramAndTimeElapsed(Date.now(), channel)
lineup = helperFuncs.createLineup(prog)
plexTranscoder.stopUpdatingPlex();
if (ffmpegSettings.enableAutoPlay == true) {
oldVideoStats = plexTranscoder.getVideoStats(enableChannelIcon, ffmpegSettings.videoEncoder);
if (lineup.length === 0) { // refresh the expired program/lineup
prog = helperFuncs.getCurrentProgramAndTimeElapsed(Date.now(), channel);
lineup = helperFuncs.createLineup(prog);
}
lineupItem = lineup.shift();
enableChannelIcon = helperFuncs.isChannelIconEnabled(ffmpegSettings.enableChannelOverlay, channel.icon, channel.overlayIcon, lineupItem.type)
deinterlace = enableChannelIcon
streamDuration = lineupItem.streamDuration / 1000;
// Only episode in this lineup, or item is a commercial, let stream end naturally
if (lineup.length === 0 || lineupItem.type === 'commercial' || lineup.length === 1 && lineup[0].type === 'commercial')
streamDuration = -1
plexTranscoder = new PlexTranscoder(plexSettings, lineupItem);
plexTranscoder.getStreamUrl(deinterlace).then(
function(streamUrl) {
newVideoStats = plexTranscoder.getVideoStats(enableChannelIcon);
// Start stream if stats are the same. Changing codecs mid stream is not good
if (ffmpegSettings.breakStreamOnCodecChange == false || oldVideoStats.length == newVideoStats.length
&& oldVideoStats.every(function(u, i) {
return u === newVideoStats[i];
})
) {
ffmpeg.spawn(streamUrl, streamDuration, enableChannelIcon, plexTranscoder.getResolutionHeight());
plexTranscoder.startUpdatingPlex();
} else {
console.log(`\r\nEnding Stream, video or audio format has changed. Channel: ${channel.number} (${channel.name})`);
console.log(` Old Stream: ${oldVideoStats}`);
console.log(` New Stream: ${newVideoStats}`);
ffmpeg.kill();
}
});
} else {
console.log(`\r\nEnding Stream, autoplay is disabled. Channel: ${channel.number} (${channel.name})`);
ffmpeg.kill();
}
ffmpeg.spawn(lineup.shift(), prog.program) // Spawn the next ffmpeg process
})
res.on('close', () => { // on HTTP close, kill ffmpeg
ffmpeg.kill()
console.log(`\r\nStream ended. Channel: ${channel.number} (${channel.name})`)
plexTranscoder.stopUpdatingPlex();
console.log(`\r\nStream ended. Channel: ${channel.number} (${channel.name})`);
ffmpeg.kill();
})
ffmpeg.spawn(lineup.shift(), prog.program) // Spawn the ffmpeg process, fire this bitch up
let streamDuration = lineupItem.streamDuration / 1000;
// Only episode in this lineup, or item is a commercial, let stream end naturally
if (lineup.length === 0 || lineupItem.type === 'commercial' || lineup.length === 1 && lineup[0].type === 'commercial')
streamDuration = -1
plexTranscoder.getStreamUrl(deinterlace).then(streamUrl => ffmpeg.spawn(streamUrl, streamDuration, enableChannelIcon, plexTranscoder.getResolutionHeight())); // Spawn the ffmpeg process, fire this bitch up
plexTranscoder.startUpdatingPlex();
})
return router
}

View File

@ -8,95 +8,33 @@
link: function (scope, element, attrs) {
pseudotv.getFfmpegSettings().then((settings) => {
scope.settings = settings
if (typeof scope.settings.args === 'undefined')
scope.createArgString()
})
scope.updateSettings = (settings) => {
pseudotv.updateFfmpegSettings(settings).then((_settings) => {
scope.settings = _settings
if (typeof scope.settings.args === 'undefined')
scope.createArgString()
})
}
scope.resetSettings = (settings) => {
pseudotv.resetFfmpegSettings(settings).then((_settings) => {
scope.settings = _settings
if (typeof scope.settings.args === 'undefined')
scope.createArgString()
})
}
scope.createArgString = () => {
if (scope.settings.videoStreamMode == 'transcodeVideo') {
scope.settings.videoArgs = `-c:v ${ scope.settings.videoEncoder }
-b:v ${ scope.settings.videoBitrate }k
-s ${ scope.settings.videoResolution }
-r ${ scope.settings.videoFrameRate }
-flags cgop+ilme
-sc_threshold 1000000000
-minrate:v ${ scope.settings.videoBitrate }k
-maxrate:v ${ scope.settings.videoBitrate }k
-bufsize:v ${ scope.settings.videoBufSize }k
-flags cgop+ilme
-sc_threshold 1000000000
-minrate:v ${ scope.settings.videoBitrate }k
-maxrate:v ${ scope.settings.videoBitrate }k
-bufsize:v ${ scope.settings.videoBufSize }k`
} else {
scope.settings.videoArgs = `-c:v copy`
}
if (scope.settings.audioStreamMode == 'transcodeAudio') {
scope.settings.audioArgs = `-c:a ${ scope.settings.audioEncoder }
-ac ${ scope.settings.audioChannels }
-ar ${ scope.settings.audioRate }
-b:a ${ scope.settings.audioBitrate }k`
} else if (scope.settings.audioStreamMode == 'transcodeAudioBestMatch') {
scope.settings.audioArgs = `-c:a AUDIOBESTMATCHSETTINGS`
} else {
scope.settings.audioArgs = `-c:a copy`
}
scope.settings.args = `-threads ${ scope.settings.threads }
-ss STARTTIME
-t DURATION
-re
-i INPUTFILE${ scope.settings.deinterlace ? `\n-vf yadif` : `` }
-map VIDEOSTREAM
-map AUDIOSTREAM
${scope.settings.videoArgs}
${scope.settings.audioArgs}
-metadata service_provider="PseudoTV"
-metadata CHANNELNAME
-f mpegts
-output_ts_offset TSOFFSET
-muxdelay 0
-muxpreload 0
OUTPUTFILE`
}
scope.videoStreamOptions=[
{id:"transcodeVideo",description:"Transcode"},
{id:"directStreamVideo",description:"Direct Stream"}
scope.hideIfNotEnableChannelOverlay = () => {
return scope.settings.enableChannelOverlay != true
};
scope.hideIfNotAutoPlay = () => {
return scope.settings.enableAutoPlay != true
};
scope.resolutionOptions=[
{id:"420",description:"420x420"},
{id:"320",description:"576x320"},
{id:"480",description:"720x480"},
{id:"768",description:"1024x768"},
{id:"720",description:"1280x720"},
{id:"1080",description:"1920x1080"},
{id:"2160",description:"3840x2160"},
{id:"unchanged",description:"Same as source"}
];
scope.hideIfNotTranscodeVideo = () => {
return scope.settings.videoStreamMode != 'transcodeVideo'
};
scope.hideIfNotDirectStreamVideo = () => {
return scope.settings.videoStreamMode != 'directStreamVideo'
};
scope.audioStreamOptions=[
{id:"transcodeAudio",description:"Transcode"},
{id:"transcodeAudioBestMatch",description:"Transcode based on source channels"},
{id:"directStreamAudio",description:"Direct Stream"}
];
scope.hideIfNotTranscodeAudio2ch = () => {
return scope.settings.audioStreamMode != 'transcodeAudio'
};
scope.hideIfNotTranscodeAudioBestMatch = () => {
return scope.settings.audioStreamMode != 'transcodeAudioBestMatch'
};
scope.hideIfNotDirectStreamAudio = () => {
return scope.settings.audioStreamMode != 'directStreamAudio'
};
}
}
}

View File

@ -56,6 +56,38 @@ module.exports = function (plex, pseudotv, $timeout) {
scope.toggleVisiblity = function () {
scope.visible = !scope.visible
}
pseudotv.getPlexSettings().then((settings) => {
scope.settings = settings
})
scope.updateSettings = (settings) => {
pseudotv.updatePlexSettings(settings).then((_settings) => {
scope.settings = _settings
})
}
scope.resetSettings = (settings) => {
pseudotv.resetPlexSettings(settings).then((_settings) => {
scope.settings = _settings
})
}
scope.maxAudioChannelsOptions=[
{id:"1",description:"1.0"},
{id:"2",description:"2.0"},
{id:"3",description:"2.1"},
{id:"4",description:"4.0"},
{id:"5",description:"5.0"},
{id:"6",description:"5.1"},
{id:"7",description:"6.1"},
{id:"8",description:"7.1"}
];
scope.resolutionOptions=[
{id:"420x420",description:"420x420"},
{id:"576x320",description:"576x320"},
{id:"720x480",description:"720x480"},
{id:"1024x768",description:"1024x768"},
{id:"1280x720",description:"1280x720"},
{id:"1920x1080",description:"1920x1080"},
{id:"3840x2160",description:"3840x2160"}
];
}
};
}

View File

@ -45,4 +45,4 @@ module.exports = function ($timeout) {
}
}
};
}
}

View File

@ -15,7 +15,7 @@
<div class="row">
<div class="col-sm-4">
<label>Threads</label>
<input type="number" class="form-control form-control-sm" ng-model="settings.threads" ng-change="createArgString()"/>
<input type="number" class="form-control form-control-sm" ng-model="settings.threads"/>
</div>
<div class="col-sm-4">
<label>Log FFMPEG</label><br/>
@ -25,111 +25,45 @@
<hr/>
<div class="row">
<div class="col-sm-6">
<h6>Video Stream Mode</h6>
<select ng-model="settings.videoStreamMode"
ng-options="o.id as o.description for o in videoStreamOptions"
ng-change="createArgString()"/>
<div class="form-group">
<input id="enableChannelOverlay" type="checkbox" ng-model="settings.enableChannelOverlay" ria-describedby="enableChannelOverlayHelp"/>
<label for="enableChannelOverlay">Enable Channel Overlay (Requires Transcoding)</label>
<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>
</div>
</div>
<div class="col-sm-6" ng-hide="hideIfNotTranscodeVideo()">
<h6>Video Options</h6>
<label>Video Encoder</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.videoEncoder" ng-change="createArgString()"/>
<label>Video Bitrate (k)</label>
<input type="number" class="form-control form-control-sm" ng-model="settings.videoBitrate" ng-change="createArgString()"/>
<label>Video Resolution</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.videoResolution" ng-change="createArgString()"/>
<label>Video Framerate</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.videoFrameRate" ng-change="createArgString()"/>
<label>Video Buffer Size (k)</label>
<input type="number" class="form-control form-control-sm" ng-model="settings.videoBufSize" ng-change="createArgString()"/>
</div>
<div class="col-sm-6" ng-hide="hideIfNotDirectStreamVideo()">
<small>Notice: Under no circumstances, will video transcode. This means subtitles and channel logos are not supported.</small>
<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>
</div>
</div>
<hr/>
<div class="row">
<div class="col-sm-6">
<h6>Audio Stream Mode</h6>
<select ng-model="settings.audioStreamMode"
ng-options="o.id as o.description for o in audioStreamOptions"
ng-change="createArgString()"/>
</div>
<div class="col-sm-6" ng-hide="hideIfNotTranscodeAudio2ch()">
<h6>Audio Options</h6>
<label>Audio Encoder</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.audioEncoder" ng-change="createArgString()"/>
<label>Audio Channels</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.audioChannels" ng-change="createArgString()"/>
<label>Audio Bitrate (k)</label>
<input type="number" class="form-control form-control-sm" ng-model="settings.audioBitrate" ng-change="createArgString()"/>
<label>Audio Rate</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.audioRate" ng-change="createArgString()"/>
</div>
<div class="col-sm-6" ng-hide="hideIfNotDirectStreamAudio()">
<small>Notice: If a audio codec is not supported by your player you may run into issues (no audio, video won't play, etc).</small>
</div>
<div class="col-sm-6" ng-hide="hideIfNotTranscodeAudioBestMatch()">
<small>Notice: In this mode audio with more channels than 6.1 will be transcoded down to 5.1.</small>
</div>
</div>
<br ng-hide="hideIfNotTranscodeAudioBestMatch()">
<div class="row" ng-hide="hideIfNotTranscodeAudioBestMatch()">
<div class="col-sm-6">
<input id="reduceAudioTranscodes" type="checkbox" ng-model="settings.reduceAudioTranscodes"/>
<label for="reduceAudioTranscodes">Don't transcode if known working format</label>
<small>(5.1 channels or less & AAC/AC3)</small>
</div>
</div>
<br ng-hide="hideIfNotTranscodeAudioBestMatch()">
<div class="row" ng-hide="hideIfNotTranscodeAudioBestMatch()">
<div class="col-sm-6">
<h6>1.0 Audio Options</h6>
<label>Audio Encoder</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.oneChAudioEncoder" ng-change="createArgString()"/>
<label>Audio Bitrate (k)</label>
<input type="number" class="form-control form-control-sm" ng-model="settings.oneChAudioBitrate" ng-change="createArgString()"/>
<label>Audio Rate</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.oneChAudioRate" ng-change="createArgString()"/>
</div>
<div class="col-sm-6">
<h6>2.0 Audio Options</h6>
<label>Audio Encoder</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.twoChAudioEncoder" ng-change="createArgString()"/>
<label>Audio Bitrate (k)</label>
<input type="number" class="form-control form-control-sm" ng-model="settings.twoChAudioBitrate" ng-change="createArgString()"/>
<label>Audio Rate</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.twoChAudioRate" ng-change="createArgString()"/>
</div>
</div>
<br ng-hide="hideIfNotTranscodeAudioBestMatch()">
<div class="row" ng-hide="hideIfNotTranscodeAudioBestMatch()">
<div class="col-sm-6">
<h6>5.1 Audio Options</h6>
<label>Audio Encoder</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.fivePointOneChAudioEncoder" ng-change="createArgString()"/>
<label>Audio Bitrate (k)</label>
<input type="number" class="form-control form-control-sm" ng-model="settings.fivePointOneChAudioBitrate" ng-change="createArgString()"/>
<label>Audio Rate</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.fivePointOneChAudioRate" ng-change="createArgString()"/>
</div>
<div class="col-sm-6">
<h6>6.1 Audio Options</h6>
<input id="transcodeSixPointOneAudioToFivePointOne" type="checkbox" ng-model="settings.transcodeSixPointOneAudioToFivePointOne"/>
<label for="reduceAudioTranscodes">Transcode 6.1 to 5.1</label>
<br>
<label>Audio Encoder</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.sixPointOneChAudioEncoder" ng-change="createArgString()" />
<label>Audio Bitrate (k)</label>
<input type="number" class="form-control form-control-sm" ng-model="settings.sixPointOneChAudioBitrate" ng-change="createArgString()"/>
<label>Audio Rate</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.sixPointOneChAudioRate" ng-change="createArgString()"/>
</div>
</div>
<hr/>
<div class="row">
<h6>Raw FFMPEG Arguments <small>Modifying options above will reset the raw arguments. Therefore edit these last...</small></h6>
<textarea class="form-control" style="height: 350px" ng-model="settings.args"></textarea>
</div>
<hr/>
</div>
</div>

View File

@ -76,4 +76,77 @@
</tr>
</table>
</div>
<hr>
<h6>Plex Transcoder Settings
<button class="pull-right btn btn-sm btn-success" style="margin-left: 5px;" ng-click="updateSettings(settings)">
Update
</button>
<button class="pull-right btn btn-sm btn-info" ng-click="resetSettings(settings)">
Reset Options
</button>
</h6>
<hr>
<div class="row" >
<div class="col-sm-6">
<h6 style="font-weight: bold">Video Options</h6>
<div class="form-group">
<label>Max Playable Resolution</label>
<select ng-model="settings.maxPlayableResolution"
ng-options="o.id as o.description for o in resolutionOptions" />
</div>
<div class="form-group">
<label>Max Transcode Resolution</label>
<select ng-model="settings.maxTranscodeResolution"
ng-options="o.id as o.description for o in resolutionOptions "/>
</div>
<div class="form-group">
<input id="enableHEVC" type="checkbox" ng-model="settings.enableHEVC"/>
<label for="enableHEVC">Enable H265</label>
</div>
</div>
<div class="col-sm-6">
<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" />
</div>
<div class="form-group">
<label>Maximum Audio Channels</label>
<select ng-model="settings.maxAudioChannels"
ng-options="o.id as o.description for o in maxAudioChannelsOptions" ria-describedby="maxAudioChannelsHelp"/>
<small id="maxAudioChannelsHelp" class="form-text text-muted">Note: 7.1 audio and on some clients, 6.1, is known to cause playback issues.</small>
</div>
</div>
</div>
<div class="row">
<div class="col-sm-6">
<h6 style="font-weight: bold">Miscellaneous Options</h6>
<div class="form-group">
<label>Max Direct Stream Bitrate</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.directStreamBitrate" />
</div>
<div class="form-group">
<label>Max Transcode Bitrate</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.transcodeBitrate" />
</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>
<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">
<h6 style="font-weight: bold">Subtitle Options</h6>
<div class="form-group">
<label>Subtitle Size</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.subtitleSize" />
</div>
<div class="form-group">
<input id="enableSubtitles" type="checkbox" ng-model="settings.enableSubtitles"/>
<label for="enableSubtitles">Enable Subtitles (Requires Transcoding)</label>
</div>
</div>
</div>
</div>

View File

@ -76,33 +76,6 @@
</div>
</div>
</div>
<div>
<h6>Streams</h6>
<div class="row">
<div class="col-md-4">
<label for="videoStream">Video Track</label><br/>
<select class="form-control form-control-sm" ng-model="program.opts.videoIndex">
<option value="-1">Plex Default</option>
<option ng-repeat="x in program.streams" ng-if="x.streamType === 1" value="{{x.index}}">{{x.displayTitle}}</option>
</select>
</div>
<div class="col-md-4">
<label for="audioStream">Audio Track</label><br/>
<select class="form-control form-control-sm" ng-model="program.opts.audioIndex">
<option value="-1">Plex Default</option>
<option ng-repeat="x in program.streams" ng-if="x.streamType === 2" value="{{x.index}}">{{x.displayTitle}}</option>
</select>
</div>
<div class="col-md-4">
<label for="subtitleStream">Subtitle Track</label><br/>
<select class="form-control form-control-sm" ng-model="program.opts.subtitleIndex">
<option value="-2">No Subtitles</option>
<option value="-1">Plex Default</option>
<option ng-repeat="x in program.streams" ng-if="x.streamType === 3" value="{{$index}}">{{x.displayTitle}}</option>
</select>
</div>
</div>
</div>
<div>
<h6 style="margin-top: 10px;">Commercials
<button class="btn btn-sm btn-primary pull-right" ng-click="showPlexLibrary = true">

View File

@ -112,6 +112,7 @@ module.exports = function ($http, $window, $interval) {
var program = {
title: res.Metadata[i].title,
key: res.Metadata[i].key,
ratingKey: res.Metadata[i].ratingKey,
server: server,
icon: `${server.protocol}://${server.host}:${server.port}${res.Metadata[i].thumb}?X-Plex-Token=${server.token}`,
type: res.Metadata[i].type,
@ -124,10 +125,6 @@ module.exports = function ($http, $window, $interval) {
date: res.Metadata[i].originallyAvailableAt,
year: res.Metadata[i].year,
}
if (program.type === 'episode' || program.type === 'movie') {
program.file = `${server.protocol}://${server.host}:${server.port}${res.Metadata[i].Media[0].Part[0].key}?X-Plex-Token=${server.token}`
program.opts = { deinterlace: false, videoIndex: '-1', audioIndex: '-1', subtitleIndex: '-2' }
}
if (program.type === 'episode') {
program.showTitle = res.Metadata[i].grandparentTitle
program.episode = res.Metadata[i].index

View File

@ -19,6 +19,25 @@ module.exports = function ($http) {
headers: { 'Content-Type': 'application/json; charset=utf-8' }
}).then((d) => { return d.data })
},
getPlexSettings: () => {
return $http.get('/api/plex-settings').then((d) => { return d.data })
},
updatePlexSettings: (config) => {
return $http({
method: 'PUT',
url: '/api/plex-settings',
data: angular.toJson(config),
headers: { 'Content-Type': 'application/json; charset=utf-8' }
}).then((d) => { return d.data })
},
resetPlexSettings: (config) => {
return $http({
method: 'POST',
url: '/api/plex-settings',
data: angular.toJson(config),
headers: { 'Content-Type': 'application/json; charset=utf-8' }
}).then((d) => { return d.data })
},
getFfmpegSettings: () => {
return $http.get('/api/ffmpeg-settings').then((d) => { return d.data })
},