// ==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 // @downloadURL https://update.greasyfork.icu/scripts/398049/MPV%20Shim%20Local%20Connection.user.js // @updateURL https://update.greasyfork.icu/scripts/398049/MPV%20Shim%20Local%20Connection.meta.js // ==/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('', '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 ""; } 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);