// ==UserScript== // @name Plex downloader // @description Adds a download button the Plex desktop interface. Works on episodes, movies, whole seasons, and entire shows. // @author Mow // @version 1.2 // @license MIT // @grant none // @match https://app.plex.tv/desktop/* // @run-at document-start // @namespace https://greasyfork.org/users/1260133 // @downloadURL none // ==/UserScript== // This code is a heavy modification of the existing PlxDwnld project // https://sharedriches.com/plex-scripts/piplongrun/ const logPrefix = "[USERJS Plex Downloader]"; const domPrefix = "USERJSINJECTED_"; const xmlParser = new DOMParser(); const playBtnSelector = "button[data-testid=preplay-play]"; // Server idenfitiers and their respective data loaded over API request const serverData = { servers : { // Example data /* "fd174cfae71eba992435d781704afe857609471b" : { "baseUri" : "https://1-1-1-1.e38c3319c1a4a0f67c5cc173d314d74cb19e862b.plex.direct:13100", "accessToken" : "fH5dn-HgT7Ihb3S-p9-k", "mediaData" : {} } */ }, // Promise for loading server data, ensure it is loaded before we try to pull data promise : false }; // Merge new data object into serverData function updateServerData(newData, serverDataScope) { if (!serverDataScope) { serverDataScope = serverData; } for (let key in newData) { if (!serverDataScope.hasOwnProperty(key)) { serverDataScope[key] = newData[key]; } else { if (typeof newData[key] === "object") { updateServerData(newData[key], serverDataScope[key]); } else { serverDataScope[key] = newData[key]; } } } } // Should not be visible in normal operation function errorHandle(msg) { console.log(logPrefix + " " + msg.toString()); } // Async fetch XML and return parsed body async function fetchXml(url) { const response = await fetch(url); const responseText = await response.text(); const responseXml = xmlParser.parseFromString(responseText, "text/xml"); return responseXml; } // Async fetch JSON and return parsed body async function fetchJSON(url) { const response = await fetch(url, { headers : { accept : "application/json" } }); const responseJSON = await response.json(); return responseJSON; } // Async load server information for this user account from plex.tv api async function loadServerData() { // Ensure access token if (!localStorage.hasOwnProperty("myPlexAccessToken")) { errorHandle("Cannot find a valid access token (localStorage Plex token missing)."); return; } const apiResourceUrl = `https://plex.tv/api/resources?includeHttps=1&X-Plex-Token=${localStorage["myPlexAccessToken"]}`; const resourceXml = await fetchXml(apiResourceUrl); const serverInfoXPath = "//Device[@provides='server']"; const servers = resourceXml.evaluate(serverInfoXPath, resourceXml, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null); // Stupid ugly iterator pattern. Yes this is how you're supposed to do this // https://developer.mozilla.org/en-US/docs/Web/API/XPathResult/iterateNext let server; while (server = servers.iterateNext()) { const clientId = server.getAttribute("clientIdentifier"); const accessToken = server.getAttribute("accessToken"); if (!clientId || !accessToken) { errorHandle("Cannot find valid server information (missing ID or token in API response)."); continue; } const connectionXPath = "//Connection[@local='0']"; const conn = resourceXml.evaluate(connectionXPath, server, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); if (!conn.singleNodeValue || !conn.singleNodeValue.getAttribute("uri")) { errorHandle("Cannot find valid server information (no connection data for server " + clientId + ")."); continue; } const baseUri = conn.singleNodeValue.getAttribute("uri"); updateServerData({ servers : { [clientId] : { baseUri : baseUri, accessToken : accessToken, mediaData : {}, } } }); } } // Merge video node data from API response into the serverData media cache function updateServerDataMedia(clientId, videoNode) { updateServerData({ servers : { [clientId] : { mediaData : { [videoNode.ratingKey] : { key : videoNode.Media[0].Part[0].key, loaded : true, } } } } }); } // Pull API response for this media item and handle parents/grandparents async function fetchMediaData(clientId, metadataId) { // Make sure server data has loaded in await serverData.promise; // Get access token and base URI for this server const baseUri = serverData.servers[clientId].baseUri; const accessToken = serverData.servers[clientId].accessToken; // Request library data from this server using metadata ID const libraryUrl = `${baseUri}/library/metadata/${metadataId}?X-Plex-Token=${accessToken}`; const libraryJSON = await fetchJSON(libraryUrl); // Determine if this is media or just a parent to media let leafCount = false; if (libraryJSON.MediaContainer.Metadata[0].hasOwnProperty("leafCount")) { leafCount = libraryJSON.MediaContainer.Metadata[0].leafCount; } let childCount = false; if (libraryJSON.MediaContainer.Metadata[0].hasOwnProperty("childCount")) { childCount = libraryJSON.MediaContainer.Metadata[0].childCount; } if (leafCount || childCount) { // This is a group media item (show, season) with children // Get all of its children, either by leaves or children directly // A series only has seasons as children, not the episodes. allLeaves must be used there let childrenUrl; if (childCount && leafCount && (childCount !== leafCount)) { childrenUrl = `${baseUri}/library/metadata/${metadataId}/allLeaves?X-Plex-Token=${accessToken}`; } else { childrenUrl = `${baseUri}/library/metadata/${metadataId}/children?X-Plex-Token=${accessToken}`; } const childrenJSON = await fetchJSON(childrenUrl); const childVideoNodes = childrenJSON.MediaContainer.Metadata; // Iterate over the children of this media item and gather their data serverData.servers[clientId].mediaData[metadataId].children = []; for (let i = 0; i < childVideoNodes.length; i++) { childMetadataId = childVideoNodes[i].ratingKey; updateServerDataMedia(clientId, childVideoNodes[i]); serverData.servers[clientId].mediaData[metadataId].children.push(childMetadataId); // Copy promise to child serverData.servers[clientId].mediaData[childMetadataId].promise = serverData.servers[clientId].mediaData[metadataId].promise; } // Manually flag parent as loaded serverData.servers[clientId].mediaData[metadataId].loaded = true; } else { // This is a regular media item (episode, movie) const videoNode = libraryJSON.MediaContainer.Metadata[0]; updateServerDataMedia(clientId, videoNode); } } // Parse current URL to get clientId and metadataId, or `false` if unable to match function parseUrl() { const metadataIdRegex = new RegExp("key=%2Flibrary%2Fmetadata%2F(\\d+)"); const clientIdRegex = new RegExp("server\/([a-f0-9]{40})\/"); let clientIdMatch = clientIdRegex.exec(window.location.href); if (!clientIdMatch || clientIdMatch.length !== 2) return false; let metadataIdMatch = metadataIdRegex.exec(window.location.href); if (!metadataIdMatch || metadataIdMatch.length !== 2) return false; // Get rid of extra regex matches let clientId = clientIdMatch[1]; let metadataId = metadataIdMatch[1]; return { clientId : clientId, metadataId : metadataId, }; } // Start fetching a media item from the URL parameters, storing promise in serverData function initFetchMediaData() { let urlIds = parseUrl(); if (urlIds === false) return; // Create media entry early updateServerData({ servers : { [urlIds.clientId] : { mediaData : { [urlIds.metadataId] : { } } } } }); if (serverData.servers[urlIds.clientId].mediaData[urlIds.metadataId].loaded) { // Media item already loaded return; } try { let mediaPromise = fetchMediaData(urlIds.clientId, urlIds.metadataId); serverData.servers[urlIds.clientId].mediaData[urlIds.metadataId].promise = mediaPromise; } catch (e) { errorHandle("Exception: " + e); } } // Initiate a download of a URI using iframes function downloadUri(uri) { let frame = document.createElement("iframe"); frame.name = domPrefix + "downloadFrame"; frame.style = "display: none !important;"; document.body.appendChild(frame); frame.src = uri; } // Clean up old DOM elements from previous downloads, if needed function cleanUpOldDownloads() { // There is no way to detect when the download dialog is closed, so just clean up here to prevent DOM clutter let oldFrames = document.getElementsByName(domPrefix + "downloadFrame"); while (oldFrames.length != 0) { oldFrames[0].remove(); } } // Assemble download URI from key and base URI function makeDownloadUri(clientId, metadataId) { const key = serverData.servers[clientId].mediaData[metadataId].key; const baseUri = serverData.servers[clientId].baseUri; const accessToken = serverData.servers[clientId].accessToken; const uri = `${baseUri}${key}?X-Plex-Token=${accessToken}&download=1`; return uri; } // Download a media item, handling parents/grandparents function downloadMedia(clientId, metadataId) { if (serverData.servers[clientId].mediaData[metadataId].hasOwnProperty("key")) { downloadUri(makeDownloadUri(clientId, metadataId)); } if (serverData.servers[clientId].mediaData[metadataId].hasOwnProperty("children")) { for (let i = 0; i < serverData.servers[clientId].mediaData[metadataId].children.length; i++) { let childId = serverData.servers[clientId].mediaData[metadataId].children[i]; downloadMedia(clientId, childId); } } } // Create and add the new DOM elements, return an object with references to them function modifyDom(injectionPoint) { // Steal CSS from the injection point element by copying its class name const downloadButton = document.createElement(injectionPoint.tagName); downloadButton.id = domPrefix + "DownloadButton"; downloadButton.textContent = "Download"; downloadButton.className = domPrefix + "element" + " " + injectionPoint.className; downloadButton.style.fontWeight = "bold"; // Starts disabled downloadButton.style.opacity = 0.5; downloadButton.disabled = true; injectionPoint.after(downloadButton); return downloadButton; } // Activate DOM element and hook clicking with function async function domCallback(domElement, clientId, metadataId) { // Make sure server data has loaded in await serverData.promise; // Make sure we have media data for this item await serverData.servers[clientId].mediaData[metadataId].promise; if (!serverData.servers[clientId].mediaData[metadataId].loaded) { errorHandle("Could not load data for metadataId " + metadataId); return; } const downloadFunction = function() { cleanUpOldDownloads(); downloadMedia(clientId, metadataId); } domElement.addEventListener("click", downloadFunction); domElement.disabled = false; domElement.style.opacity = 1; } async function checkStateAndRun() { // We detect the prescence of the play button (and absence of our injected button) after each page mutation const playBtn = document.querySelector(playBtnSelector); if (!playBtn) return; if (playBtn.nextSibling.id.startsWith(domPrefix)) return; const urlIds = parseUrl(); if (urlIds === false) return; try { const domElement = modifyDom(playBtn); await domCallback(domElement, urlIds.clientId, urlIds.metadataId); } catch (e) { errorHandle("Exception: " + e); } } (function() { // Begin loading server data immediately serverData.promise = loadServerData(); // Try to eager load media info initFetchMediaData(); window.addEventListener("hashchange", initFetchMediaData); // Use a mutation observer to detect pages loading in const mo = new MutationObserver(checkStateAndRun); mo.observe(document.documentElement, { childList: true, subtree: true }); })();