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