const { v4: uuidv4 } = require('uuid'); const axios = require('axios'); 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.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" } async getStream(deinterlace) { let stream = {directPlay: false} this.log("Getting stream") this.log(` deinterlace: ${deinterlace}`) this.log(` streamPath: ${this.settings.streamPath}`) if (this.settings.streamPath === 'direct' || this.settings.forceDirectPlay) { if (this.settings.enableSubtitles) { console.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) await this.getDecision(stream.directPlay); if (this.isDirectPlay()) { stream.directPlay = true; stream.streamUrl = this.plexFile; } } catch (err) { this.log("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.") 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) // Update transcode decision for session await this.getDecision(stream.directPlay); stream.streamUrl = (this.settings.streamPath === 'direct') ? this.file : this.plexFile; 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) // 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}` } 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) { 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 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=`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]})` // 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=1&\ 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.log("Error at decision:" + e); return false; } } isAV1() { try { return this.getVideoStats().videoCodec === 'av1'; } catch (e) { return false; } } isDirectPlay() { try { return this.getVideoStats().videoDecision === "copy" && this.getVideoStats().audioDecision === "copy"; } catch (e) { console.log("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) { // Video if (stream["streamType"] == "1") { ret.anamorphic = (stream.anamorphic === "1"); 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; } // 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; } }) } catch (e) { console.log("Error at decision:" + e); } 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.log("Error at get media info:" + e); } }) .catch((err) => { console.log(err); }); this.log(`Found audio index: ${index}`) return index } async getDecision(directPlay) { await axios.get(`${this.server.uri}/video/:/transcode/universal/decision?${this.transcodingArgs}`, { headers: { Accept: 'application/json' } }) .then((res) => { this.decisionJson = res.data; this.log("Recieved transcode decision:") this.log(res.data) // Print error message if transcode not possible // TODO: handle failure better let transcodeDecisionCode = res.data.MediaContainer.transcodeDecisionCode if (!(directPlay || transcodeDecisionCode == "1001")) { console.log(`IMPORTANT: Recieved transcode decision code ${transcodeDecisionCode}! Expected code 1001.`) console.log(`Error message: '${res.data.MediaContainer.transcodeDecisionText}'`) } }) } 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") axios.post(this.getStatusUrl()); 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), } } module.exports = PlexTranscoder