// ==UserScript==
// @name YouTube Playlist Duration Sort
// @namespace http://tampermonkey.net/
// @version 0.5
// @description Add duration sorting options to YouTube playlists.
// @author Surf Archer
// @icon https://www.youtube.com/favicon.ico
// @require https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.0.0/crypto-js.min.js
// @match https://www.youtube.com/playlist?*
// @grant none
// @run-at document-idle
// ==/UserScript==
// VERSION HISTORY
// ---------------
// v0.2 08-Oct-2020 Initial public release.
// v0.3 08-Oct-2020 Removed unusued/unneeded functions. Reordered code for ease of reading. Wrote out use of wait().
// v0.4 20-Oct-2020 Restructured to modern format. Made this a prviate script - it only works on Chrome on Windows.
// v0.5 21-Oct-2020 Implemented new method to reorder playlists - synthesizing YouTube fetch() commands. Fix to debug mode.
'use strict';
logMsg("Initialising YouTube Playlist Duration Sort...");
const debug = false;
// Setup code.
addSortMenuItem("Duration (shortest)", "durationSortClickShortest");
addSortMenuItem("Duration (longest)", "durationSortClickLongest");
injectJS();
function addSortMenuItem(text, handler) {
var sfm = document.querySelector("#sort-filter-menu");
var submenu = sfm.querySelector("#menu");
var d1=document.createElement("DIV");
d1.setAttribute("class", "item style-scope yt-dropdown-menu");
d1.textContent=text;
var d2=document.createElement("DIV");
d2.setAttribute("secondary", "");
d2.setAttribute("id", "subtitle");
d2.setAttribute("class", "style-scope yt-dropdown-menu");
d2.setAttribute("hidden", "");
var pib=document.createElement("paper-item-body");
pib.setAttribute("class", "style-scope yt-dropdown-menu");
pib.appendChild(d1);
pib.appendChild(d2);
var pi=document.createElement("paper-item");
pi.setAttribute("class", "style-scope yt-dropdown-menu");
pi.setAttribute("role", "option");
pi.tabindex="0";
pi.appendChild(pib);
var e=document.createElement("A");
e.setAttribute("class", "yt-simple-endpoint style-scope yt-dropdown-menu");
e.setAttribute("id", handler);
e.setAttribute("tabindex", "-1");
e.appendChild(pi);
submenu.appendChild(e);
}
function injectJS() {
logMsg("Injecting Javascript...");
var script = document.createElement("script");
script.type = "application/javascript";
script.textContent = ("(" + injectScript + ")();").replace("const debug = false;", "const debug = "+debug+";");
document.body.appendChild(script);
logMsg("Javascript injected!");
}
function injectScript() {
logMsg("Initialising durationSort...");
// This following gets modified to "carry into" from the outer process during injection.
const debug = false;
var duratonSortRunning = false;
var currentDelay = 0;
addScriptToPage("//cdnjs.cloudflare.com/ajax/libs/crypto-js/4.0.0/crypto-js.min.js");
document.getElementById('durationSortClickShortest').onclick = durationSortClickShortest;
document.getElementById('durationSortClickLongest').onclick = durationSortClickLongest;
document.addEventListener('ytplDurationSortEvent', durationSortEvent);
// EVENT HANDLERS.
function durationSortClickShortest() {
logMsg('durationSortClickShortest')
delayedRun(function(){
var event = new CustomEvent('ytplDurationSortEvent', {
detail: {
operation: "init", order: 0
}
});
document.dispatchEvent(event);
}, 50);
}
function durationSortClickLongest() {
logMsg('durationSortClickLongest')
delayedRun(function(){
var event = new CustomEvent('ytplDurationSortEvent', {
detail: {
operation: "init", order: 1
}
});
document.dispatchEvent(event);
}, 50);
}
function durationSortEvent(e) {
logDebug("durationSortEvent("+JSON.stringify(e.detail)+")");
currentDelay = 0;
switch(e.detail.operation) {
case "init":
initSort(e);
break;
case "scroll-to-end":
loadPlaylist(e);
break;
case "sort":
sortPlaylist(e);
break;
case "finished":
finishedSorting();
break;
}
}
// WORKER FUNCTIONS.
function initSort(e) {
logMsg("Initialising playlist sorting...");
// De-click the menu.
delayedRun(function(){
var elemMenu = document.querySelector("#sort-filter-menu > yt-sort-filter-sub-menu-renderer > yt-dropdown-menu > paper-menu-button");
var evObj = document.createEvent('Events');
evObj.initEvent('click', true, false);
elemMenu.dispatchEvent(evObj);
}, 50);
// Lock the page.
delayedRun(lockPage(true), 100);
// Then initiate the sort.
e.detail.operation = "sort";
delayedRun(function(){
var event = new CustomEvent('ytplDurationSortEvent', {detail: e.detail});
document.dispatchEvent(event);
}, 150);
}
function loadPlaylist(e) {
logMsg("Ensuring playlist is fully loaded...");
logMsg(" - loadPlaylist() starting");
var playList = document.querySelector("#contents > ytd-playlist-video-renderer:nth-child(1)").parentNode;
var numChildren = playList.children.length;
logDebug(" - Videos in list: "+numChildren);
if(numChildren > 0) {
logDebug(" - Scrolling to last video...");
document.querySelector("#contents > ytd-playlist-video-renderer:nth-child("+numChildren+")").scrollIntoView(true);
// Then initiate the sort.
delayedRun(function(){
var event = new CustomEvent('ytplDurationSortEvent', {
detail: { operation: "sort", order: e.detail.order }
});
document.dispatchEvent(event);
}, 250);
}
logMsg(" - loadPlaylist() finished");
}
function sortPlaylist(e) {
logDebug(" - sortPlaylist("+JSON.stringify(e.detail)+") starting");
var numInList=document.querySelector("#contents > ytd-playlist-video-renderer:nth-child(1)").parentNode.children.length;
var eventSent=false;
for (var i = 1; numInList > 1 && i < numInList && !eventSent; i++) {
var nInd=i + 1;
var nSecs=parseInt(getRowVal(i, "lengthSeconds"));
var nPrevInd=0;
var nMoveTo=-1;
while(nPrevInd < nInd && nMoveTo == -1) {
var nPrevSecs=parseInt(getRowVal(nPrevInd, "lengthSeconds"));
if(nPrevSecs !== undefined) {
if(e.detail.order == 0 && nPrevSecs > nSecs) {
nMoveTo=nPrevInd;
}
if(e.detail.order == 1 && nPrevSecs < nSecs) {
nMoveTo=nPrevInd;
}
}
nPrevInd++;
}
if(nMoveTo > -1) {
nMoveTo++;
logDebug(" - MOVING row "+nInd+" ("+nSecs+" secs) to row "+nMoveTo+" ("+nPrevSecs+" secs)", true);
moveRowTo(nInd, nMoveTo);
if(i < (numInList - 1)) {
// Then send the finished event.
delayedRun(function(){
var event = new CustomEvent('ytplDurationSortEvent', {
detail: e.detail
});
document.dispatchEvent(event);
}, 750);
eventSent=true;
}
} else {
logDebug(" - Ignoring row "+nInd+" (duration: "+nSecs+", prevDuration: "+nPrevSecs+")");
}
}
if(!eventSent) {
// Then initiate the sort.
delayedRun(function(){
var event = new CustomEvent('ytplDurationSortEvent', {
detail: { operation: "finished", order: e.detail.order }
});
document.dispatchEvent(event);
}, 250);
} else {
logDebug(" - sortPlaylist("+JSON.stringify(e.detail)+") finished");
}
}
function finishedSorting() {
logMsg("Finished sorting, doing cleanup...");
logDebug(" - re-enable the page.");
lockPage(false);
logDebug(" - re-fresh the page.");
if(!debug) {
location.reload();
} else {
logDebug(" - Skipping reload since DEBUG is on.");
}
};
// PLAYLIST FUNCTIONS.
function getRowVal(index, key) {
var row=document.querySelector("#contents > ytd-playlist-video-renderer:nth-child(1)").parentNode.children[index];
if((key in row.data)) {
return row.data[key];
}
}
function moveRowTo(src, dest) {
var ret=false;
var srcElement=document.querySelector("#contents > ytd-playlist-video-renderer:nth-child("+src+")");
var destElement=document.querySelector("#contents > ytd-playlist-video-renderer:nth-child("+dest+")");
var srcEndpoint=srcElement.data.menu.menuRenderer.items[3].menuServiceItemRenderer.serviceEndpoint;
// Build the body part.
var b={"context":{}, "actions":[{"setVideoId":"", "action":""}], "params":"", "playlistId":""};
b.context=window.ytcfg.get("INNERTUBE_CONTEXT");
b.context.client.screenWidthPoints=window.innerWidth;
b.context.client.screenHeightPoints=window.innerHeight;
b.context.client.screenPixelDensity=Math.round(window.devicePixelRatio || 1);
b.context.client.screenDensityFloat=window.devicePixelRatio || 1;
b.context.client.utcOffsetMinutes=-Math.floor((new Date).getTimezoneOffset());
b.context.client.userInterfaceTheme="USER_INTERFACE_THEME_LIGHT";
b.context.request.internalExperimentFlags=[];
b.context.request.consistencyTokenJars=[];
b.context.user={};
b.context.clientScreenNonce=window.ytcfg.get("client-screen-nonce");
// Add in the parts specific to the srcRow.
b.context.clickTracking={"clickTrackingParams" : srcEndpoint.clickTrackingParams};
b.actions=srcEndpoint.playlistEditEndpoint.actions;
if(dest > 1) {
// Don't need to worry about this if it's being moved to the top.
var destEndpoint=destElement.previousElementSibling.data.menu.menuRenderer.items[3].menuServiceItemRenderer.serviceEndpoint;
b.actions[0].movedSetVideoIdPredecessor=destEndpoint.playlistEditEndpoint.actions[0].setVideoId;
}
b.params=srcEndpoint.playlistEditEndpoint.params;
b.playlistId=srcEndpoint.playlistEditEndpoint.playlistId;
var s=JSON.stringify(b);
// Now build the request.
var r={"credentials": "include", "headers":{}, "referrer": "", "body": "", "method": "POST", "mode": "cors"};
if(!("user-agent" in r.headers) && !("User-Agent" in r.headers)) {
r.headers['User-Agent']=navigator.userAgent;
}
r.headers.Accept="*/*";
r.headers['Accept-Language']=(navigator.language || navigator.userLanguage);
r.headers['Content-Type']="application/json";
r.headers.Authorization=sapisidHash();
if(!("x-goog-authuser" in r.headers) && !("X-Goog-Authuser" in r.headers) && !("X-Goog-AuthUser" in r.headers)) {
r.headers['X-Goog-AuthUser']=window.ytcfg.get("SESSION_INDEX");
}
r.headers['X-Origin']=window.location.origin;
r.referrer=window.location.href;
r.body=s;
// Dispatch the fetch with the right key and wait for it to finish.
var key=window.ytcfg.get("INNERTUBE_API_KEY");
var promise=fetch("https://www.youtube.com/youtubei/v1/browse/edit_playlist?key="+key, r);
promise.then(value => {
destElement.parentNode.insertBefore(srcElement, destElement);
ret=true;
});
return ret;
}
// GENERIC UTILITY FUNCTIONS
function addScriptToPage(s) {
var script = document.createElement("script");
script.setAttribute("src", s);
document.body.appendChild(script);
}
function delayedRun(code, delay=100) {
currentDelay += delay;
setTimeout(code, delay);
}
function lockPage(op=true) {
var divId="yt-pl-ds-shadow";
if(op) {
var shadowed = document.createElement("div");
shadowed.id=divId;
shadowed.style="position:fixed; top:0; left:0; z-index:9999999999; background-color:#000; opacity:0.5; width:100%; height:100%;";
shadowed.innerHTML = "<br />";
document.body.appendChild(shadowed);
} else {
document.getElementById(divId).remove();
}
};
function logDebug(msg, force=false) {
if(debug || force) {
console.debug("[yt-pl-duration-sort] "+msg);
}
}
function logMsg(msg) {
console.log("[yt-pl-duration-sort] "+msg);
}
function sapisidHash() {
var ret="";
// First get the cookie value.
var cookies=decodeURIComponent(document.cookie).split(';');
const SC1="SAPISIDHASH=";
const SC2="__Secure-3PAPISID=";
var cval="";
for(var i=0; i < cookies.length && cval == ""; i++) {
var c=cookies[i].trim();
if(c.indexOf(SC1) == 0) {
cval=c.substring(SC1.length, c.length);
} else if(c.indexOf(SC2) == 0) {
cval=c.substring(SC2.length, c.length);
}
}
// Now generate the hash.
if(cval != "") {
var timeSecs = Math.floor(new Date().getTime()/1000);
var s=timeSecs+" "+cval+" https://www.youtube.com"
var h=CryptoJS.SHA1(s);
s=h.toString();
ret="SAPISIDHASH "+timeSecs+"_"+s;
}
return ret;
}
logMsg("Initialisation of YouTube Playlist Duration Sort finished...");
}
function logMsg(msg) {
console.log("[yt-pl-duration-sort] "+msg);
}