Sign in/Urls: Sign in now adds all servers; however refresh guide/refresh channels must be edited through the json. Now uses local/remote server https url instead of local 34440 port url with no cert. Video Playback: Remove code not enforcing time limit for streams. Make default stream protocol http instread of hls which was used previously. Add option to choose. Add option to specify video codecs (which is prone to user error). Added mpeg2video to default video codecs. Add option to specify direct stream/transcode media buffer size. Not sure how much of a difference this makes. Add in safeguard to ffmpeg's kill so failed streams don't crash the application M3Us: Add group-title="PseudoTV" for easier management in xteve
212 lines
8.3 KiB
JavaScript
212 lines
8.3 KiB
JavaScript
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.uri}/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 mediaBufferSize = (directStream == true) ? this.settings.mediaBufferSize : this.settings.transcodeMediaBufferSize
|
|
let subtitles = (this.settings.enableSubtitles == true) ? "burn" : "none" // subtitle options: burn, none, embedded, sidecar
|
|
let streamContainer = "mpegts" // Other option is mkv, mkv has the option of copying it's subs for later processing
|
|
|
|
let videoQuality=`100` // Not sure how this applies, maybe this works if maxVideoBitrate is not set
|
|
let audioBoost=`100` // only applies when downmixing to stereo I believe, add option later?
|
|
let profileName=`Generic` // Blank profile, everything is specified through X-Plex-Client-Profile-Extra
|
|
|
|
let resolutionArr = resolution.split("x")
|
|
|
|
let clientProfile=`add-transcode-target(type=videoProfile&protocol=${this.settings.streamProtocol}&container=${streamContainer}&videoCodec=${this.settings.videoCodecs}&audioCodec=${this.settings.audioCodecs}&subtitleCodec=&context=streaming&replace=true)+\
|
|
add-transcode-target-settings(type=videoProfile&context=streaming&protocol=${this.settings.streamProtocol}&CopyMatroskaAttachments=true)+\
|
|
add-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) {
|
|
clientProfile+=`+add-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=${this.settings.streamProtocol}&audioCodec=${codec})`
|
|
if (codec == "mp3") {
|
|
clientProfile+=`+add-limitation(scope=videoAudioCodec&scopeName=${codec}type=upperBound&name=audio.channels&value=2)`
|
|
} else {
|
|
clientProfile+=`+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) {
|
|
clientProfile+=`+add-limitation(scope=videoCodec&scopeName=*&type=notMatch&name=video.scanType&value=interlaced)`
|
|
}
|
|
|
|
let clientProfile_enc=encodeURIComponent(clientProfile)
|
|
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.accessToken}&\
|
|
X-Plex-Client-Profile-Extra=${clientProfile_enc}&\
|
|
protocol=${this.settings.streamProtocol}&\
|
|
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.uri}/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.uri}/:/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.accessToken}`;
|
|
|
|
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
|