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:
parent
cc6df415bf
commit
1848a9c432
79
index.js
79
index.js
@ -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({
|
||||
|
||||
@ -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"
|
||||
|
||||
90
src/api.js
90
src/api.js
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
210
src/ffmpeg.js
210
src/ffmpeg.js
@ -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() {
|
||||
|
||||
@ -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
211
src/plexTranscoder.js
Normal 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
|
||||
76
src/video.js
76
src/video.js
@ -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
|
||||
}
|
||||
|
||||
@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"}
|
||||
];
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -45,4 +45,4 @@ module.exports = function ($timeout) {
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 })
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user