Plex downloader

Adds a download button to the Plex desktop interface. Works on episodes, movies, whole seasons, and entire shows.

// ==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.11
// @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="&#x2715;" 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;
		}
		
		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.setAttribute("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.setAttribute("title", "This server is configured to disallow downloads.");
			} else {
				domElement.setAttribute("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.setAttribute("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();
})();