// ==UserScript== // @name Plex downloader // @description Adds a download button to the Plex desktop interface. Works on episodes, movies, whole seasons, and entire shows. // @author Mow // @version 1.5.0 // @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/ (function() { "use strict"; const logPrefix = "[USERJS Plex Downloader]"; const domPrefix = `USERJSINJECTED-${Math.random().toString(36).slice(2)}_`; // Settings of what element to clone, where to inject it, and any additional CSS to use const injectionElement = "button[data-testid=preplay-play]"; // Play button const injectPosition = "after"; const domElementStyle = ""; const domElementInnerHTML = "Download"; // Should not be visible in normal operation const errorLog = []; function errorHandle(msg) { errorLog.push(msg); console.log(`${logPrefix} ${msg.toString()}`); } // Turn a number of bytes to a more friendly size display function makeFilesize(numbytes) { const units = [ "B", "KB", "MB", "GB" ]; let ui = 0; numbytes = parseInt(numbytes); if (isNaN(numbytes)) { return "?"; } // I don't care what hard drive manufacturers say, there are 1024 bytes in a kilobyte while (numbytes >= 1024 && ui < units.length - 1) { numbytes /= 1024; ui++; } if (ui !== 0) { return `${numbytes.toFixed(2)} ${units[ui]}`; } else { return `${numbytes} ${units[ui]}`; } } // Turn a number of milliseconds to a more friendly HH:MM:SS display function makeDuration(ms) { ms = parseInt(ms); if (isNaN(ms) || ms < 0) { return "?"; } let h = Math.floor(ms/3600000); let m = Math.floor((ms%3600000)/60000); let s = Math.floor(((ms%3600000)%60000)/1000); let ret = [ h, m, s ]; // If no hours, omit them. Leave minutes and seconds even if they're zero if (ret[0] === 0) { ret.shift(); } // Except for first unit, make sure all are two digits by prepending zero // EG: 0:07 for 7s, 2:01:04 for 2h 1m 4s for (let i = 1; i < ret.length; i++) { ret[i] = ret[i].toString().padStart(2, '0'); } // Add separator return ret.join(":") } // The modal is the popup that prompts you for a selection of a group media item like a whole season of a TV show const modal = {}; modal.container = document.createElement(`${domPrefix}element`); modal.container.id = `${domPrefix}modal_container`; // Styling and element tree as careful as possible to not interfere or be interfered with by Plex modal.stylesheet = ` ${domPrefix}element { display: block; color: #eee; } #${domPrefix}modal_container { width: 0; height: 0; pointer-events: none; transition: opacity 0.2s; opacity: 0; } #${domPrefix}modal_container.${domPrefix}open { pointer-events: auto; opacity: 1; } #${domPrefix}modal_overlay { width: 100%; height: 100%; position: fixed; top: 0; left: 0; z-index: 99990; display: flex; align-items: center; justify-content: center; background: #0007; } #${domPrefix}modal_popup { width: 90%; max-width: 750px; /* height: 80%; */ /* max-height: 650px; */ min-height: 40%; max-height: min(80%, 650px); display: flex; flex-direction: column; border-radius: 14px; background: #3f3f42; padding: 20px; text-align: center; box-shadow: 0 0 10px 1px black; position: relative; transition: top 0.2s ease-out; top: -15%; } #${domPrefix}modal_container.${domPrefix}open #${domPrefix}modal_popup { top: -2%; } #${domPrefix}modal_title { font-size: 16pt; } #${domPrefix}modal_scrollbox { width: 100%; overflow-y: scroll; scrollbar-color: #aaa #333; background: #0005; margin-top: 12px; border-radius: 6px; box-shadow: 0 0 4px 1px #0003 inset; flex: 1; } #${domPrefix}modal_container input[type="button"] { transition: color 0.15s, background 0.15s, opacity 0.15s; } #${domPrefix}modal_topx { position: absolute; top: 1em; right: 1em; cursor: pointer; height: 1.5em; width: 1.5em; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-size: 14pt; color: #fff8; background: transparent; border: none; } #${domPrefix}modal_topx:hover { background: #fff2; color: #000c; } #${domPrefix}modal_topx:hover:active { background: #fff7; } #${domPrefix}modal_downloadbutton { display: inline-flex; justify-content: center; align-items: center; background: #0008; padding: 0.2em 0.5em; border-radius: 4px; cursor: pointer; color: #eee; border: 1px solid #5555; font-size: 14pt; } #${domPrefix}modal_downloadbutton:hover:not([disabled]) { background: #0007; } #${domPrefix}modal_downloadbutton[disabled] { opacity: 0.5; cursor: default; } .${domPrefix}modal_table_row { display: table-row; } .${domPrefix}modal_table_header { display: table-row; font-weight: 600; position: sticky; top: 0; background: #222; box-shadow: 0 0 4px #000a; } .${domPrefix}modal_table_header > *:not(:first-child) { border-left: 1px solid #bcf1; } .${domPrefix}modal_table_header > *:not(:last-child) { border-right: 1px solid #bcf1; } #${domPrefix}modal_container .${domPrefix}modal_table_cell { padding: 8px; display: table-cell; vertical-align: middle; text-align: center; } #${domPrefix}modal_table_rowcontainer > *:nth-child(2n) { background: #7781; } #${domPrefix}modal_container label { cursor: pointer; } #${domPrefix}modal_container label:hover { background: #bdf2; } #${domPrefix}modal_container label:hover:active { background: #b5d3ff28; } #${domPrefix}modal_container label:has(input:not(:checked)) .${domPrefix}modal_table_cell { color: #eee6; } #${domPrefix}modal_container input[type="checkbox"] { margin: 0 0.8em; height: 1rem; width: 1rem; cursor: pointer; accent-color: #1394e1; } #${domPrefix}modal_container *:focus-visible { outline: 2px solid #408cffbf; outline-offset: 2px; } `; modal.container.innerHTML = ` <${domPrefix}element id="${domPrefix}modal_overlay"> <${domPrefix}element id="${domPrefix}modal_popup" role="dialog" aria-modal="true" aria-labelledby="${domPrefix}modal_title" aria-describedby="${domPrefix}modal_downloaddescription"> <${domPrefix}element id="${domPrefix}modal_title">Download <${domPrefix}element id="${domPrefix}modal_scrollbox" aria-label="List of files that may be downloaded"> <${domPrefix}element style="display:table; width:100%"> <${domPrefix}element style="display:table-header-group"> <${domPrefix}element class="${domPrefix}modal_table_header"> <${domPrefix}element class="${domPrefix}modal_table_cell" style="width:100%">File <${domPrefix}element class="${domPrefix}modal_table_cell">Runtime <${domPrefix}element class="${domPrefix}modal_table_cell">Resolution <${domPrefix}element class="${domPrefix}modal_table_cell">Type <${domPrefix}element class="${domPrefix}modal_table_cell">Size <${domPrefix}element style="display:table-row-group" id="${domPrefix}modal_table_rowcontainer"> /* Items inserted here */ <${domPrefix}element style="display: block; margin: 1em;"> <${domPrefix}element id="${domPrefix}modal_downloaddescription"> <${domPrefix}element> `; modal.itemTemplate = document.createElement(`label`); modal.itemTemplate.className = `${domPrefix}modal_table_row`; modal.itemTemplate.innerHTML = ` <${domPrefix}element class="${domPrefix}modal_table_cell"> <${domPrefix}element class="${domPrefix}modal_table_cell" style="text-align:left"> <${domPrefix}element class="${domPrefix}modal_table_cell" style="white-space:nowrap"> <${domPrefix}element class="${domPrefix}modal_table_cell" style="white-space:nowrap"> <${domPrefix}element class="${domPrefix}modal_table_cell" style="white-space:nowrap"> <${domPrefix}element class="${domPrefix}modal_table_cell" style="white-space:nowrap"> `; // Must use DocumentFragment here to access getElementById modal.documentFragment = document.createDocumentFragment(); modal.documentFragment.append(modal.container); modal.overlay = modal.documentFragment.getElementById(`${domPrefix}modal_overlay`); modal.popup = modal.documentFragment.getElementById(`${domPrefix}modal_popup`); modal.title = modal.documentFragment.getElementById(`${domPrefix}modal_title`); modal.itemContainer = modal.documentFragment.getElementById(`${domPrefix}modal_table_rowcontainer`); modal.topX = modal.documentFragment.getElementById(`${domPrefix}modal_topx`); modal.downloadButton = modal.documentFragment.getElementById(`${domPrefix}modal_downloadbutton`); modal.checkAll = modal.documentFragment.getElementById(`${domPrefix}modal_checkall`); modal.clientId = modal.documentFragment.getElementById(`${domPrefix}modal_clientid`); modal.parentId = modal.documentFragment.getElementById(`${domPrefix}modal_parentid`); modal.downloadDescription = modal.documentFragment.getElementById(`${domPrefix}modal_downloaddescription`); // Live updating collection of items modal.itemCheckboxes = modal.itemContainer.getElementsByTagName("input"); modal.firstTab = modal.topX; modal.lastTab = modal.downloadButton; // Allow Tab/Enter/Space to correctly interact with the modal modal.captureKeyPress = function(e) { // No keypresses are allowed to interact with any lower event listeners e.stopImmediatePropagation(); switch (e.key) { case "Tab": // Move focus into the modal if it somehow isn't already if (!modal.container.contains(document.activeElement)) { e.preventDefault(); modal.firstTab.focus(); break; } // Clamp tabbing to the next element to the selectable elements within the modal // Shift key reverses the direction if (e.shiftKey) { if (document.activeElement === modal.firstTab) { e.preventDefault(); modal.lastTab.focus(); } } else { if (document.activeElement === modal.lastTab) { e.preventDefault(); modal.firstTab.focus(); } } break; case "Escape": modal.close(); break; case "Enter": // The enter key interacting with checkboxes can be unreliable e.preventDefault(); if (modal.container.contains(document.activeElement)) { document.activeElement.click(); } break; } } // Modal removes itself from the DOM once its CSS transition is over modal.container.addEventListener("transitionend", function(e) { // Ignore any transitionend events fired by child elements if (e.target !== modal.container) return; // Look to remove the modal from the DOM if (!modal.container.classList.contains(`${domPrefix}open`)) { modal.documentFragment.appendChild(modal.container); } }); // Show the modal on screen modal.open = function(clientId, metadataId) { modal.populate(clientId, metadataId); // Reset all checkboxes for (let checkbox of modal.itemCheckboxes) { checkbox.checked = true; } modal.checkAll.checked = true; modal.checkBoxChange(); // Add modal to DOM document.body.appendChild(modal.container); // Set up event listeners window.addEventListener("keydown", modal.captureKeyPress, { capturing : true }); window.addEventListener("popstate", modal.close); // Focus on the download button, such that "Enter" immediately will start download modal.lastTab.focus(); // CSS animation entrance modal.container.classList.add(`${domPrefix}open`); } // Close modal modal.close = function() { // Stop capturing keypresses window.removeEventListener("keydown", modal.captureKeyPress, { capturing : true }); // Stop listening to popstate too window.removeEventListener("popstate", modal.close); // CSS animation exit, triggers the removal from the DOM on the transitionend event modal.container.classList.remove(`${domPrefix}open`); } // Hook functionality for modal modal.overlay.addEventListener("click", modal.close); modal.popup.addEventListener("click", function(e) { e.stopPropagation() }); modal.topX.addEventListener("click", modal.close); modal.checkAll.addEventListener("change", function() { for (let checkbox of modal.itemCheckboxes) { checkbox.checked = modal.checkAll.checked; } modal.checkBoxChange(); }); modal.downloadChecked = function() { let clientId = modal.clientId.value; for (let checkbox of modal.itemCheckboxes) { if (checkbox.checked) { download.fromMedia(clientId, checkbox.value); } } modal.close(); } modal.downloadButton.addEventListener("click", modal.downloadChecked); // Process a change to checkboxes inside the modal modal.checkBoxChange = function() { // Add up total filesize let totalFilesize = 0; let selectedItems = 0; for (let checkbox of modal.itemCheckboxes) { if (checkbox.checked) { totalFilesize += serverData.servers[modal.clientId.value].mediaData[checkbox.value].filesize; selectedItems++; } } let description = `${selectedItems} file(s) selected. Total size: ${makeFilesize(totalFilesize)}`; modal.downloadDescription.textContent = description; modal.downloadButton.disabled = (totalFilesize === 0); // Can't download nothing } // Fill the modal with information for a specific group media item modal.populate = function(clientId, metadataId) { if ( modal.clientId.value === clientId && modal.parentId.value === metadataId ) { // Ignore double trigger return; } // Clear out container contents while (modal.itemContainer.hasChildNodes()) { modal.itemContainer.firstChild.remove(); } // Recursively follow children and add all of their media to the container (function recurseMediaChildren(metadataId, titles) { titles.push(serverData.servers[clientId].mediaData[metadataId].title); if (serverData.servers[clientId].mediaData[metadataId].hasOwnProperty("children")) { // Must sort the children by index here so they appear in the proper order serverData.servers[clientId].mediaData[metadataId].children.sort((a, b) => { let mediaA = serverData.servers[clientId].mediaData[a]; let mediaB = serverData.servers[clientId].mediaData[b]; return mediaA.index - mediaB.index; }); for (let childId of serverData.servers[clientId].mediaData[metadataId].children) { recurseMediaChildren(childId, titles); } } else { let mediaData = serverData.servers[clientId].mediaData[metadataId]; let item = modal.itemTemplate.cloneNode(/*deep=*/true); let checkbox = item.getElementsByTagName("input")[0]; checkbox.id = `${domPrefix}item_checkbox_${metadataId}`; checkbox.value = metadataId; checkbox.addEventListener("change", modal.checkBoxChange); item.htmlFor = checkbox.id; // Ignore the first title, which is the modal title instead let itemTitle = titles.slice(1).join(", "); item.title = `Download ${itemTitle}`; let cells = item.getElementsByClassName(`${domPrefix}modal_table_cell`); cells[1].textContent = itemTitle; cells[2].textContent = makeDuration(mediaData.runtimeMS); cells[3].textContent = mediaData.resolution; cells[4].textContent = mediaData.filetype.toUpperCase(); cells[5].textContent = makeFilesize(mediaData.filesize); modal.itemContainer.appendChild(item); } titles.pop(); })(metadataId, []); // Set the modal title modal.title.textContent = serverData.servers[clientId].mediaData[metadataId].title; // Hidden values required for the button to work // Also help detect if we don't need to repopulate the modal modal.clientId.value = clientId; modal.parentId.value = metadataId; // Refresh the item count/total filesize modal.checkBoxChange(); } // The observer object that waits for page to be right to inject new functionality const DOMObserver = {}; // Check to see if we need to modify the DOM, do so if yes DOMObserver.callback = async function() { // Detect the presence of the injection point first const injectionPoint = document.querySelector(injectionElement); if (!injectionPoint) return; // We can always stop observing when we have found the injection point // Note: This relies on the fact that the page does not mutate without also // triggering hashchange. This is currently true (most of the time) but // may change in future plex desktop updates DOMObserver.stop(); // Should be on the right URL if we're observing the DOM and the injection point is found const urlIds = parseUrl(); if (!urlIds) return; // Make sure we don't ever double trigger for any reason if (document.getElementById(`${domPrefix}DownloadButton`)) return; // Inject new button and await the data to add functionality const domElement = modifyDom(injectionPoint); let success = await domCallback(domElement, urlIds.clientId, urlIds.metadataId); if (success) { domElement.disabled = false; domElement.style.opacity = 1; } else { domElement.style.opacity = 0.25; } } DOMObserver.mo = new MutationObserver(DOMObserver.callback); DOMObserver.observe = function() { DOMObserver.mo.observe(document.body, { childList : true, subtree : true }); } DOMObserver.stop = function() { DOMObserver.mo.disconnect(); } // Fetch XML and return parsed body const xmlParser = new DOMParser(); async function fetchXml(url) { const response = await fetch(url); const responseText = await response.text(); const responseXml = xmlParser.parseFromString(responseText, "text/xml"); return responseXml; } // 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; } // Server identifiers 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 media data promise : null, }; // Merge new data object into serverData serverData.update = function(newData, serverDataScope) { serverDataScope = serverDataScope || serverData; for (let key in newData) { if (!serverDataScope.hasOwnProperty(key) || typeof newData[key] !== "object") { // Write directly if key doesn't exist or key contains POD serverDataScope[key] = newData[key]; } else { // Merge objects if needed instead serverData.update(newData[key], serverDataScope[key]); } } } // Load server information for this user account from plex.tv API. Returns an async bool indicating success serverData.load = async function() { // Ensure access token let serverToken = window.localStorage.getItem("myPlexAccessToken"); if (serverToken === null) { errorHandle(`Cannot find a valid access token (localStorage Plex token missing).`); return false; } const apiResourceUrl = `https://plex.tv/api/resources?includeHttps=1&includeRelay=1&X-Plex-Token=${serverToken}`; let resourceXml; try { resourceXml = await fetchXml(apiResourceUrl); } catch(e) { errorHandle(`Cannot load valid server information (resources API call returned error ${e})`); return false; } 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"); serverData.update({ servers : { [clientId] : { baseUri : baseUri, accessToken : accessToken, mediaData : {}, } } }); const relayXPath = "//Connection[@relay='1']"; const relay = resourceXml.evaluate(relayXPath, server, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); if (!relay.singleNodeValue || !relay.singleNodeValue.getAttribute("uri")) { // Can ignore a possible error here as this is only a fallback option continue; } const fallbackUri = relay.singleNodeValue.getAttribute("uri"); serverData.update({ servers : { [clientId] : { fallbackUri : fallbackUri, } } }); } return true; } // Keep trying loading server data if it happens to fail serverData.available = async function() { if (!(await serverData.promise)) { // Reload serverData.promise = serverData.load(); // If this one doesn't work we just fail and try again later return await serverData.promise; } return true; } // Shorthand for updating server data on a media item entry serverData.updateMediaDirectly = function(clientId, metadataId, newData) { serverData.update({ servers : { [clientId] : { mediaData : { [metadataId] : newData } } } }); } // Merge media noda data, excluding any file metadata, into the serverData media cache serverData.updateMediaBase = function(clientId, mediaObject, topPromise, previousRecurse) { // New data to add to this media item let mediaObjectData = { title : mediaObject.title, index : 0, }; // Index is used for sorting correctly when displayed in the modal // Some items are unindexed, and that's fine, they will be displayed in whatever order if (mediaObject.hasOwnProperty("index")) { mediaObjectData.index = mediaObject.index; } // Determine title // Note if this is a parent item, its title may be overwritten by its children .parentTitle // Therefore, only leaves can have these special titles apply switch (mediaObject.type) { case "episode": mediaObjectData.title = `Episode ${mediaObject.index}: ${mediaObject.title}`; break; case "movie": mediaObjectData.title = `${mediaObject.title} (${mediaObject.year})`; break; } // Copy the top level promise in case this is a lower recursion level. // If this isn't a lower recursion level, the promise is already there. // NOTE: this causes a bug where a media item request that is followed by a // children request can be double-requested if it itself is a child of something else. // The API recurse will go item1 -> children -> item2 -> children, ignoring that item2 // may already be in the media cache with a resolved promise. To avoid this, there would // need to be a check here if a media object already exists in the cache and then abort // further media data updating for it and its children. This is very annoying, and mostly // the fault of collections containing TV shows. if (previousRecurse) { mediaObjectData.promise = topPromise; } // Merge new data serverData.updateMediaDirectly(clientId, mediaObject.ratingKey, mediaObjectData); // Shorthand to add a child entry, if not already present, into a parent // Also can merge potentially otherwise missing data that the child knows about the parent function updateParent(childId, parentId, otherData) { if (otherData) { serverData.updateMediaDirectly(clientId, parentId, otherData); } serverData.updateMediaDirectly(clientId, parentId, { children : [], }); // Could use a Set object potentially instead if (!serverData.servers[clientId].mediaData[parentId].children.includes(childId)) { serverData.servers[clientId].mediaData[parentId].children.push(childId); } } // Handle parent, if neccessary if (mediaObject.hasOwnProperty("parentRatingKey")) { let parentData = { title : mediaObject.parentTitle, }; // Copy index for sorting if we have it if (mediaObject.hasOwnProperty("parentIndex")) { parentData.index = mediaObject.parentIndex; } // Copy promise to parent (season), if this was part of a show request // This isn't strictly required, but it reduces double-requesting if (previousRecurse && previousRecurse.type === "show" && mediaObject.type === "episode") { parentData.promise = topPromise; } updateParent(mediaObject.ratingKey, mediaObject.parentRatingKey, parentData); // Handle grandparent, if neccessary if (mediaObject.hasOwnProperty("grandparentRatingKey")) { let grandparentData = { title : mediaObject.grandparentTitle, }; // Copy index for sorting if we have it if (mediaObject.hasOwnProperty("grandparentIndex")) { grandparentData.index = mediaObject.grandparentIndex; } updateParent(mediaObject.parentRatingKey, mediaObject.grandparentRatingKey, grandparentData); } } // Update collection parent, if this was part of a collection // Collections are weird, they contain children but the child has no idea it's part of a collection (most of the time) if (previousRecurse && previousRecurse.type === "collection") { updateParent(mediaObject.ratingKey, previousRecurse.ratingKey); } } // Merge media node file metadata from API response into the serverData media cache serverData.updateMediaFileInfo = function(clientId, mediaObject, previousRecurse) { // Values we expect plus default values for fields needed by the modal let fileInfo = { key : mediaObject.Media[0].Part[0].key, filesize : mediaObject.Media[0].Part[0].size, filetype : "?", resolution : "?", runtimeMS : -1, } // Use multiple fallbacks in case something goes weird here if (mediaObject.Media[0].hasOwnProperty("container")) { fileInfo.filetype = mediaObject.Media[0].container; } else if (mediaObject.Media[0].Part[0].hasOwnProperty("container")) { fileInfo.filetype = mediaObject.Media[0].Part[0].container; } else if (fileInfo.key.lastIndexOf(".") !== -1) { fileInfo.filetype = fileInfo.key.slice(fileInfo.key.lastIndexOf(".") + 1); } if (mediaObject.Media[0].hasOwnProperty("videoResolution")) { fileInfo.resolution = mediaObject.Media[0].videoResolution.toUpperCase(); if ([ "144", "240", "480", "720", "1080" ].includes(fileInfo.resolution)) { // A specific p resolution fileInfo.resolution += "p"; } } if (mediaObject.Media[0].hasOwnProperty("duration")) { // Duration is measured in milliseconds fileInfo.runtimeMS = mediaObject.Media[0].duration; } serverData.updateMediaDirectly(clientId, mediaObject.ratingKey, fileInfo); } // Recursive function that will follow children/leaves of an API call and store them all into mediaData // Returns an async bool of success serverData.recurseMediaApi = async function(clientId, apiPath, topPromise, previousRecurse) { const baseUri = serverData.servers[clientId].baseUri; const accessToken = serverData.servers[clientId].accessToken; let responseJSON; try { responseJSON = await fetchJSON(`${baseUri}${apiPath}`); } catch(e) { // Network failure, try the fallback URI for this server if (serverData.servers[clientId].fallbackUri) { serverData.servers[clientId].baseUri = serverData.servers[clientId].fallbackUri; serverData.servers[clientId].fallbackUri = false; // Run again from the top return await serverData.recurseMediaApi(clientId, apiPath, topPromise, previousRecurse); } else { errorHandle(`Could not establish connection to server at ${serverData.servers[clientId].baseUri}: ${e}`); return false; } } const recursionPromises = []; for (let i = 0; i < responseJSON.MediaContainer.Metadata.length; i++) { let mediaObject = responseJSON.MediaContainer.Metadata[i]; // Record basic information about this media object before looking deeper into what it is serverData.updateMediaBase(clientId, mediaObject, topPromise, previousRecurse); if (mediaObject.hasOwnProperty("Media")) { serverData.updateMediaFileInfo(clientId, mediaObject, previousRecurse); continue; } if (mediaObject.hasOwnProperty("leafCount") || mediaObject.hasOwnProperty("childCount")) { // Very stupid quirk of the Plex API: it will tell you something has leaves, but then calling allLeaves gives nothing. // Only when something has children AND leaves can you use allLeaves // (like a TV show could have 10 children (seasons) and 100 leaves (episodes)) if ( mediaObject.hasOwnProperty("childCount") && mediaObject.hasOwnProperty("leafCount") && (mediaObject.childCount !== mediaObject.leafCount) ) { let leafUri = `/library/metadata/${mediaObject.ratingKey}/allLeaves?X-Plex-Token=${accessToken}`; let recursion = serverData.recurseMediaApi(clientId, leafUri, topPromise, mediaObject); recursionPromises.push(recursion); continue; } else { let childUri = `/library/metadata/${mediaObject.ratingKey}/children?X-Plex-Token=${accessToken}`; let recursion = serverData.recurseMediaApi(clientId, childUri, topPromise, mediaObject); recursionPromises.push(recursion); continue; } } } return await Promise.all(recursionPromises); } // Start pulling an API response for this media item. Returns an async bool indicating success serverData.loadMediaData = async function(clientId, metadataId) { // Make sure server data has loaded in if (!(await serverData.available())) { errorHandle(`Server information loading failed, trying again on next trigger.`); return false; } // Get access token and base URI for this server if (!serverData.servers[clientId].hasOwnProperty("baseUri") || !serverData.servers[clientId].hasOwnProperty("accessToken")) { errorHandle(`No server information for clientId ${clientId} when trying to load media data`); return false; } const accessToken = serverData.servers[clientId].accessToken; const promise = serverData.servers[clientId].mediaData[metadataId].promise; return await serverData.recurseMediaApi(clientId, `/library/metadata/${metadataId}?X-Plex-Token=${accessToken}`, promise); } // Try to ensure media data is loaded for a given item. Returns an async bool indicating if the item is available serverData.mediaAvailable = async function(clientId, metadataId) { if (serverData.servers[clientId].mediaData[metadataId].promise) { return await serverData.servers[clientId].mediaData[metadataId].promise; } else { // Note we don't create a request here as this method is used // in handleHashChange to detect if we need to create a new request return false; } } // Parse current URL to get clientId and metadataId, or `false` if unable to match const metadataIdRegex = /^\/library\/(?:metadata|collections)\/(\d+)$/; const clientIdRegex = /^\/server\/([a-f0-9]{40})\/(?:details|activity)$/; function parseUrl() { if (!location.hash.startsWith("#!/")) return false; // Use a URL object to parse the shebang let shebang = location.hash.slice(2); let hashUrl = new URL(`https://dummy.plex.tv${shebang}`); // URL.pathname should be something like: // /server/fd174cfae71eba992435d781704afe857609471b/details let clientIdMatch = clientIdRegex.exec(hashUrl.pathname); if (!clientIdMatch || clientIdMatch.length !== 2) return false; // URL.searchParams should be something like: // ?key=%2Flibrary%2Fmetadata%2F25439&context=home%3Ahub.continueWatching~0~0 // of which we only care about ?key=[], which should be something like: // /library/metadata/25439 let mediaKey = hashUrl.searchParams.get("key"); let metadataIdMatch = metadataIdRegex.exec(mediaKey); if (!metadataIdMatch || metadataIdMatch.length !== 2) return false; // Get rid of regex match and retain only capturing group 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 // Also handles avoiding duplicate API calls for the same media item async function handleHashChange() { let urlIds = parseUrl(); if (!urlIds) { // If not on the right URL to inject new elements, don't bother observing // Note: this assumes the URL which triggers pulling media data is the same URL which // is where the new element and functionality is to be injected. This is // currently true but may change in future plex desktop app updates. DOMObserver.stop(); return; } // URL matches, observe the DOM for when the injection point loads // Also handle readyState if this is the page we start on if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", DOMObserver.observe); } else { DOMObserver.observe(); } // Create empty media entry early serverData.updateMediaDirectly(urlIds.clientId, urlIds.metadataId, {}); if (!(await serverData.mediaAvailable(urlIds.clientId, urlIds.metadataId))) { let mediaPromise = serverData.loadMediaData(urlIds.clientId, urlIds.metadataId); serverData.servers[urlIds.clientId].mediaData[urlIds.metadataId].promise = mediaPromise; } } let download = {}; download.frameName = `${domPrefix}downloadFrame`; // Initiate a download of a URI using iframes download.fromUri = function(uri) { let frame = document.createElement("iframe"); frame.name = download.frameName; frame.style = "display: none !important;"; document.body.appendChild(frame); frame.src = uri; } // Clean up old DOM elements from previous downloads, if needed download.cleanUp = function() { // 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(download.frameName); while (oldFrames.length !== 0) { oldFrames[0].remove(); } } // Assemble download URI from key and base URI download.makeUri = function(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 download.fromMedia = function(clientId, metadataId) { if (serverData.servers[clientId].mediaData[metadataId].hasOwnProperty("key")) { const uri = download.makeUri(clientId, metadataId); download.fromUri(uri); } 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]; download.fromMedia(clientId, childId); } } } // Create and add the new DOM element, return a reference to it function modifyDom(injectionPoint) { // Clone the tag of the injection point element const downloadButton = document.createElement(injectionPoint.tagName); downloadButton.id = `${domPrefix}DownloadButton`; downloadButton.innerHTML = domElementInnerHTML; // Steal CSS from the injection point element by copying its class name downloadButton.className = `${domPrefix}element ${injectionPoint.className}`; // Apply custom CSS first downloadButton.style = domElementStyle; // Match the font used by the text content of the injection point // We traverse the element and select the first text node, then use its parent let textNode = (function findTextNode(parent) { for (let child of parent.childNodes) { if (child.nodeType === HTMLElement.TEXT_NODE) { return child; } if (child.hasChildNodes()) { let recurseResult = findTextNode(child); if (recurseResult) { return recurseResult; } } } return false; })(injectionPoint); // If no text node was found as a child of the injection point, fall back to the injection point itself let textParentNode = textNode ? textNode.parentNode : injectionPoint; // Get computed font and apply it let textNodeStyle = getComputedStyle(textParentNode); downloadButton.style.font = textNodeStyle.getPropertyValue("font"); downloadButton.style.color = textNodeStyle.getPropertyValue("color"); // Starts disabled downloadButton.style.opacity = 0.5; downloadButton.disabled = true; switch (injectPosition.toLowerCase()) { case "after": injectionPoint.after(downloadButton); break; case "before": injectionPoint.before(downloadButton); break; default: errorHandle(`Invalid injection position: ${injectPosition}`); break; } return downloadButton; } // Activate DOM element and hook clicking with function. Returns bool indicating success async function domCallback(domElement, clientId, metadataId) { // Make sure server data has loaded in if (!(await serverData.available())) { errorHandle(`Server information loading failed, trying again on next trigger.`); return false; } // Make sure we have media data for this item if (!(await serverData.mediaAvailable(clientId, metadataId))) { errorHandle(`Could not load data for metadataId ${metadataId}`); return false; } // Hook function to button if everything works const downloadFunction = function(e) { e.stopPropagation(); download.cleanUp(); // Open modal box for group media items if (serverData.servers[clientId].mediaData[metadataId].hasOwnProperty("children")) { modal.open(clientId, metadataId); } else { // Download immediately for single media items download.fromMedia(clientId, metadataId); } }; domElement.addEventListener("click", downloadFunction); // Add the filesize on hover, if available if (serverData.servers[clientId].mediaData[metadataId].hasOwnProperty("filesize")) { let filesize = makeFilesize(serverData.servers[clientId].mediaData[metadataId].filesize); domElement.setAttribute("title", filesize); } return true; } function init() { // Begin loading server data immediately serverData.promise = serverData.load(); // Try to start immediately handleHashChange(); window.addEventListener("hashchange", handleHashChange); } init(); })();