YouTube Playlist Duration Sort

Add duration sorting options to YouTube playlists.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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);
}