Reverse Engineering Plex: Casting High Bitrate Video to Chromecast
I am a big fan of Plex Media Server-- it has a great set of software, both server and client side, and is much easier to setup and use than alternatives such as XBMC. Attached to my ReadyNAS, my Plex server has access to 6 TB of storage.
I also have several Chromecast devices-- they're great little media streamer sticks that simply plug into your HDMI port on your TV. Using your phone as a remote, you can "cast" media from an app (such as Netflix, HBO, or Plex) and onto your TV. Chromecast also has a browser API, so Plex's website also allows you to cast media to your local TVs.
There's one major issue, however, in terms of compatibility between Plex and the Chromecast-- and it's not actually the Chromecast's fault. Plex, for whatever reason, has decided to limit the maximum bitrate of a video file to 12 mbps when casting to a Chromecast device. If you have a powerful PC running as your Plex server, this is fine-- the server software will transcode the higher bitrate videos on the fly to 12 mbps. But, I am using an old laptop that can barely transcode to 4 mbps, 720p video files, so the video playback stutters.
Plex claims this forced transcoding is due to "performance issues" with media over 12 mbps, but this is not true1. Not only have users casted media higher than 12 mbps from other apps, but I have successfully gotten around this hard coded limitation and streamed 20+ mbps video without a problem.
Note: This is a detailed post on how I figured out my workaround, and contains some technical material on advanced Javascript concepts. If you are an end user that just wants to perform the fix yourself, please see my separate how-to guide:
The Hard-Coded Problem
First, to understand the issue, you have to know how a Chromecast app and Plex work:
Essentially, when you hit play and stream to your Chromecast, Plex does a few things:
First, the media is checked against an XML based profile for your device that is located on your Plex server. This profile contains info about what device supports what features, and if the media is too high resolution or too high bitrate, Plex decides to transcode the video to a compatible format. The Chromecast file specifies a maximum bitrate of 12,000 kbps.
But, what if we change this XML profile to specify a maximum bitrate of 30,000 kbps? This would solve the problem for most devices, but this does not fix the issue for the Chromecast. Some people believe that they are fixing the issue by changing the XML, but this is not true. There is in fact a second place where this 12,000 kbps limitation is enforced, and it is not changeable by conventional means.
Chromecast apps are composed of two things-- a sender application, which can be a native mobile app or a web application for Chrome, and a receiver application that runs on the Chromecast. The Chromecast also has several types of receiver applications, including the default video player (which basically allows you to send a URL to it and it will use a default UI for playback) and a custom receiver. The custom receiver is essentially a web page and can run most code any web browser can, allowing you to style and program the application to do more than just play a simple video.
Plex has opted to use a custom receiver in order to display poster art on your screen, among other things. However, since custom applications can run arbitrary code, they have hard coded a 12,000 kbps limitation for videos, which overrides the XML profile on your server.
Don't believe me? Check for yourself-- the Plex Chromecast app is composed of several files: an HTML page, which serves as the view you see on your screen, and a Javascript file with logic for communicating with Plex2. These files are located at:
- https://plexapp.com/chromecast/qa/index.html
- https://plexapp.com/chromecast/qa/js/plex.js
In the plex.js
file, search for the line containing maxBitrate: 12000
. If you're a programmer, you can follow the code, but essentially this maximum bitrate overrides any other maximum bitrate sent to the Chromecast (such as the one that is sent to the Chromecast from the XML profile).
Notice the "qa" in the URL-- this is a "testing" version of the app which is not compressed, and therefore is readable to humans. This is also present in the production app, which is compressed.
- https://plexapp.com/chromecast/production/js/plex.js
You have to look for something a little bit different: "maxBitrate":12e3
. In case you're curious why this is, it is because the compressor (really, it is called a "minifier") converts the number 12,000 into the shorter "12e3", which is simply exponential notation for the same value. This allows the file to be smaller, and reduce network overhead and the time to download the Javascript application to your Chromecast. Many websites use this technique, and because the Chromecast is web based, Chromecast applications should do this as well.
Removing the Maximum Bitrate through Reverse Engineering
Now that we know where the limitation is and how it is enforced, we can try and remove it. This post is dedicated to detailing how I came up with my solution-- if you are just a Plex user that wants to play back high bitrate content, see my how-to guide.
There were a couple different ideas that popped into mind:
- Man-in-the-middle the Chromecast, and dynamically replace the value
- Modify the Plex server application to ignore the 12,000 kbps limitation from the Chromecast
- Modify the Chromecast application to remove the limitation
Man-in-the-middle
Essentially, a man-in-the-middle (MITM) "attack" is the practice of inserting a computer between a client and server with the intention of reading or modifying the data that is sent over a network. You can use this for malicious purposes, such as stealing unencrypted passwords over insecure WiFi, or for legitimate purposes such as debugging (i.e. looking at the network requests your app sends so that you can find and fix bugs).
This is a relatively complex method of removing a simple number from a file, so I explored some alternatives.
Modifying the Plex binary
Another way to "fix" the issue would be to modify the server software itself. This poses a couple challenges, however-- the debugging process, for me, would take quite a bit of time to find the point at which the code needs to be modifier, it requires re-applying your modifications every time the server software is updated.
Modifying the Chromecast app
Finally, I decided the best chance of fixing the issue would be to modify the Chromecast app itself. This would be similar to the MITM attack in that I would modify the source code of the Chromecast receiver application, but it would be a one-time fix versus doing it on the fly as I would have to do with a MITM.
Chromecast applications are vetted through a process similar to a native app store-- in order to use them on any Chromecast, you must submit the application and have it approved. However, developers can run whatever applications they want, similar to how you can register as an Apple developer and run your own applications without publishing them in the app store.
So, now that we know that we can run custom applications, all it takes is downloading the original Plex assets, re-uploading them, and removing the hard coded limitation.
Even though the application has now been modified and fixed, we still can't use it. On the sender side, Chromecast applications pass an application ID number to the Chromecast API in order to launch the app. The senders for Plex are located in their native apps (iOS and Android), as well as their web app. Because web browsers are much more open, I decided to tackle the web application.
Unfortunately, the application ID value is embedded deep in the application's Javascript. Plex also uses RequireJS, meaning that there is no way to simply override a global configuration variable.
The application ID is passed into the Chromecast API through the chrome.cast.SessionRequest
object, like so:
var sessionRequest = new chrome.cast.SessionRequest("ABC1234");
So, we now know the last point at which we can modify the application ID easily-- in the constructor for the session request.
Fortunately, this also gives us a vector through which we can inject a variable. Because the Chromecast API is located in the global namespace, any script can access and modify it.
We can obviously assign anything to the chrome.cast.SessionRequest
variable, and since "classes" in Javascript are essentially defined with functions, we can do something like this:
window.chrome.cast.SessionRequest = function(id, c, d) {
console.log(id);
};
Now, every time a new SessionRequest is created, the ID will simply be logged to the console. Of course, since we completely replaced the Chromecast API we actually just disabled the casting functionality entirely. To fix this, we can essentially try to subclass the SessionRequest to override the constructor. By doing so, our new object will retain all of the functionality of the old SessionRequest, but with the benefit of overriding the constructor to pass in our ID:
window.chrome.cast.SessionRequest = (function($parent) {
var SessionRequest = function() {
arguments[0] = "ABC1234";
$parent.constructor.apply(this, arguments);
};
SessionRequest.prototype = $parent;
return SessionRequest;
})(window.chrome.cast.SessionRequest.prototype);
The above code is relatively simple, though it may be a little foreign to those not familiar with how Javascript works. We are doing a couple things here:
- Wrapping the new object definition in an anonymous function so that we don't leak objects
- Passing the old prototype into the anonymous function as
$parent
- Creating a new object that will serve as our injection script
- Setting the prototype of our new injector to be the same as the old SessionRequest, which is similar to "inheriting" all of
$parent
's methods - Calling the "parent" constructor in our new constructor, with the arguments modified
The net result of this is a new object that behaves almost exactly like the old SessionRequest, but that injects and overrides the passed in application ID. We also assign this new object in place of the old SessionRequest, which means that all existing Chromecast code will continue to work.
To test this, I simply ran the injection code and started casting Plex to my Chromecast. With the developer "unlocked" Chromecast device, the injection code worked successfully and my device appeared in the popup menu for the Chromecast extension. After I hit "Start Casting", the custom application name appeared3 and what appeared to be the Plex app showed up on my TV.
Of course, to this point, I was injecting this code manually. This involved opening the web console, pasting the injection code, hitting refresh, and then hitting "enter" in the web console the run the code as soon as I saw the page load. This was hit or miss, and relied on me timing the button press correctly. Instead, I decided to make a simple Chrome extension that would inject the code for me.
Building the Chrome Extension
The Chrome extension was quite simple, but because I'd never written one before, I ran into the sandboxing security feature several times.
Though a "content script" extension can modify a page, it cannot access the same set of variables. In other words, I could not simply run the injection code from my content script and expect it to work, since the code would try and modify a property of window
that didn't exist in my sandbox.
To get around this, you can append a script tag to the page and execute the code in the context of the page itself:
var code = function() {
console.log(window);
};
var script = document.createElement('script');
script.textContent = '(' + code + ')()';
(document.head || document.documentElement).appendChild(script);
script.parentNode.removeChild(script);
This only works because of weird behavior in Javascript. When a function is cast to a string, you actually are returned the code that represents the function itself.
var test = function() {
console.log("Hello!");
}
console.log("" + test);
/*
"var test = function() {
console.log("Hello!");
}"
*/
With this behavior, we inject the function's code into the context of the actual Plex webpage, allowing access to the same window
variable the application uses and therefore allowing access to the Chromecast API.
Overwriting the Chromecast API
But, this is unfortunately only half the battle. Because the script is now injected just after the DOM has loaded, our code is executed before the Chromecast API is actually available. This means, if we run the Chrome extension as is, we will end up trying to overwrite some undefined variable.
To fix this, we can use the Chromecast extension's "on load" callback-- __onGCastApiAvailable
. This is a special function, that when defined, will be executed by the Chromecast API once it is loaded. This is normally where you write your own code to setup your Chromecast application, and it is also where Plex initializes their own code.
This, of course, poses a problem-- if we define the __onGCastApiAvailable
function, when the Plex application loads our code will simply be overwritten. Enter, Object.defineProperty
:
The Object.defineProperty
method allows us to essentially add a hook that runs when a property on an object is get or set. In this case, we want to say, "when Plex tries to set the Chromecast callback __onGCastApiAvailable
, then overwrite it with our own function". We can do this quite simply:
Object.defineProperty(window, '__onGCastApiAvailable', {
get: function() {
// Run our injection code here
return window.____onGCastApiAvailable;
},
set: function(a) {
window.____onGCastApiAvailable = a;
}
});
As you can see, we are defining a function to be run when the __onGCastApiAvailable
property on the window object is set and when it is retrieved. All we do is actually store Plex's specified callback function in another variable, with four underscores instead of two, and execute some code when the function is retrieved again.
Put together, our injection extension now hooks into the __onGCastApiAvailable
function to overwrite the Chromecast API.
In the actual Chrome extension, there's some additional code that creates a very basic options page and uses the message passing API to communicate between the different Chrome sandboxes. This allows us to remove the hard coded "new" Chromecast application ID, and allows the user to change it on the fly in the Chrome extensions page.
To view the actual Chrome extension's source, you can visit the GitHub page:
Putting it All Together
Once the Chrome extension was built, all that was left to do was to test it out. A good way to do so is to use the Jellyfish bitrate test files. The Jellyfish videos are a series of files with different bitrates.
You can try this test yourself-- download the 20 mbps Jellyfish video and add it to your Plex server. With the injection extension disabled, I couldn't even get the file to play-- Plex simply complained that there was an unspecified problem. With the extension enabled, however, Plex happily "Direct Play"s the file4 with absolutely no stuttering.
This was an interesting exercise in reverse engineering, and I definitely enjoyed the challenge. However, Plex needs to see the value in removing this hard coded limitation by default, and allow for users with different setups to tweak their servers for their specific needs.
Despite the limitation remaining in place, in the mean time, you can always perform the fix yourself and go back to enjoying your high bitrate media. Enjoy!
- If the Plex team is really running into performance problems, they need to re-evaluate their testing setup. Not only have I gotten high bitrate video working fantastically in Plex, as detailed in this writeup, but other users have used other applications to stream higher bitrate content to their Chromecast. ↩
- There are many people that don't realize that changing the XML is useless in the Chromecast's case. You can verify this on your own Plex server by enabling the debug logs, and watching them as you cast a 12 mbps+ video to your TV. You will see a message indicating that the bitrate was locked to 12,000 kbps. You can also watch the server's processes-- you'll see the transcoder spin up and working hard. ↩
- When casting to a Chromecast, the application's name is displayed on the sender computer in the extension popup. Normally, Plex is shown as "Plex". When the injection was working, this changed to "HighBitratePlex", which was what I named my application in the Cast Developer Console. ↩
- "Direct Play" is a feature in Plex that streams a file directly to a client if it is compatible, bypassing the transcoder entirely. In this case, that means that our extension worked since Plex thought that the 20 mbps file was "ok to play" on the Chromecast. ↩