Greasy Fork is available in English.

MPV Shim Local Connection

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

// ==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);