MPV Shim Local Connection

Allow Plex to connect to MPV Shim running on the same computer without a local Plex server.

  1. // ==UserScript==
  2. // @name MPV Shim Local Connection
  3. // @version 2.3
  4. // @grant GM.xmlHttpRequest
  5. // @include https://app.plex.tv/*
  6. // @connect 127.0.0.1
  7. // @description Allow Plex to connect to MPV Shim running on the same computer without a local Plex server.
  8. // @license MIT; https://spdx.org/licenses/MIT.html#licenseText
  9. // @namespace https://greasyfork.org/users/456605
  10. // ==/UserScript==
  11.  
  12. function messageHandler(event) {
  13. let message;
  14. try {
  15. message = JSON.parse(event.data);
  16. } catch(_) {
  17. return;
  18. }
  19. if (message.eventName != "gm_xhr_send") return;
  20. let parsedURL = new URL(message.url);
  21. parsedURL.host = "127.0.0.1:3000";
  22. parsedURL.protocol = "http:";
  23. GM.xmlHttpRequest({
  24. method: 'GET',
  25. url: parsedURL.toString(),
  26. headers: {
  27. "X-Plex-Client-Identifier": parsedURL.searchParams.get("X-Plex-Client-Identifier")
  28. },
  29. onload: function (result) {
  30. window.postMessage(JSON.stringify({
  31. eventName: "gm_xhr_recv",
  32. response: result.responseText,
  33. headers: result.responseHeaders,
  34. id: message.id
  35. }), "*");
  36. }
  37. });
  38. }
  39.  
  40. window.addEventListener("message", messageHandler, false);
  41.  
  42. function main () {
  43. // From https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript
  44. function uuidv4() {
  45. return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
  46. (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
  47. );
  48. }
  49. let clientId = localStorage.getItem('gmpx_uuid');
  50. if (!clientId) {
  51. clientId = uuidv4();
  52. localStorage.setItem('gmpx_uuid', clientId);
  53. }
  54. let serverId = localStorage.getItem('gmpx_suuid');
  55. if (!serverId) {
  56. serverId = uuidv4();
  57. localStorage.setItem('gmpx_suuid', serverId);
  58. }
  59.  
  60. // Yes I know this is disgusting. But apparently you can't cast to a server that isn't local.
  61. // The Plex Web App doesn't even *try* to check for clients.
  62. var inject = true;
  63. var fake_cast_server_resource = {
  64. "name": "fake-cast-server",
  65. "product": "Plex Media Server",
  66. "productVersion": "1.18.9.2571-e106a8a91",
  67. "platform": "Linux",
  68. "platformVersion": "10 (buster)",
  69. "device": "PC",
  70. "clientIdentifier": serverId,
  71. "createdAt": "2000-01-01T00:00:00Z",
  72. "lastSeenAt": "2000-01-01T00:00:00Z",
  73. "provides": "server",
  74. "ownerId": null,
  75. "sourceTitle": null,
  76. "publicAddress": "0.0.0.0",
  77. "accessToken": "AAAAAAAAAAAAAAAAAAAA",
  78. "owned": false,
  79. "home": false,
  80. "synced": false,
  81. "relay": false,
  82. "presence": false,
  83. "httpsRequired": false,
  84. "publicAddressMatches": false,
  85. "dnsRebindingProtection": false,
  86. "natLoopbackSupported": true,
  87. "connections": [
  88. {
  89. "protocol": "https",
  90. "address": "127.0.0.1",
  91. "port": 32400,
  92. "uri": "https://fake.uri",
  93. "local": true,
  94. "relay": false,
  95. "IPv6": false
  96. }
  97. ]
  98. };
  99.  
  100. var fake_cast_server_provider = {
  101. "MediaContainer": {
  102. "size": 1,
  103. "allowCameraUpload": false,
  104. "allowChannelAccess": false,
  105. "allowMediaDeletion": false,
  106. "allowSharing": false,
  107. "allowSync": false,
  108. "allowTuners": false,
  109. "backgroundProcessing": false,
  110. "certificate": true,
  111. "companionProxy": true,
  112. "countryCode": "usa",
  113. "diagnostics": "",
  114. "eventStream": false,
  115. "friendlyName": "fake-cast-server",
  116. "livetv": 7,
  117. "machineIdentifier": serverId,
  118. "myPlex": false,
  119. "myPlexMappingState": "mapped",
  120. "myPlexSigninState": "ok",
  121. "myPlexSubscription": true,
  122. "myPlexUsername": "admin@fake.uri",
  123. "ownerFeatures": "",
  124. "photoAutoTag": false,
  125. "platform": "Linux",
  126. "platformVersion": "10 (buster)",
  127. "pluginHost": false,
  128. "pushNotifications": false,
  129. "readOnlyLibraries": false,
  130. "streamingBrainABRVersion": 3,
  131. "streamingBrainVersion": 2,
  132. "sync": false,
  133. "transcoderActiveVideoSessions": 0,
  134. "transcoderAudio": false,
  135. "transcoderLyrics": false,
  136. "transcoderSubtitles": false,
  137. "transcoderVideo": false,
  138. "transcoderVideoBitrates": "64,96,208,320,720,1500,2000,3000,4000,8000,10000,12000,20000",
  139. "transcoderVideoQualities": "0,1,2,3,4,5,6,7,8,9,10,11,12",
  140. "transcoderVideoResolutions": "128,128,160,240,320,480,768,720,720,1080,1080,1080,1080",
  141. "updatedAt": 946702800,
  142. "updater": false,
  143. "version": "1.18.9.2571-e106a8a91",
  144. "voiceSearch": false,
  145. "MediaProvider": []
  146. }
  147. };
  148. window.gmpx_eventHandlers = {};
  149. window.gmpx_id = 0;
  150. const parser = new DOMParser();
  151. const serializer = new XMLSerializer();
  152. function gmpx_messageHandler(event) {
  153. let message;
  154. try {
  155. message = JSON.parse(event.data);
  156. } catch(_) {
  157. return;
  158. }
  159. if (message.eventName != "gm_xhr_recv") return;
  160. window.gmpx_eventHandlers[message.id](message);
  161. window.gmpx_eventHandlers[message.id] = undefined;
  162. }
  163. window.addEventListener("message", gmpx_messageHandler, false);
  164. function intercept(url, responseText) {
  165. if (url == "") return;
  166. let parsedURL = new URL(url);
  167. if (parsedURL.pathname == "/clients") {
  168. const xml = parser.parseFromString(responseText, "text/xml");
  169. const s = xml.createElement("Server")
  170.  
  171. s.setAttribute("name", "local (direct)");
  172. s.setAttribute("host", "127.0.0.1");
  173. s.setAttribute("address", "127.0.0.1");
  174. s.setAttribute("port", "3000");
  175. s.setAttribute("machineIdentifier", clientId);
  176. s.setAttribute("version", "1.0");
  177. s.setAttribute("protocol", "plex");
  178. s.setAttribute("product", "Plex MPV Shim");
  179. s.setAttribute("deviceClass", "pc");
  180. s.setAttribute("protocolVersion", "1");
  181. s.setAttribute("protocolCapabilities", "timeline,playback,navigation,playqueues");
  182.  
  183. xml.children[0].appendChild(s);
  184. inject = false;
  185. return serializer.serializeToString(xml);
  186. } else if (parsedURL.pathname == "/api/v2/resources" && (parsedURL.hostname == "clients.plex.tv" || parsedURL.hostname == "plex.tv") && inject) {
  187. const parsed = JSON.parse(responseText);
  188. parsed.unshift(fake_cast_server_resource);
  189. return JSON.stringify(parsed);
  190. } else {
  191. return responseText;
  192. }
  193. }
  194.  
  195. // From https://stackoverflow.com/questions/26447335/
  196. // Please note: This is very dirty in the way it works. Don't expect it to work perfectly in all areas.
  197. (function() {
  198. // create XMLHttpRequest proxy object
  199. var oldXMLHttpRequest = XMLHttpRequest;
  200. var oldWebSocket = WebSocket;
  201. WebSocket = function(url, extra) {
  202. var self = this;
  203. if (url.indexOf("fake.uri") >= 0) {
  204. self.override = true;
  205. var actual = {};
  206. } else {
  207. var actual = new oldWebSocket(url, extra);
  208. }
  209. // add all proxy getters/setters
  210. ["binaryType", "bufferedAmount", "extensions", "onclose", "onerror",
  211. "onmessage", "onopen", "protocol", "readyState", "url"].forEach(function(item) {
  212. Object.defineProperty(self, item, {
  213. get: function() { return actual[item];},
  214. set: function(val) { actual[item] = val;}
  215. });
  216. });
  217. // add all pure proxy pass-through methods
  218. ["close", "send"].forEach(function(item) {
  219. Object.defineProperty(self, item, {
  220. value: function() {
  221. if (self.override) { return; }
  222. return actual[item].apply(actual, arguments);
  223. }
  224. });
  225. });
  226. }
  227. WebSocket.CONNECTING = 0;
  228. WebSocket.OPEN = 1;
  229. WebSocket.CLOSING = 2;
  230. WebSocket.CLOSED = 3;
  231.  
  232. // define constructor for my proxy object
  233. XMLHttpRequest = function() {
  234. var actual = new oldXMLHttpRequest();
  235. var self = this;
  236. self.override = false;
  237.  
  238. this.onreadystatechange = null;
  239.  
  240. // this is the actual handler on the real XMLHttpRequest object
  241. actual.onreadystatechange = function() {
  242. if (this.readyState == 4 && (actual.responseType == '' || actual.responseType == 'text')) {
  243. try {
  244. self._responseText = intercept(actual.responseURL, actual.responseText);
  245. } catch (err) {
  246. self._responseText = actual.responseText;
  247. }
  248. }
  249. if (self.onreadystatechange) {
  250. return self.onreadystatechange();
  251. }
  252. };
  253.  
  254. // add all proxy getters/setters
  255. ["upload", "ontimeout, timeout", "withCredentials", "onerror", "onprogress"].forEach(function(item) {
  256. Object.defineProperty(self, item, {
  257. get: function() { return actual[item];},
  258. set: function(val) { actual[item] = val;}
  259. });
  260. });
  261.  
  262. // add all proxy getters/setters
  263. ["response", "statusText", "status", "readyState", "responseURL", "responseType", "responseText"].forEach(function(item) {
  264. Object.defineProperty(self, item, {
  265. get: function() {
  266. if (self.hasOwnProperty("_" + item)) {
  267. return self["_" + item];
  268. } else {
  269. return actual[item];
  270. }
  271. },
  272. set: function(val) {actual[item] = val;}
  273. });
  274. });
  275.  
  276. // add all pure proxy pass-through methods
  277. ["addEventListener", "abort", "getResponseHeader", "overrideMimeType", "setRequestHeader"].forEach(function(item) {
  278. Object.defineProperty(self, item, {
  279. value: function() {
  280. if (self.override) { return; }
  281. return actual[item].apply(actual, arguments);
  282. }
  283. });
  284. });
  285.  
  286. self.open = function() {
  287. if (arguments[0] == "GET") {
  288. const parsedURL = new URL(arguments[1]);
  289. if (parsedURL.searchParams.get("X-Plex-Target-Client-Identifier") == clientId) {
  290. const url = arguments[1];
  291. self.override = true;
  292. self._readyState = 1;
  293. self._send = function() {
  294. const id = window.gmpx_id++;
  295. self._responseURL = url;
  296. self._responseType = "";
  297. window.gmpx_eventHandlers[id] = function(result) {
  298. self._readyState = 4;
  299. self._status = 200;
  300. self._statusText = "OK";
  301. self._responseText = result.response;
  302. self.headers = result.headers;
  303. if (self.onreadystatechange) {
  304. self.onreadystatechange();
  305. }
  306. if (self._onload) {
  307. self._onload();
  308. }
  309. };
  310. window.postMessage(JSON.stringify({
  311. eventName: "gm_xhr_send",
  312. url: url,
  313. id: id
  314. }), "*");
  315. }
  316. } else if (parsedURL.hostname == "fake.uri") {
  317. const url = arguments[1];
  318. self.override = true;
  319. self.override2 = true;
  320. self._readyState = 1;
  321. self._send = function() {
  322. self._responseURL = url;
  323. self._responseType = "";
  324. self._status = 200;
  325. self._statusText = "OK";
  326. self._readyState = 4;
  327. self._responseText = "";
  328. if (parsedURL.pathname == "/clients") {
  329. const xml = parser.parseFromString('<MediaContainer/>', 'text/xml');
  330. const s = xml.createElement("Server");
  331. s.setAttribute("name", "local (direct)");
  332. s.setAttribute("host", "127.0.0.1");
  333. s.setAttribute("address", "127.0.0.1");
  334. s.setAttribute("port", "3000");
  335. s.setAttribute("machineIdentifier", clientId);
  336. s.setAttribute("version", "1.0");
  337. s.setAttribute("protocol", "plex");
  338. s.setAttribute("product", "Plex MPV Shim");
  339. s.setAttribute("deviceClass", "pc");
  340. s.setAttribute("protocolVersion", "1");
  341. s.setAttribute("protocolCapabilities", "timeline,playback,navigation,playqueues");
  342. xml.children[0].appendChild(s);
  343. self._responseText = serializer.serializeToString(xml);
  344. } else if (parsedURL.pathname == "/neighborhood/devices") {
  345. return "<MediaContainer size=\"0\"/>";
  346. } else if (parsedURL.pathname == "/media/providers") {
  347. self._responseText = JSON.stringify(fake_cast_server_provider);
  348. } else if (parsedURL.pathname == "/player/proxy/poll") {
  349. return;
  350. } else {
  351. console.log("Unhandled URL: " + arguments[1]);
  352. }
  353.  
  354. if (self.onreadystatechange) {
  355. self.onreadystatechange();
  356. }
  357. if (self._onload) {
  358. self._onload();
  359. }
  360. }
  361. } else {
  362. return actual.open.apply(actual, arguments);
  363. }
  364. } else {
  365. return actual.open.apply(actual, arguments);
  366. }
  367. }
  368.  
  369. self.send = function() {
  370. if (self.override) {
  371. self._send();
  372. } else {
  373. return actual.send.apply(actual, arguments);
  374. }
  375. }
  376.  
  377. self.getAllResponseHeaders = function() {
  378. if (self.override2) {
  379. return "";
  380. } else if (self.override) {
  381. const headers = self.headers.split("\r\n");
  382. for (let i = 0; i < headers.length; i++) {
  383. if (headers[i].indexOf("x-plex-client-identifier") >= 0) {
  384. headers[i] = "x-plex-client-identifier: " + clientId;
  385. }
  386. }
  387. return headers.join("\r\n");
  388. } else {
  389. return actual.getAllResponseHeaders.apply(actual, arguments);
  390. }
  391.  
  392. }
  393.  
  394. Object.defineProperty(self, "responseXML", {
  395. get: function() {
  396. if (self.override) {
  397. return parser.parseFromString(self._responseText, "text/xml");
  398. } else {
  399. return actual[item];
  400. }
  401. }
  402. });
  403.  
  404. Object.defineProperty(self, "onload", {
  405. get: function() { if (self.override) return self._onload; return actual.onload;},
  406. set: function(val) { if (self.override) self._onload = val; else actual.onload = val;}
  407. });
  408. }
  409. })();
  410. }
  411.  
  412. // From https://stackoverflow.com/questions/2303147/
  413. var script = document.createElement('script');
  414. script.appendChild(document.createTextNode('('+ main +')();'));
  415. (document.body || document.head || document.documentElement).appendChild(script);