dizquetv/src/plexTranscoder.js

481 lines
19 KiB
JavaScript

const { v4: uuidv4 } = require('uuid');
const axios = require('axios');
const fs = require('fs');
class PlexTranscoder {
constructor(clientId, server, settings, channel, lineupItem) {
this.session = uuidv4()
this.device = "channel-" + channel.number;
this.deviceName = this.device;
this.clientIdentifier = clientId;
this.product = "dizqueTV";
this.settings = settings
this.log("Plex transcoder initiated")
this.log("Debug logging enabled")
this.key = lineupItem.key
this.metadataPath = `${server.uri}${lineupItem.key}?X-Plex-Token=${server.accessToken}`
this.plexFile = `${server.uri}${lineupItem.plexFile}?X-Plex-Token=${server.accessToken}`
if (typeof(lineupItem.file)!=='undefined') {
this.file = lineupItem.file.replace(settings.pathReplace, settings.pathReplaceWith)
}
this.transcodeUrlBase = `${server.uri}/video/:/transcode/universal/start.m3u8?`
this.ratingKey = lineupItem.ratingKey
this.currTimeMs = lineupItem.start
this.currTimeS = this.currTimeMs / 1000
this.duration = lineupItem.duration
this.server = server
this.transcodingArgs = undefined
this.decisionJson = undefined
this.updateInterval = 30000
this.updatingPlex = undefined
this.playState = "stopped"
this.mediaHasNoVideo = false;
this.albumArt = {
attempted : false,
path: null,
}
}
async getStream(deinterlace) {
let stream = {directPlay: false}
this.log("Getting stream")
this.log(` deinterlace: ${deinterlace}`)
this.log(` streamPath: ${this.settings.streamPath}`)
this.setTranscodingArgs(stream.directPlay, true, false, false);
await this.tryToDetectAudioOnly();
if (this.settings.streamPath === 'direct' || this.settings.forceDirectPlay) {
if (this.settings.enableSubtitles) {
this.log("Direct play is forced, so subtitles are forcibly disabled.");
this.settings.enableSubtitles = false;
}
stream = {directPlay: true}
} else {
try {
this.log("Setting transcoding parameters")
this.setTranscodingArgs(stream.directPlay, true, deinterlace, this.mediaHasNoVideo)
await this.getDecision(stream.directPlay);
if (this.isDirectPlay()) {
stream.directPlay = true;
stream.streamUrl = this.plexFile;
}
} catch (err) {
console.error("Error when getting decision. 1. Check Plex connection. 2. This might also be a sign that plex direct play and transcode settings are too strict and it can't find any allowed action for the selected video.", err)
stream.directPlay = true;
}
}
if (stream.directPlay || this.isAV1() ) {
if (! stream.directPlay) {
this.log("Plex doesn't support av1, so we are forcing direct play, including for audio because otherwise plex breaks the stream.")
}
this.log("Direct play forced or native paths enabled")
stream.directPlay = true
this.setTranscodingArgs(stream.directPlay, true, false, this.mediaHasNoVideo )
// Update transcode decision for session
await this.getDecision(stream.directPlay);
stream.streamUrl = (this.settings.streamPath === 'direct') ? this.file : this.plexFile;
if(this.settings.streamPath === 'direct') {
fs.access(this.file, fs.F_OK, (err) => {
if (err) {
throw Error("Can't access this file", err);
return
}
})
}
if (typeof(stream.streamUrl) == 'undefined') {
throw Error("Direct path playback is not possible for this program because it was registered at a time when the direct path settings were not set. To fix this, you must either revert the direct path setting or rebuild this channel.");
}
} else if (this.isVideoDirectStream() === false) {
this.log("Decision: Should transcode")
// Change transcoding arguments to be the user chosen transcode parameters
this.setTranscodingArgs(stream.directPlay, false, deinterlace, this.mediaHasNoVideo)
// Update transcode decision for session
await this.getDecision(stream.directPlay);
stream.streamUrl = `${this.transcodeUrlBase}${this.transcodingArgs}`
} else {
//This case sounds complex. Apparently plex is sending us just the audio, so we would need to get the video in a separate stream.
this.log("Decision: Direct stream. Audio is being transcoded")
stream.separateVideoStream = (this.settings.streamPath === 'direct') ? this.file : this.plexFile;
stream.streamUrl = `${this.transcodeUrlBase}${this.transcodingArgs}`
this.directInfo = await this.getDirectInfo();
this.videoIsDirect = true;
}
stream.streamStats = this.getVideoStats();
// use correct audio stream if direct play
stream.streamStats.audioIndex = (stream.directPlay) ? ( await this.getAudioIndex() ) : 'a'
this.log(stream)
return stream
}
setTranscodingArgs(directPlay, directStream, deinterlace, audioOnly) {
let resolution = (directStream) ? this.settings.maxPlayableResolution : this.settings.maxTranscodeResolution
let bitrate = (directStream) ? this.settings.directStreamBitrate : this.settings.transcodeBitrate
let mediaBufferSize = (directStream) ? this.settings.mediaBufferSize : this.settings.transcodeMediaBufferSize
let subtitles = (this.settings.enableSubtitles) ? "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 isDirectPlay = (directPlay) ? '1' : '0';
let hasMDE = '1';
let videoQuality=`100` // Not sure how this applies, maybe this works if maxVideoBitrate is not set
let profileName=`Generic` // Blank profile, everything is specified through X-Plex-Client-Profile-Extra
let resolutionArr = resolution.split("x")
let vc = this.settings.videoCodecs;
//This codec is not currently supported by plex so requesting it to transcode will always
// cause an error. If Plex ever supports av1, remove this. I guess.
if (vc != '') {
vc += ",av1";
} else {
vc = "av1";
}
let clientProfile ="";
if (! audioOnly ) {
clientProfile=`add-transcode-target(type=videoProfile&protocol=${this.settings.streamProtocol}&container=${streamContainer}&videoCodec=${vc}&audioCodec=${this.settings.audioCodecs}&subtitleCodec=&context=streaming&replace=true)+\
add-transcode-target-settings(type=videoProfile&context=streaming&protocol=${this.settings.streamProtocol}&CopyMatroskaAttachments=true)+\
add-transcode-target-settings(type=videoProfile&context=streaming&protocol=${this.settings.streamProtocol}&BreakNonKeyframes=true)+\
add-limitation(scope=videoCodec&scopeName=*&type=upperBound&name=video.width&value=${resolutionArr[0]})+\
add-limitation(scope=videoCodec&scopeName=*&type=upperBound&name=video.height&value=${resolutionArr[1]})`
} else {
clientProfile=`add-transcode-target(type=musicProfile&protocol=${this.settings.streamProtocol}&container=${streamContainer}&audioCodec=${this.settings.audioCodecs}&subtitleCodec=&context=streaming&replace=true)`
}
// 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-Product=${this.product}&\
X-Plex-Client-Platform=${profileName}&\
X-Plex-Client-Profile-Name=${profileName}&\
X-Plex-Device-Name=${this.deviceName}&\
X-Plex-Device=${this.device}&\
X-Plex-Client-Identifier=${this.clientIdentifier}&\
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=${hasMDE}&\
path=${this.key}&\
mediaIndex=0&\
partIndex=0&\
fastSeek=1&\
directPlay=${isDirectPlay}&\
directStream=1&\
directStreamAudio=1&\
copyts=1&\
audioBoost=${this.settings.audioBoost}&\
mediaBufferSize=${mediaBufferSize}&\
session=${this.session}&\
offset=${this.currTimeS}&\
subtitles=${subtitles}&\
subtitleSize=${this.settings.subtitleSize}&\
maxVideoBitrate=${bitrate}&\
videoQuality=${videoQuality}&\
videoResolution=${resolution}&\
lang=en`
}
isVideoDirectStream() {
try {
return this.getVideoStats().videoDecision === "copy";
} catch (e) {
console.error("Error at decision:", e);
return false;
}
}
isAV1() {
try {
return this.getVideoStats().videoCodec === 'av1';
} catch (e) {
return false;
}
}
isDirectPlay() {
try {
if (this.getVideoStats().audioOnly) {
return this.getVideoStats().audioDecision === "copy";
}
return this.getVideoStats().videoDecision === "copy" && this.getVideoStats().audioDecision === "copy";
} catch (e) {
console.error("Error at decision:" , e);
return false;
}
}
getVideoStats() {
let ret = {}
try {
let streams = this.decisionJson.MediaContainer.Metadata[0].Media[0].Part[0].Stream
ret.duration = parseFloat( this.decisionJson.MediaContainer.Metadata[0].Media[0].Part[0].duration );
streams.forEach(function (_stream, $index) {
// Video
let stream = _stream;
if (stream["streamType"] == "1") {
if ( this.videoIsDirect === true && typeof(this.directInfo) !== 'undefined') {
stream = this.directInfo.MediaContainer.Metadata[0].Media[0].Part[0].Stream[$index];
}
ret.anamorphic = ( (stream.anamorphic === "1") || (stream.anamorphic === true) );
if (ret.anamorphic) {
let parsed = parsePixelAspectRatio(stream.pixelAspectRatio);
if (isNaN(parsed.p) || isNaN(parsed.q) ) {
throw Error("isNaN");
}
ret.pixelP = parsed.p;
ret.pixelQ = parsed.q;
} else {
ret.pixelP= 1;
ret.pixelQ = 1;
}
ret.videoCodec = stream.codec;
ret.videoWidth = stream.width;
ret.videoHeight = stream.height;
ret.videoFramerate = Math.round(stream["frameRate"]);
// Rounding framerate avoids scenarios where
// 29.9999999 & 30 don't match.
ret.videoDecision = (typeof stream.decision === 'undefined') ? 'copy' : stream.decision;
ret.videoScanType = stream.scanType;
}
// Audio. Only look at stream being used
if (stream["streamType"] == "2" && stream["selected"] == "1") {
ret.audioChannels = stream["channels"];
ret.audioCodec = stream["codec"];
ret.audioDecision = (typeof stream.decision === 'undefined') ? 'copy' : stream.decision;
}
}.bind(this) )
} catch (e) {
console.error("Error at decision:" , e);
}
if (typeof(ret.videoCodec) === 'undefined') {
ret.audioOnly = true;
ret.placeholderImage = (this.albumArt.path != null) ?
ret.placeholderImage = this.albumArt.path
:
ret.placeholderImage = `http://localhost:${process.env.PORT}/images/generic-music-screen.png`
;
}
this.log("Current video stats:")
this.log(ret)
return ret
}
async getAudioIndex() {
let index = 'a'
await axios.get(`${this.server.uri}${this.key}?X-Plex-Token=${this.server.accessToken}`, {
headers: { Accept: 'application/json' }
})
.then((res) => {
this.log(res.data)
try {
let streams = res.data.MediaContainer.Metadata[0].Media[0].Part[0].Stream
streams.forEach(function (stream) {
// Audio. Only look at stream being used
if (stream["streamType"] == "2" && stream["selected"] == "1") {
index = stream.index
}
})
} catch (e) {
console.error("Error at get media info:" + e);
}
})
.catch((err) => {
console.error("Error getting audio index",err);
});
this.log(`Found audio index: ${index}`)
return index
}
async getDirectInfo() {
return (await axios.get(this.metadataPath) ).data;
}
async getDecisionUnmanaged(directPlay) {
let url = `${this.server.uri}/video/:/transcode/universal/decision?${this.transcodingArgs}`;
let res = await axios.get(url, {
headers: { Accept: 'application/json' }
})
this.decisionJson = res.data;
this.log("Received transcode decision:");
this.log(res.data)
// Print error message if transcode not possible
// TODO: handle failure better
if (res.data.MediaContainer.mdeDecisionCode === 1000) {
this.log("mde decision code 1000, so it's all right?");
return;
}
let transcodeDecisionCode = res.data.MediaContainer.transcodeDecisionCode;
if (
( typeof(transcodeDecisionCode) === 'undefined' )
) {
this.decisionJson.MediaContainer.transcodeDecisionCode = 'novideo';
this.log("Strange case, attempt direct play");
} else if (!(directPlay || transcodeDecisionCode == "1001")) {
this.log(`IMPORTANT: Recieved transcode decision code ${transcodeDecisionCode}! Expected code 1001.`)
this.log(`Error message: '${res.data.MediaContainer.transcodeDecisionText}'`)
}
}
async tryToDetectAudioOnly() {
try {
this.log("Try to detect audio only:");
let url = `${this.server.uri}${this.key}?${this.transcodingArgs}`;
let res = await axios.get(url, {
headers: { Accept: 'application/json' }
});
let mediaContainer = res.data.MediaContainer;
let metadata = getOneOrUndefined( mediaContainer, "Metadata");
if (typeof(metadata) !== 'undefined') {
this.albumArt.path = `${this.server.uri}${metadata.thumb}?X-Plex-Token=${this.server.accessToken}`;
let media = getOneOrUndefined( metadata, "Media");
if (typeof(media) !== 'undefined') {
if (typeof(media.videoCodec)==='undefined') {
this.log("Audio-only file detected");
this.mediaHasNoVideo = true;
}
}
}
} catch (err) {
console.error("Error when getting album art", err);
}
}
async getDecision(directPlay) {
try {
await this.getDecisionUnmanaged(directPlay);
} catch (err) {
console.error(err);
}
}
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-Product=${this.product}&\
X-Plex-Platform=${profileName}&\
X-Plex-Client-Platform=${profileName}&\
X-Plex-Client-Profile-Name=${profileName}&\
X-Plex-Device-Name=${this.deviceName}&\
X-Plex-Device=${this.device}&\
X-Plex-Client-Identifier=${this.clientIdentifier}&\
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() {
this.log("Updating plex status");
const statusUrl = this.getStatusUrl();
try {
axios.post(statusUrl);
} catch (error) {
this.log(`Problem updating Plex status using status URL ${statusUrl}:`);
this.log(error);
return false;
}
this.currTimeMs += this.updateInterval;
if (this.currTimeMs > this.duration) {
this.currTimeMs = this.duration;
}
this.currTimeS = this.duration / 1000;
}
log(message) {
if (this.settings.debugLogging) {
console.log(message)
}
}
}
function parsePixelAspectRatio(s) {
let x = s.split(":");
return {
p: parseInt(x[0], 10),
q: parseInt(x[1], 10),
}
}
function getOneOrUndefined(object, field) {
if (typeof(object) === 'undefined') {
return undefined;
}
if ( typeof(object[field]) === "undefined") {
return undefined;
}
let x = object[field];
if (x.length < 1) {
return undefined;
}
return x[0];
}
module.exports = PlexTranscoder