diff --git a/Dockerfile b/Dockerfile index 18bd41c..af34134 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,6 @@ -FROM node:12.16 -RUN apt-get -y update && \ - apt-get -y upgrade && \ - apt-get install -y ffmpeg && \ - rm -rf /var/lib/apt/lists/* +FROM node:12.18-alpine3.12 +# Should be ffmpeg v4.2.3 +RUN apk add --no-cache ffmpeg && ffmpeg -version WORKDIR /home/node/app COPY package*.json ./ RUN npm install diff --git a/index.js b/index.js index a0b8d35..f21af88 100644 --- a/index.js +++ b/index.js @@ -113,8 +113,7 @@ function initDB(db) { videoResolutionHeight: 'unchanged', videoBitrate: 10000, videoBufSize: 2000, - enableAutoPlay: true, - breakStreamOnCodecChange: true, + concatMuxDelay: '0', logFfmpeg: true }) } diff --git a/package-lock.json b/package-lock.json index 01cdf62..2b2e50d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3200,7 +3200,7 @@ "follow-redirects": { "version": "1.5.10", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", - "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", + "integrity": "sha1-e3qfmuov3/NnhqlP9kPtB/T/Xio=", "requires": { "debug": "=3.1.0" }, @@ -7246,6 +7246,11 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" }, + "uuid": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.1.0.tgz", + "integrity": "sha512-CI18flHDznR0lq54xBycOVmphdCYnQLKn8abKn7PXUiKUGdEd+/l9LWNJmugXel4hXq7S+RMNl34ecyC9TntWg==" + }, "validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", diff --git a/src/api.js b/src/api.js index efba488..94b58f9 100644 --- a/src/api.js +++ b/src/api.js @@ -70,8 +70,7 @@ function api(db, xmltvInterval) { videoResolutionHeight: 'unchanged', videoBitrate: 10000, videoBufSize: 2000, - enableAutoPlay: true, - breakStreamOnCodecChange: true, + concatMuxDelay: '0', logFfmpeg: true }) let ffmpeg = db['ffmpeg-settings'].find()[0] diff --git a/src/ffmpeg.js b/src/ffmpeg.js index b9b8b83..0f7965c 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -9,14 +9,23 @@ class FFMPEG extends events.EventEmitter { this.channel = channel this.ffmpegPath = opts.ffmpegPath } - async spawn(streamUrl, duration, enableIcon, videoResolution) { + async spawn(streamUrl, streamStats, duration, enableIcon, type, isConcatPlaylist) { let ffmpegArgs = [`-threads`, this.opts.threads, - `-t`, duration, - `-re`, - `-fflags`, `+genpts`, - `-i`, streamUrl]; + `-re`, + `-fflags`, `+genpts+discardcorrupt+igndts`]; + + if (duration > 0) + ffmpegArgs.push(`-t`, duration) + + if (isConcatPlaylist == true) + ffmpegArgs.push(`-f`, `concat`, + `-safe`, `0`, + `-protocol_whitelist`, `file,http,tcp,https,tcp,tls`) - if (enableIcon == true) { + ffmpegArgs.push(`-i`, streamUrl) + + // Overlay icon + if (enableIcon && type === 'program') { if (process.env.DEBUG) console.log('Channel Icon Overlay Enabled') 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) @@ -27,39 +36,48 @@ class FFMPEG extends events.EventEmitter { 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)) { + if (this.opts.videoResolutionHeight != "unchanged" && streamStats.videoHeight != `undefined` && parseInt(this.opts.videoResolutionHeight, 10) < parseInt(streamStats.videoHeight, 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]` } 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`); - } + `-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`, + `-muxdelay`, `0`, + `-muxpreload`, `0`); + } else if (enableIcon && streamStats.videoCodec != this.opts.videoEncoder) { // Encode commercial if video codec does not match + ffmpegArgs.push(`-map`, `0`, + `-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`, + `-c:a`, `copy`, + `-muxdelay`, `0`, + `-muxpreload`, `0`); + } else + ffmpegArgs.push(`-map`, `0`, + `-c`, `copy`, + `-muxdelay`, this.opts.concatMuxDelay, + `-muxpreload`, this.opts.concatMuxDelay); ffmpegArgs.push(`-metadata`, - `service_provider="PseudoTV"`, - `-metadata`, - `service_name="${this.channel.name}"`, - `-f`, - `mpegts`, - `-output_ts_offset`, - `0`, - `-muxdelay`, - `0`, - `-muxpreload`, - `0`, - `pipe:1`); + `service_provider="PseudoTV"`, + `-metadata`, + `service_name="${this.channel.name}`, + `-f`, `mpegts`, + `pipe:1`) this.ffmpeg = spawn(this.ffmpegPath, ffmpegArgs) this.ffmpeg.stdout.on('data', (chunk) => { diff --git a/src/helperFuncs.js b/src/helperFuncs.js index c1b39d5..a972aff 100644 --- a/src/helperFuncs.js +++ b/src/helperFuncs.js @@ -19,8 +19,10 @@ function getCurrentProgramAndTimeElapsed(date, channel) { timeElapsed -= program.duration } } + if (currentProgramIndex === -1) throw new Error("No program found; find algorithm fucked up") + return { program: channel.programs[currentProgramIndex], timeElapsed: timeElapsed, programIndex: currentProgramIndex } } @@ -104,5 +106,7 @@ function createLineup(obj) { } function isChannelIconEnabled(enableChannelOverlay, icon, overlayIcon, type) { - return enableChannelOverlay == true && icon !== '' && overlayIcon && type === 'program' + if (typeof type === `undefined`) + return enableChannelOverlay == true && icon !== '' && overlayIcon + return enableChannelOverlay == true && icon !== '' && overlayIcon } \ No newline at end of file diff --git a/src/plexTranscoder.js b/src/plexTranscoder.js index af9d676..039ea08 100644 --- a/src/plexTranscoder.js +++ b/src/plexTranscoder.js @@ -22,6 +22,13 @@ class PlexTranscoder { this.playState = "stopped" } + async getStream(deinterlace) { + let stream = {} + stream.streamUrl = await this.getStreamUrl(deinterlace); + stream.streamStats = this.getVideoStats(); + return stream; + } + async getStreamUrl(deinterlace) { // Set transcoding parameters based off direct stream params this.setTranscodingArgs(true, deinterlace) @@ -45,7 +52,7 @@ class PlexTranscoder { 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 @@ -54,6 +61,7 @@ class PlexTranscoder { 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-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]})` @@ -110,36 +118,25 @@ lang=en` return this.decisionJson["MediaContainer"]["Metadata"][0]["Media"][0]["Part"][0]["Stream"][0]["height"]; } - getVideoStats(channelIconEnabled, ffmpegEncoderName) { - let ret = [] + getVideoStats() { + 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"]) + 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. } // Audio. Only look at stream being used - if (stream["streamType"] == "2" && stream["selected"] == "1") - ret.push(stream["channels"], stream["codec"]) + if (stream["streamType"] == "2" && stream["selected"] == "1") { + ret.audioChannels = stream["channels"]; + ret.audioCodec = stream["codec"]; + } }) return ret @@ -151,6 +148,14 @@ lang=en` }) .then((res) => { this.decisionJson = res.data; + + // Print error message if transcode not possible + // TODO: handle failure better + let transcodeDecisionCode = res.data.MediaContainer.transcodeDecisionCode + if (transcodeDecisionCode != "1001") { + console.log(`IMPORTANT: Recieved transcode decision code ${transcodeDecisionCode}! Expected code 1001.`) + console.log(`Error message: '${res.data.MediaContainer.transcodeDecisionText}'`) + } }) .catch((err) => { console.log(err); diff --git a/src/video.js b/src/video.js index ff36ae6..866f1a9 100644 --- a/src/video.js +++ b/src/video.js @@ -39,7 +39,7 @@ function video(db) { console.log(`\r\nStream ended. Channel: 1 (PseudoTV)`) }) }) - + // Continuously stream video to client. Leverage ffmpeg concat for piecing together videos router.get('/video', (req, res) => { // Check if channel queried is valid if (typeof req.query.channel === 'undefined') { @@ -53,10 +53,62 @@ function video(db) { } channel = channel[0] - // 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] + + // Check if ffmpeg path is valid + if (!fs.existsSync(ffmpegSettings.ffmpegPath)) { + res.status(500).send("FFMPEG path is invalid. The file (executable) doesn't exist.") + console.error("The FFMPEG Path is invalid. Please check your configuration.") + return + } + + res.writeHead(200, { + 'Content-Type': 'video/mp2t' + }) + + console.log(`\r\nStream starting. Channel: ${channel.number} (${channel.name})`) + + let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options + + ffmpeg.on('data', (data) => { res.write(data) }) + + ffmpeg.on('error', (err) => { + console.error("FFMPEG ERROR", err); + res.status(500).send("FFMPEG ERROR"); + return; + }) + + ffmpeg.on('close', () => { + res.send(); + }) + + res.on('close', () => { // on HTTP close, kill ffmpeg + console.log(`\r\nStream ended. Channel: ${channel.number} (${channel.name})`); + ffmpeg.kill(); + }) + + ffmpeg.on('end', () => { + console.log("Recieved end of stream when playing a continuous playlist. This should never happen!"); + console.log("This either means ffmpeg could not open any valid streams, or you've watched countless hours of television without changing channels. If it is the latter I salute you.") + }) + + let channelNum = parseInt(req.query.channel, 10) + ffmpeg.spawn(`http://localhost:${process.env.PORT}/playlist?channel=${channelNum}`, `undefined`, -1, false, `undefined`, true); + }) + // Stream individual video to ffmpeg concat above. This is used by the server, NOT the client + router.get('/stream', (req, res) => { + // Check if channel queried is valid + if (typeof req.query.channel === 'undefined') { + res.status(500).send("No Channel Specified") + return + } + let channel = db['channels'].find({ number: parseInt(req.query.channel, 10) }) + if (channel.length === 0) { + res.status(500).send("Channel doesn't exist") + return + } + channel = channel[0] + let ffmpegSettings = db['ffmpeg-settings'].find()[0] let plexSettings = db['plex-settings'].find()[0] @@ -67,79 +119,86 @@ function video(db) { return } - console.log(`\r\nStream starting. Channel: ${channel.number} (${channel.name})`) - - 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) => { - plexTranscoder.stopUpdatingPlex(); - console.error("FFMPEG ERROR", err); - res.status(500).send("FFMPEG ERROR"); - return; - }) - - ffmpeg.on('close', () => { - res.send(); - }) - - ffmpeg.on('end', () => { // On finish transcode - END of program or commercial... - 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; - - 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(); - } - }) - - res.on('close', () => { // on HTTP close, kill ffmpeg - plexTranscoder.stopUpdatingPlex(); - console.log(`\r\nStream ended. Channel: ${channel.number} (${channel.name})`); - ffmpeg.kill(); - }) + // 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 streamDuration = lineupItem.streamDuration / 1000; - - plexTranscoder.getStreamUrl(deinterlace).then(streamUrl => ffmpeg.spawn(streamUrl, streamDuration, enableChannelIcon, plexTranscoder.getResolutionHeight())); // Spawn the ffmpeg process, fire this bitch up - plexTranscoder.startUpdatingPlex(); + + // 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 + + let plexTranscoder = new PlexTranscoder(plexSettings, lineupItem); + + let deinterlace = enableChannelIcon = helperFuncs.isChannelIconEnabled(ffmpegSettings.enableChannelOverlay, channel.icon, channel.overlayIcon) + + plexTranscoder.getStream(deinterlace).then(stream => { + let streamUrl = stream.streamUrl + let streamStats = stream.streamStats + // Not time limited & no transcoding required. Pass plex transcode url directly + if (!enableChannelIcon && streamDuration === -1) { + res.redirect(streamUrl); + } else { // ffmpeg needed limit time or insert channel icon + res.writeHead(200, { + 'Content-Type': 'video/mp2t' + }) + + let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options + + ffmpeg.on('data', (data) => { res.write(data) }) + + ffmpeg.on('error', (err) => { + console.error("FFMPEG ERROR", err); + res.status(500).send("FFMPEG ERROR"); + return; + }) + + ffmpeg.on('close', () => { + res.send(); + }) + + ffmpeg.on('end', () => { // On finish transcode - END of program or commercial... + plexTranscoder.stopUpdatingPlex(); + res.end() + }) + + res.on('close', () => { // on HTTP close, kill ffmpeg + ffmpeg.kill(); + }) + + ffmpeg.spawn(streamUrl, streamStats, streamDuration, enableChannelIcon, lineupItem.type, false); // Spawn the ffmpeg process, fire this bitch up + plexTranscoder.startUpdatingPlex(); + } + }); + }) + router.get('/playlist', (req, res) => { + res.type('text') + + // Check if channel queried is valid + if (typeof req.query.channel === 'undefined') { + res.status(500).send("No Channel Specified") + return + } + + let channelNum = parseInt(req.query.channel, 10) + let channel = db['channels'].find({ number: channelNum }) + if (channel.length === 0) { + res.status(500).send("Channel doesn't exist") + return + } + + // Maximum number of streams to concatinate beyond channel starting + // If someone passes this number then they probably watch too much television + let maxStreamsToPlayInARow = 100; + + var data = "#ffconcat version 1.0\n" + + for (var i = 0; i < maxStreamsToPlayInARow; i++) + data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}'\n` + + res.send(data) }) return router } diff --git a/web/directives/ffmpeg-settings.js b/web/directives/ffmpeg-settings.js index 8ea4459..f8e2fcd 100644 --- a/web/directives/ffmpeg-settings.js +++ b/web/directives/ffmpeg-settings.js @@ -35,6 +35,14 @@ {id:"2160",description:"3840x2160"}, {id:"unchanged",description:"Same as source"} ]; + scope.muxDelayOptions=[ + {id:"0",description:"0 Seconds"}, + {id:"1",description:"1 Seconds"}, + {id:"2",description:"2 Seconds"}, + {id:"3",description:"3 Seconds"}, + {id:"4",description:"4 Seconds"}, + {id:"5",description:"5 Seconds"} + ]; } } } \ No newline at end of file diff --git a/web/public/templates/ffmpeg-settings.html b/web/public/templates/ffmpeg-settings.html index 26c0a9a..48b8dbd 100644 --- a/web/public/templates/ffmpeg-settings.html +++ b/web/public/templates/ffmpeg-settings.html @@ -9,17 +9,26 @@
FFMPEG Executable Path (eg: C:\ffmpeg\bin\ffmpeg.exe || /usr/bin/ffmpeg)
- + + FFMPEG version 4.2+ required. Check by running '{{settings.ffmpegPath}} -version' from the command line
Miscellaneous Options
- - +
+
+ + +
+
-
- +
+ +
+ + +

@@ -31,38 +40,37 @@ Note: This transcoding is done by PseudoTV, not Plex.
- - -
-
- - - Clients typically cannot handle resolution, video/audio codec, framerate, or audio channels changing between programs/commercials + + - Some possible values are: - Intel Quick Sync: h264_qsv, mpeg2_qsv - NVIDIA: GPU: h264_nvenc - MPEG2: mpeg2video (default) - H264: libx264 - MacOS: h264_videotoolbox -
-
- - -
-
- - +
+
+
+ + + Some possible values are: + Intel Quick Sync: h264_qsv, mpeg2_qsv + NVIDIA: GPU: h264_nvenc + MPEG2: mpeg2video (default) + H264: libx264 + MacOS: h264_videotoolbox +
+
+ + +
+
+ + +
diff --git a/web/public/templates/plex-settings.html b/web/public/templates/plex-settings.html index 879b119..ebc3cb5 100644 --- a/web/public/templates/plex-settings.html +++ b/web/public/templates/plex-settings.html @@ -36,6 +36,11 @@ + + +

Edit server values in plex-servers.json

+ +
@@ -102,7 +107,7 @@
- + Note: This affects the "on deck" for your plex account.