// ==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.12
// @license MIT
// @grant none
// @match https://app.plex.tv/desktop/
// @include https://*.*.plex.direct:32400/web/index.html*
// @run-at document-start
// @namespace https://greasyfork.org/users/1260133
// ==/UserScript==
// Bookmarklet version:
/*
javascript:(d=>{if(!window._PLDLR){let s;window._PLDLR=s=d.createElement`script`;s.src='https://update.greasyfork.org/scripts/487119/Plex%20downloader.user.js';d.head.append(s)}})(document)
*/
// This code is a heavy modification of the existing PlxDwnld project
// https://sharedriches.com/plex-scripts/piplongrun/
(function() {
"use strict";
function randToken() {
return Math.random().toString(36).slice(2);
}
const logPrefix = "[USERJS Plex Downloader]";
const domPrefix = `USERJSINJECTED-${randToken()}_`.toLowerCase();
// 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 = "<svg style='height:1.5rem; width:1.5rem; margin:0 4px 0 0;'><g><path d='M3,12.3v7a2,2,0,0,0,2,2H19a2,2,0,0,0,2-2v-7' fill='none' stroke='currentcolor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'></path><g><polyline fill='none' stroke='currentcolor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' points='7.9 12.3 12 16.3 16.1 12.3'></polyline><line fill='none' stroke='currentcolor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' x1='12' x2='12' y1='2.7' y2='14.2'></line></g></g></svg>Download";
// Should not be visible in normal operation
const errorLog = [];
function errorHandle(msg) {
errorLog.push(msg);
console.log(`${logPrefix} ${msg.toString()}`);
}
// Redact potentially sensitive information from a URL so it can be safely used for error reports
const ipAddrRegex = /^\d{1,3}-\d{1,3}-\d{1,3}-\d{1,3}$/;
const ipAddrReplace = "1-1-1-1";
const hexStartRegex = /^[0-9a-f]{16}/;
const hexStartReplace = "XXXXXXXXXXXXXXXX";
const XPlexTokenReplace = "REDACTED";
function redactUrl(unsafeUrl) {
let url;
try {
url = new URL(unsafeUrl);
} catch {
// A totally malformed URL throws exceptions
return "?";
}
let domains = url.hostname.split(".");
for (let i = 0; i < domains.length; i++) {
domains[i] = domains[i].replace(ipAddrRegex, ipAddrReplace);
domains[i] = domains[i].replace(hexStartRegex, hexStartReplace);
}
url.hostname = domains.join(".");
if (url.searchParams.has("X-Plex-Token")) {
url.searchParams.set("X-Plex-Token", XPlexTokenReplace);
}
return url.href;
}
// Turn a number of bytes to a more friendly size display
const fsUnits = [ "B", "KB", "MB", "GB", "TB" ];
function makeFilesize(numbytes) {
let ui = 0;
numbytes = parseInt(numbytes);
if (isNaN(numbytes) || numbytes < 0) {
return "?";
}
// I don't care what hard drive manufacturers say, there are 1024 bytes in a kilobyte
while (numbytes >= 1024 && ui < fsUnits.length - 1) {
numbytes /= 1024;
ui++;
}
if (ui !== 0) {
return `${numbytes.toFixed(2)} ${fsUnits[ui]}`;
} else {
return `${numbytes} ${fsUnits[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%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 = {};
// Must use DocumentFragment to access getElementById
modal.documentFragment = document.createDocumentFragment();
modal.container = document.createElement(`${domPrefix}element`);
modal.documentFragment.append(modal.container);
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; /* Important to explicitly declare! */
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 {
min-width: 33%;
max-width: 90%;
min-height: 40%;
max-height: min(80%, 650px);
display: flex;
flex-direction: column;
gap: 1em;
padding: 1em 1.3em;
border-radius: 14px;
background: #3f3f42;
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: #fff8 #fff1;
scrollbar-width: thin;
background: #0005;
border-radius: 6px;
box-shadow: 0 0 4px 1px #0003 inset;
border-left: 2px solid #222;
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;
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;
background: #0008;
padding: 0.2em 0.5em;
border-radius: 4px;
cursor: pointer;
color: #eee;
border: 1px solid #5555;
font-size: 14pt;
}
#${domPrefix}modal_downloadbutton:not([disabled]):hover {
background: #14161a78;
}
#${domPrefix}modal_downloadbutton[disabled] {
opacity: 0.5;
cursor: default;
}
#${domPrefix}modal_container .${domPrefix}modal_table_header {
display: table-row;
font-weight: 600;
position: sticky;
top: 0;
background: #222;
box-shadow: 0 0 4px #000a;
}
#${domPrefix}modal_container .${domPrefix}modal_table_header > *:not(:first-child) {
border-left: 1px solid #bcf1;
}
#${domPrefix}modal_container .${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 = `
<style>${modal.stylesheet}</style>
<${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>
<input type="button" id="${domPrefix}modal_topx" value="✕" aria-label="close" title="Close" tabindex="0"/>
<input type="hidden" id="${domPrefix}modal_clientid" tabindex="-1"/>
<input type="hidden" id="${domPrefix}modal_parentid" tabindex="-1"/>
<${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">
<label for="${domPrefix}modal_checkall" class="${domPrefix}modal_table_cell" title="Select all">
<input type="checkbox" id="${domPrefix}modal_checkall" checked tabindex="0"/>
</label>
<${domPrefix}element class="${domPrefix}modal_table_cell" style="width:100%">File</${domPrefix}element>
<${domPrefix}element class="${domPrefix}modal_table_cell">Watched</${domPrefix}element>
<${domPrefix}element class="${domPrefix}modal_table_cell">Runtime</${domPrefix}element>
<${domPrefix}element class="${domPrefix}modal_table_cell">Resolution</${domPrefix}element>
<${domPrefix}element class="${domPrefix}modal_table_cell">Type</${domPrefix}element>
<${domPrefix}element class="${domPrefix}modal_table_cell">Size</${domPrefix}element>
</${domPrefix}element>
</${domPrefix}element>
<${domPrefix}element style="display:table-row-group" id="${domPrefix}modal_table_rowcontainer">
<!-- Items inserted here -->
</${domPrefix}element>
</${domPrefix}element>
</${domPrefix}element>
<${domPrefix}element id="${domPrefix}modal_downloaddescription"></${domPrefix}element>
<${domPrefix}element>
<input type="button" id="${domPrefix}modal_downloadbutton" value="Download" tabindex="0"/>
</${domPrefix}element>
</${domPrefix}element>
</${domPrefix}element>
<template id="${domPrefix}modal_item_template">
<label style="display:table-row" data-${domPrefix}template-id="modal_item_label">
<${domPrefix}element class="${domPrefix}modal_table_cell">
<input type="checkbox" checked data-${domPrefix}template-id="modal_item_checkbox" class="${domPrefix}modal_item_checkbox" tabindex="0"/>
</${domPrefix}element>
<${domPrefix}element class="${domPrefix}modal_table_cell" data-${domPrefix}template-id="modal_item_title" style="text-align:left"></${domPrefix}element>
<${domPrefix}element class="${domPrefix}modal_table_cell" data-${domPrefix}template-id="modal_item_watched" style="white-space:nowrap"></${domPrefix}element>
<${domPrefix}element class="${domPrefix}modal_table_cell" data-${domPrefix}template-id="modal_item_runtime" style="white-space:nowrap"></${domPrefix}element>
<${domPrefix}element class="${domPrefix}modal_table_cell" data-${domPrefix}template-id="modal_item_resolution" style="white-space:nowrap"></${domPrefix}element>
<${domPrefix}element class="${domPrefix}modal_table_cell" data-${domPrefix}template-id="modal_item_filetype" style="white-space:nowrap"></${domPrefix}element>
<${domPrefix}element class="${domPrefix}modal_table_cell" data-${domPrefix}template-id="modal_item_filesize" style="white-space:nowrap"></${domPrefix}element>
</label>
</template>
`;
modal.getElementByIdSuffix = function(idSuffix) {
return modal.documentFragment.getElementById(`${domPrefix}${idSuffix}`);
};
modal.overlay = modal.getElementByIdSuffix("modal_overlay");
modal.popup = modal.getElementByIdSuffix("modal_popup");
modal.title = modal.getElementByIdSuffix("modal_title");
modal.itemContainer = modal.getElementByIdSuffix("modal_table_rowcontainer");
modal.topX = modal.getElementByIdSuffix("modal_topx");
modal.downloadButton = modal.getElementByIdSuffix("modal_downloadbutton");
modal.checkAll = modal.getElementByIdSuffix("modal_checkall");
modal.clientId = modal.getElementByIdSuffix("modal_clientid");
modal.parentId = modal.getElementByIdSuffix("modal_parentid");
modal.downloadDescription = modal.getElementByIdSuffix("modal_downloaddescription");
modal.itemTemplate = modal.getElementByIdSuffix("modal_item_template");
// Live updating collection of items
modal.itemCheckboxes = modal.itemContainer.getElementsByClassName(`${domPrefix}modal_item_checkbox`);
modal.firstTab = modal.topX;
modal.lastTab = modal.downloadButton;
// Allow Tab/Enter/Space to correctly interact with the modal
modal.captureKeyPress = function(event) {
// Do nothing is modal is not open
if (!modal.container.classList.contains(`${domPrefix}open`)) {
return;
}
// No keypresses are allowed to interact with any lower event listeners
event.stopImmediatePropagation();
switch (event.key) {
case "Tab":
// Move focus into the modal if it somehow isn't already
if (!modal.container.contains(document.activeElement)) {
event.preventDefault();
modal.firstTab.focus();
break;
}
// Clamp tabbing to the next element to the selectable elements within the modal
// Shift key reverses the direction
if (event.shiftKey) {
if (document.activeElement === modal.firstTab) {
event.preventDefault();
modal.lastTab.focus();
}
} else {
if (document.activeElement === modal.lastTab) {
event.preventDefault();
modal.firstTab.focus();
}
}
break;
case "Escape":
event.preventDefault();
modal.close();
break;
case "Enter":
// The enter key interacting with checkboxes can be unreliable
event.preventDefault();
if (modal.container.contains(document.activeElement)) {
document.activeElement.click();
}
break;
}
};
modal.keyUpDetectEscape = function(event) {
if (event.key === "Escape") {
modal.close();
}
};
// Set up this listener immediately, and decide whether to fire it or not inside the callback
// This is required so no other event listener fires before it, by being attached after it
window.addEventListener("keydown", modal.captureKeyPress, { capturing : true });
// Modal removes itself from the DOM once its CSS transition is over
modal.container.addEventListener("transitionend", function(event) {
// Ignore any transitionend events fired by child elements
if (event.target !== modal.container) {
return;
}
// Look to remove the modal from the DOM
if (!modal.container.classList.contains(`${domPrefix}open`)) {
modal.documentFragment.append(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.append(modal.container);
// Listen to page navigation to close the modal
window.addEventListener("popstate", modal.close);
// BUG: in some circumstances, the Escape key will not fire a keydown keyboard event
// I believe this is Plex's fault. If you execute:
// window.dispatchEvent(new KeyboardEvent('keydown', {'key': 'Escape'}));
// then the event dispatches normally. However, if you instead do:
// document.body.dispatchEvent(new KeyboardEvent('keydown', {'key': 'Escape'}));
// then the event handler for document.body sometimes, somehow, stops the event, even
// if an earlier event handler is supposed to get the event first.
// The only fix for this I found is to also listen for keyup to detect Escape
window.addEventListener("keyup", modal.keyUpDetectEscape);
// 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 listening to popstate
window.removeEventListener("popstate", modal.close);
window.removeEventListener("keyup", modal.keyUpDetectEscape);
// 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(event) { event.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
};
// Clone the item template and gather references to its important child nodes
modal.getItemTemplateClone = function() {
let clone = modal.itemTemplate.content.cloneNode(/*deep=*/true);
let idMap = {};
for (let node of clone.querySelectorAll(`[data-${domPrefix}template-id]`)) {
let id = node.getAttribute(`data-${domPrefix}template-id`);
idMap[id] = node;
}
return {
documentFragment : clone,
elementId : idMap
};
};
// 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 (Object.hasOwn(serverData.servers[clientId].mediaData[metadataId], "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.getItemTemplateClone();
// Set up functionality of checkbox and label
let checkbox = item.elementId["modal_item_checkbox"];
checkbox.id = `${domPrefix}item_checkbox_${metadataId}`;
checkbox.value = metadataId;
checkbox.addEventListener("change", modal.checkBoxChange);
item.elementId["modal_item_label"].htmlFor = checkbox.id;
// Ignore the first title, which is the modal title instead
let itemTitle = titles.slice(1).join(", ");
// Set hover title
item.elementId["modal_item_label"].title = `Download ${itemTitle}`;
// Fill fields in table cells
item.elementId["modal_item_title"].textContent = itemTitle;
item.elementId["modal_item_watched"].textContent = mediaData.viewed ? "\u2713" : ""; // U+2713 is a checkmark symbol
item.elementId["modal_item_watched"].title = mediaData.viewed ? "Watched" : "Unwatched";
item.elementId["modal_item_runtime"].textContent = makeDuration(mediaData.runtimeMS);
item.elementId["modal_item_resolution"].textContent = mediaData.resolution;
item.elementId["modal_item_filetype"].textContent = mediaData.filetype.toUpperCase();
item.elementId["modal_item_filesize"].textContent = makeFilesize(mediaData.filesize);
modal.itemContainer.append(item.documentFragment);
}
titles.pop();
})(metadataId, []);
// Set the modal title
modal.title.textContent = `Download from ${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();
};
// 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,
};
// Wrapper to make an API call to a specific Plex server
serverData.apiCall = async function(clientId, apiPath) {
const baseUri = serverData.servers[clientId].baseUri;
const accessToken = serverData.servers[clientId].accessToken;
const apiUrl = new URL(`${baseUri}${apiPath}`);
apiUrl.searchParams.set("X-Plex-Token", accessToken);
try {
// Headers here are required for Plex API to respond in JSON
let response = await fetch(apiUrl.href, { headers : { accept : "application/json" } });
if (!response.ok) {
// If the server responds with non-OK, then there is a non-network related issue
// Perhaps on a bad page with invalid URL?
errorHandle(`Could not retrieve API data at ${redactUrl(apiUrl.href)} : received response code ${response.status}`);
return false;
}
// Parse JSON body, may fail with SyntaxError
let responseJSON = await response.json();
return responseJSON;
} catch (exception) {
switch (exception.name) {
case "TypeError":
// 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.apiCall(clientId, apiPath);
} else {
errorHandle(`Could not establish connection to server at ${redactUrl(apiUrl.href)} : ${exception.message}`);
}
break;
case "SyntaxError":
// Did not parse JSON, malformed response in some way
errorHandle(`Could not parse API JSON at ${redactUrl(apiUrl.href)} : ${exception.message}`);
break;
default:
errorHandle(`Could not retrieve API data at ${redactUrl(apiUrl.href)} : ${exception.message}`);
break;
}
return false;
}
};
// Merge new data object into serverData
serverData.update = function(newData, serverDataScope) {
serverDataScope = serverDataScope || serverData;
for (let key in newData) {
if (!Object.hasOwn(serverDataScope, 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]);
}
}
};
// Make sure a server is online and allows downloads
serverData.checkServer = async function(clientId) {
const apiPath = "/media/providers/";
let responseJSON = await serverData.apiCall(clientId, apiPath);
if (responseJSON === false) {
return false;
}
serverData.servers[clientId].allowsDl = responseJSON.MediaContainer.allowSync;
// True here just meaning this request succeeded, nothing about the allowsDl field
return true;
};
// 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");
let browserToken = window.localStorage.getItem("clientID");
if (serverToken === null || browserToken === null) {
errorHandle(`Cannot find a valid access token (localStorage Plex token missing).`);
return false;
}
const apiResourceUrl = new URL("https://clients.plex.tv/api/v2/resources");
apiResourceUrl.searchParams.set("includeHttps", "1");
apiResourceUrl.searchParams.set("includeRelay", "1");
apiResourceUrl.searchParams.set("X-Plex-Client-Identifier", browserToken);
apiResourceUrl.searchParams.set("X-Plex-Token", serverToken);
let resourceJSON;
try {
let response = await fetch(apiResourceUrl.href, { headers : { accept : "application/json" } });
if (!response.ok) {
// If Plex responds with non-OK, then there is a non-network related issue
// Perhaps Plex is down, serving 500s?
errorHandle(`Could not retrieve Plex resources: received HTTP ${response.status}`);
return false;
}
resourceJSON = await response.json();
} catch (exception) {
switch (exception.name) {
case "TypeError":
errorHandle(`Network error occurred while retrieving Plex resources: ${exception.message}`);
break;
case "SyntaxError":
errorHandle(`Could not parse JSON while retrieving Plex resources: ${exception.message}`);
break;
default:
errorHandle(`Unknown error occurred while retrieving Plex resources: ${exception.message}`);
break;
}
return false;
}
for (let i = 0; i < resourceJSON.length; i++) {
let server = resourceJSON[i];
if (server.provides !== "server") continue;
if (!Object.hasOwn(server, "clientIdentifier") || !Object.hasOwn(server, "accessToken")) {
errorHandle(`Cannot find valid server information (missing ID or token in API response).`);
continue;
}
const clientId = server.clientIdentifier;
const accessToken = server.accessToken;
const connection = server.connections.find(connection => (!connection.local && !connection.relay));
if (!connection || !Object.hasOwn(connection, "uri")) {
errorHandle(`Cannot find valid server information (no connection data for server ${clientId}).`);
continue;
}
const baseUri = connection.uri;
serverData.update({
servers : {
[clientId] : {
baseUri : baseUri,
accessToken : accessToken,
mediaData : {},
allowsDl : "indeterminate",
}
}
});
const relay = server.connections.find(connection => (!connection.local && connection.relay));
if (relay && Object.hasOwn(relay, "uri")) {
// Can ignore a possible error here as this is only a fallback option
const fallbackUri = relay.uri;
serverData.update({
servers : {
[clientId] : {
fallbackUri : fallbackUri,
}
}
});
}
// Run checks
serverData.update({
servers : {
[clientId] : {
check : serverData.checkServer(clientId),
}
}
});
}
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 (Object.hasOwn(mediaObject, "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 : [],
});
// Cannot use a Set object here, since the items are ordered
if (!serverData.servers[clientId].mediaData[parentId].children.includes(childId)) {
serverData.servers[clientId].mediaData[parentId].children.push(childId);
}
}
// Handle parent, if neccessary
if (Object.hasOwn(mediaObject, "parentRatingKey")) {
let parentData = {
title : mediaObject.parentTitle,
};
// Copy index for sorting if we have it
if (Object.hasOwn(mediaObject, "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 (Object.hasOwn(mediaObject, "grandparentRatingKey")) {
let grandparentData = {
title : mediaObject.grandparentTitle,
};
// Copy index for sorting if we have it
if (Object.hasOwn(mediaObject, "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,
viewed : false,
// letterboxd : false,
}
// Replace forward slashes with backslashes, then use the last backslash
// This is to work on both Windows and Unix filepaths
let filename = mediaObject.Media[0].Part[0].file;
filename = filename.replaceAll("/", "\\");
filename = filename.slice(filename.lastIndexOf("\\") + 1);
fileInfo.filename = filename;
// Use multiple fallbacks in case something goes weird here
if (Object.hasOwn(mediaObject.Media[0], "container")) {
fileInfo.filetype = mediaObject.Media[0].container;
} else if (Object.hasOwn(mediaObject.Media[0].Part[0], "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 (Object.hasOwn(mediaObject.Media[0], "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 (Object.hasOwn(mediaObject.Media[0], "duration")) {
// Duration is measured in milliseconds
fileInfo.runtimeMS = mediaObject.Media[0].duration;
}
// Checked viewcount for viewed flag
if (Object.hasOwn(mediaObject, "viewCount") && mediaObject.viewCount !== 0) {
fileInfo.viewed = true;
}
/*
if (Object.hasOwn(mediaObject, "Guid")) {
for (let i = 0; i < mediaObject.Guid.length; i++) {
let id = mediaObject.Guid[i].id;
if (id.startsWith("imdb://") || id.startsWith("tmdb://")) {
fileInfo.letterboxd = `https://letterboxd.com/${id.slice(0,4)}/${id.slice(7)}`;
break;
}
}
}
*/
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) {
let responseJSON = await serverData.apiCall(clientId, apiPath);
if (responseJSON === false) {
return false;
}
// Catch empty media items (can happen!)
if (!Object.hasOwn(responseJSON.MediaContainer, "Metadata")) {
return true;
}
const recursionPromises = [];
/*
// Possible better method than detecting /allLeaves vs /children
let continueRecursion = true;
if (Object.hasOwn(responseJSON.MediaContainer, "Directory")) {
continueRecursion = false;
let nextPath = responseJSON.MediaContainer.Directory[0].key;
let recursion = serverData.recurseMediaApi(clientId, nextPath, topPromise, null);
recursionPromises.push(recursion);
}
*/
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 this object has associated media, record its file information
if (Object.hasOwn(mediaObject, "Media")) {
serverData.updateMediaFileInfo(clientId, mediaObject, previousRecurse);
continue;
}
// Otherwise, check if this object has children/leaves that need to be recursed
if (Object.hasOwn(mediaObject, "leafCount") || Object.hasOwn(mediaObject, "childCount")) {
let nextPath = `/library/metadata/${mediaObject.ratingKey}/children`;
// 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 (
Object.hasOwn(mediaObject, "childCount") &&
Object.hasOwn(mediaObject, "leafCount") &&
(mediaObject.childCount !== mediaObject.leafCount)
) {
nextPath = `/library/metadata/${mediaObject.ratingKey}/allLeaves`;
}
let recursion = serverData.recurseMediaApi(clientId, nextPath, 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())) {
return false;
}
// Get access token and base URI for this server
if (!Object.hasOwn(serverData.servers[clientId], "baseUri") ||
!Object.hasOwn(serverData.servers[clientId], "accessToken")) {
errorHandle(`No server information for clientId ${clientId} when trying to load media data`);
return false;
}
// Make sure this server is alive and allows downloads
if (!(await serverData.servers[clientId].check)) {
// Check again if we couldn't complete the previous check
serverData.servers[clientId].check = serverData.checkServer(clientId);
if (!(await serverData.servers[clientId].check)) {
// This should have already triggered an errorHandle at the failed request
return false;
}
}
if (serverData.servers[clientId].allowsDl === false && serverData.servers[clientId].baseUri !== `${location.protocol}//${location.host}`) {
// Downloading disabled by server
return false;
}
const promise = serverData.servers[clientId].mediaData[metadataId].promise;
return await serverData.recurseMediaApi(clientId, `/library/metadata/${metadataId}`, 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;
}
}
window.addEventListener("hashchange", handleHashChange);
let download = {};
download.frameClass = `${domPrefix}downloadFrame`;
download.trigger = document.createElement("a");
// Live collection of frames
download.frames = document.getElementsByClassName(download.frameClass);
// Initiate a download of a URI using iframes
download.fromUri = function(uri, filename) {
let frame = document.createElement("iframe");
frame.className = download.frameClass;
frame.name = `USERJSINJECTED-${randToken()}`;
frame.style = "display: none !important;";
document.body.append(frame);
// Must be same origin to use specific file names, otherwise they are just ignored
// Must use the <a> tag with the download and target attributes to do this without opening windows or tabs
download.trigger.href = uri;
download.trigger.target = frame.name;
download.trigger.download = filename;
download.trigger.click();
};
// 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
while (download.frames.length !== 0) {
download.frames[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 url = new URL(`${baseUri}${key}`);
url.searchParams.set("X-Plex-Token", accessToken);
url.searchParams.set("download", "1");
return url.href;
};
// Download a media item, handling parents/grandparents
download.fromMedia = function(clientId, metadataId) {
if (Object.hasOwn(serverData.servers[clientId].mediaData[metadataId], "key")) {
const uri = download.makeUri(clientId, metadataId);
const filename = serverData.servers[clientId].mediaData[metadataId].filename;
if (serverData.servers[clientId].allowsDl === false && uri.startsWith(`${location.protocol}//${location.host}`)) {
let url = new URL(uri);
url.searchParams.set("download", "0");
download.fromUri(url.href, filename);
} else {
download.fromUri(uri, filename);
}
}
if (Object.hasOwn(serverData.servers[clientId].mediaData[metadataId], "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.cssText = 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 an async bool indicating success
async function domCallback(domElement, clientId, metadataId) {
// Make sure server data has loaded in
if (!(await serverData.available())) {
domElement.title = "Failed to load Plex resource information.";
return false;
}
// Make sure we have media data for this item
if (!(await serverData.mediaAvailable(clientId, metadataId))) {
if (serverData.servers[clientId].allowsDl === false) {
// Nothing went wrong, this server just forbids downloads
domElement.title = "This server is configured to disallow downloads.";
} else {
domElement.title = "Failed to load media information from this Plex server.";
}
return false;
}
// Hook function to button if everything works
const downloadFunction = function(event) {
event.stopPropagation();
download.cleanUp();
// Open modal box for group media items
if (Object.hasOwn(serverData.servers[clientId].mediaData[metadataId], "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 (Object.hasOwn(serverData.servers[clientId].mediaData[metadataId], "filesize")) {
let filesize = makeFilesize(serverData.servers[clientId].mediaData[metadataId].filesize);
domElement.title = filesize;
}
return true;
}
function init() {
// Begin loading server data immediately
serverData.promise = serverData.load();
// Check the URL we loaded in on
handleHashChange();
// Check the callback immediately too, just in case the script was not loaded before the page did
DOMObserver.callback();
}
init();
})();