// ==UserScript==
// @name MPV Shim Local Connection
// @version 2.3
// @grant GM.xmlHttpRequest
// @include https://app.plex.tv/*
// @connect 127.0.0.1
// @description Allow Plex to connect to MPV Shim running on the same computer without a local Plex server.
// @license MIT; https://spdx.org/licenses/MIT.html#licenseText
// @namespace https://greasyfork.org/users/456605
// ==/UserScript==
function messageHandler(event) {
let message;
try {
message = JSON.parse(event.data);
} catch(_) {
return;
}
if (message.eventName != "gm_xhr_send") return;
let parsedURL = new URL(message.url);
parsedURL.host = "127.0.0.1:3000";
parsedURL.protocol = "http:";
GM.xmlHttpRequest({
method: 'GET',
url: parsedURL.toString(),
headers: {
"X-Plex-Client-Identifier": parsedURL.searchParams.get("X-Plex-Client-Identifier")
},
onload: function (result) {
window.postMessage(JSON.stringify({
eventName: "gm_xhr_recv",
response: result.responseText,
headers: result.responseHeaders,
id: message.id
}), "*");
}
});
}
window.addEventListener("message", messageHandler, false);
function main () {
// From https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript
function uuidv4() {
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
);
}
let clientId = localStorage.getItem('gmpx_uuid');
if (!clientId) {
clientId = uuidv4();
localStorage.setItem('gmpx_uuid', clientId);
}
let serverId = localStorage.getItem('gmpx_suuid');
if (!serverId) {
serverId = uuidv4();
localStorage.setItem('gmpx_suuid', serverId);
}
// Yes I know this is disgusting. But apparently you can't cast to a server that isn't local.
// The Plex Web App doesn't even *try* to check for clients.
var inject = true;
var fake_cast_server_resource = {
"name": "fake-cast-server",
"product": "Plex Media Server",
"productVersion": "1.18.9.2571-e106a8a91",
"platform": "Linux",
"platformVersion": "10 (buster)",
"device": "PC",
"clientIdentifier": serverId,
"createdAt": "2000-01-01T00:00:00Z",
"lastSeenAt": "2000-01-01T00:00:00Z",
"provides": "server",
"ownerId": null,
"sourceTitle": null,
"publicAddress": "0.0.0.0",
"accessToken": "AAAAAAAAAAAAAAAAAAAA",
"owned": false,
"home": false,
"synced": false,
"relay": false,
"presence": false,
"httpsRequired": false,
"publicAddressMatches": false,
"dnsRebindingProtection": false,
"natLoopbackSupported": true,
"connections": [
{
"protocol": "https",
"address": "127.0.0.1",
"port": 32400,
"uri": "https://fake.uri",
"local": true,
"relay": false,
"IPv6": false
}
]
};
var fake_cast_server_provider = {
"MediaContainer": {
"size": 1,
"allowCameraUpload": false,
"allowChannelAccess": false,
"allowMediaDeletion": false,
"allowSharing": false,
"allowSync": false,
"allowTuners": false,
"backgroundProcessing": false,
"certificate": true,
"companionProxy": true,
"countryCode": "usa",
"diagnostics": "",
"eventStream": false,
"friendlyName": "fake-cast-server",
"livetv": 7,
"machineIdentifier": serverId,
"myPlex": false,
"myPlexMappingState": "mapped",
"myPlexSigninState": "ok",
"myPlexSubscription": true,
"myPlexUsername": "admin@fake.uri",
"ownerFeatures": "",
"photoAutoTag": false,
"platform": "Linux",
"platformVersion": "10 (buster)",
"pluginHost": false,
"pushNotifications": false,
"readOnlyLibraries": false,
"streamingBrainABRVersion": 3,
"streamingBrainVersion": 2,
"sync": false,
"transcoderActiveVideoSessions": 0,
"transcoderAudio": false,
"transcoderLyrics": false,
"transcoderSubtitles": false,
"transcoderVideo": false,
"transcoderVideoBitrates": "64,96,208,320,720,1500,2000,3000,4000,8000,10000,12000,20000",
"transcoderVideoQualities": "0,1,2,3,4,5,6,7,8,9,10,11,12",
"transcoderVideoResolutions": "128,128,160,240,320,480,768,720,720,1080,1080,1080,1080",
"updatedAt": 946702800,
"updater": false,
"version": "1.18.9.2571-e106a8a91",
"voiceSearch": false,
"MediaProvider": []
}
};
window.gmpx_eventHandlers = {};
window.gmpx_id = 0;
const parser = new DOMParser();
const serializer = new XMLSerializer();
function gmpx_messageHandler(event) {
let message;
try {
message = JSON.parse(event.data);
} catch(_) {
return;
}
if (message.eventName != "gm_xhr_recv") return;
window.gmpx_eventHandlers[message.id](message);
window.gmpx_eventHandlers[message.id] = undefined;
}
window.addEventListener("message", gmpx_messageHandler, false);
function intercept(url, responseText) {
if (url == "") return;
let parsedURL = new URL(url);
if (parsedURL.pathname == "/clients") {
const xml = parser.parseFromString(responseText, "text/xml");
const s = xml.createElement("Server")
s.setAttribute("name", "local (direct)");
s.setAttribute("host", "127.0.0.1");
s.setAttribute("address", "127.0.0.1");
s.setAttribute("port", "3000");
s.setAttribute("machineIdentifier", clientId);
s.setAttribute("version", "1.0");
s.setAttribute("protocol", "plex");
s.setAttribute("product", "Plex MPV Shim");
s.setAttribute("deviceClass", "pc");
s.setAttribute("protocolVersion", "1");
s.setAttribute("protocolCapabilities", "timeline,playback,navigation,playqueues");
xml.children[0].appendChild(s);
inject = false;
return serializer.serializeToString(xml);
} else if (parsedURL.pathname == "/api/v2/resources" && (parsedURL.hostname == "clients.plex.tv" || parsedURL.hostname == "plex.tv") && inject) {
const parsed = JSON.parse(responseText);
parsed.unshift(fake_cast_server_resource);
return JSON.stringify(parsed);
} else {
return responseText;
}
}
// From https://stackoverflow.com/questions/26447335/
// Please note: This is very dirty in the way it works. Don't expect it to work perfectly in all areas.
(function() {
// create XMLHttpRequest proxy object
var oldXMLHttpRequest = XMLHttpRequest;
var oldWebSocket = WebSocket;
WebSocket = function(url, extra) {
var self = this;
if (url.indexOf("fake.uri") >= 0) {
self.override = true;
var actual = {};
} else {
var actual = new oldWebSocket(url, extra);
}
// add all proxy getters/setters
["binaryType", "bufferedAmount", "extensions", "onclose", "onerror",
"onmessage", "onopen", "protocol", "readyState", "url"].forEach(function(item) {
Object.defineProperty(self, item, {
get: function() { return actual[item];},
set: function(val) { actual[item] = val;}
});
});
// add all pure proxy pass-through methods
["close", "send"].forEach(function(item) {
Object.defineProperty(self, item, {
value: function() {
if (self.override) { return; }
return actual[item].apply(actual, arguments);
}
});
});
}
WebSocket.CONNECTING = 0;
WebSocket.OPEN = 1;
WebSocket.CLOSING = 2;
WebSocket.CLOSED = 3;
// define constructor for my proxy object
XMLHttpRequest = function() {
var actual = new oldXMLHttpRequest();
var self = this;
self.override = false;
this.onreadystatechange = null;
// this is the actual handler on the real XMLHttpRequest object
actual.onreadystatechange = function() {
if (this.readyState == 4 && (actual.responseType == '' || actual.responseType == 'text')) {
try {
self._responseText = intercept(actual.responseURL, actual.responseText);
} catch (err) {
self._responseText = actual.responseText;
}
}
if (self.onreadystatechange) {
return self.onreadystatechange();
}
};
// add all proxy getters/setters
["upload", "ontimeout, timeout", "withCredentials", "onerror", "onprogress"].forEach(function(item) {
Object.defineProperty(self, item, {
get: function() { return actual[item];},
set: function(val) { actual[item] = val;}
});
});
// add all proxy getters/setters
["response", "statusText", "status", "readyState", "responseURL", "responseType", "responseText"].forEach(function(item) {
Object.defineProperty(self, item, {
get: function() {
if (self.hasOwnProperty("_" + item)) {
return self["_" + item];
} else {
return actual[item];
}
},
set: function(val) {actual[item] = val;}
});
});
// add all pure proxy pass-through methods
["addEventListener", "abort", "getResponseHeader", "overrideMimeType", "setRequestHeader"].forEach(function(item) {
Object.defineProperty(self, item, {
value: function() {
if (self.override) { return; }
return actual[item].apply(actual, arguments);
}
});
});
self.open = function() {
if (arguments[0] == "GET") {
const parsedURL = new URL(arguments[1]);
if (parsedURL.searchParams.get("X-Plex-Target-Client-Identifier") == clientId) {
const url = arguments[1];
self.override = true;
self._readyState = 1;
self._send = function() {
const id = window.gmpx_id++;
self._responseURL = url;
self._responseType = "";
window.gmpx_eventHandlers[id] = function(result) {
self._readyState = 4;
self._status = 200;
self._statusText = "OK";
self._responseText = result.response;
self.headers = result.headers;
if (self.onreadystatechange) {
self.onreadystatechange();
}
if (self._onload) {
self._onload();
}
};
window.postMessage(JSON.stringify({
eventName: "gm_xhr_send",
url: url,
id: id
}), "*");
}
} else if (parsedURL.hostname == "fake.uri") {
const url = arguments[1];
self.override = true;
self.override2 = true;
self._readyState = 1;
self._send = function() {
self._responseURL = url;
self._responseType = "";
self._status = 200;
self._statusText = "OK";
self._readyState = 4;
self._responseText = "";
if (parsedURL.pathname == "/clients") {
const xml = parser.parseFromString('<MediaContainer/>', 'text/xml');
const s = xml.createElement("Server");
s.setAttribute("name", "local (direct)");
s.setAttribute("host", "127.0.0.1");
s.setAttribute("address", "127.0.0.1");
s.setAttribute("port", "3000");
s.setAttribute("machineIdentifier", clientId);
s.setAttribute("version", "1.0");
s.setAttribute("protocol", "plex");
s.setAttribute("product", "Plex MPV Shim");
s.setAttribute("deviceClass", "pc");
s.setAttribute("protocolVersion", "1");
s.setAttribute("protocolCapabilities", "timeline,playback,navigation,playqueues");
xml.children[0].appendChild(s);
self._responseText = serializer.serializeToString(xml);
} else if (parsedURL.pathname == "/neighborhood/devices") {
return "<MediaContainer size=\"0\"/>";
} else if (parsedURL.pathname == "/media/providers") {
self._responseText = JSON.stringify(fake_cast_server_provider);
} else if (parsedURL.pathname == "/player/proxy/poll") {
return;
} else {
console.log("Unhandled URL: " + arguments[1]);
}
if (self.onreadystatechange) {
self.onreadystatechange();
}
if (self._onload) {
self._onload();
}
}
} else {
return actual.open.apply(actual, arguments);
}
} else {
return actual.open.apply(actual, arguments);
}
}
self.send = function() {
if (self.override) {
self._send();
} else {
return actual.send.apply(actual, arguments);
}
}
self.getAllResponseHeaders = function() {
if (self.override2) {
return "";
} else if (self.override) {
const headers = self.headers.split("\r\n");
for (let i = 0; i < headers.length; i++) {
if (headers[i].indexOf("x-plex-client-identifier") >= 0) {
headers[i] = "x-plex-client-identifier: " + clientId;
}
}
return headers.join("\r\n");
} else {
return actual.getAllResponseHeaders.apply(actual, arguments);
}
}
Object.defineProperty(self, "responseXML", {
get: function() {
if (self.override) {
return parser.parseFromString(self._responseText, "text/xml");
} else {
return actual[item];
}
}
});
Object.defineProperty(self, "onload", {
get: function() { if (self.override) return self._onload; return actual.onload;},
set: function(val) { if (self.override) self._onload = val; else actual.onload = val;}
});
}
})();
}
// From https://stackoverflow.com/questions/2303147/
var script = document.createElement('script');
script.appendChild(document.createTextNode('('+ main +')();'));
(document.body || document.head || document.documentElement).appendChild(script);