Sort Youtube Playlist by Duration

As the name implies, sorts youtube playlist by duration

作者のサイトでサポートを受ける。または、このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
/**
 *  Changelog 08/08/2024
 *  - Attempt to address the most serious of buggy code, script should now work in all but the longest playlist.
 *
 *  Changelog 07/08/2024
 *  - Emergency fix for innerHTML violations
 *  - Script is now loaded at any YT page - allowing the script to load whenever user hot-navigates to a playlist page without reloading
 *
 *  Changelog 24/12/2023
 *  - Fixed an issue where recommended videos at the end of the list breaks sorting (due to the lack of reorder anchors)
 *  - Attempted fix for "Upcoming" or any other non-timestamped based videos, sorting to bottom (operating on principle that split(':') will produce at least 2 elements on timestamps)
 *  - Renaming the script to more accurately reflects its capability
 *  - Change license to fit SPDX license list
 *  - Minor code cleanups
 *
 *  Changelog 11/02/2023
 *  - Migrated to a full proper repo to better support discussions, issues and pull requests
 */

/* jshint esversion: 8 */
// ==UserScript==
// @name              Sort Youtube Playlist by Duration
// @namespace         https://github.com/KohGeek/SortYoutubePlaylistByDuration
// @version           3.1.0
// @description       As the name implies, sorts youtube playlist by duration
// @author            KohGeek
// @license           GPL-2.0-only
// @match             http://*.youtube.com/*
// @match             https://*.youtube.com/*
// @require           https://greasyfork.org/scripts/374849-library-onelementready-es7/code/Library%20%7C%20onElementReady%20ES7.js
// @supportURL        https://github.com/KohGeek/SortYoutubePlaylistByDuration/
// @grant             none
// @run-at            document-start
// ==/UserScript==

/**
 * Variables and constants
 */
const css =
    `
        .sort-playlist-div {
            font-size: 12px;
            padding: 3px 1px;
        }
        .sort-button-wl {
            border: 1px #a0a0a0;
            border-radius: 2px;
            padding: 3px;
            cursor: pointer;
        }
        .sort-button-wl-default {
            background-color: #30d030;
        }
        .sort-button-wl-stop {
            background-color: #d03030;
        }
        .sort-button-wl-default:active {
            background-color: #209020;
        }
        .sort-button-wl-stop:active {
            background-color: #902020;
        }
        .sort-log {
            padding: 3px;
            margin-top: 3px;
            border-radius: 2px;
            background-color: #202020;
            color: #e0e0e0;
        }
        .sort-margin-right-3px {
            margin-right: 3px;
        }
    `

const modeAvailable = [
    { value: 'asc', label: 'Shortest First' },
    { value: 'desc', label: 'Longest First' }
];

const autoScrollOptions = [
    { value: true, label: 'Sort all' },
    { value: false, label: 'Sort only loaded' }
]

const debug = false;

var scrollLoopTime = 600;

let sortMode = 'asc';

let autoScrollInitialVideoList = true;

let log = document.createElement('div');

let stopSort = false;

/**
 * Fire a mouse event on an element
 * @param {string=} type
 * @param {Element} elem
 * @param {number} centerX
 * @param {number} centerY
 */
let fireMouseEvent = (type, elem, centerX, centerY) => {
    const event = new MouseEvent(type, {
        view: window,
        bubbles: true,
        cancelable: true,
        clientX: centerX,
        clientY: centerY
    });

    elem.dispatchEvent(event);
};

/**
 * Simulate drag and drop
 * @see: https://ghostinspector.com/blog/simulate-drag-and-drop-javascript-casperjs/
 * @param {Element} elemDrag - Element to drag
 * @param {Element} elemDrop - Element to drop
 */
let simulateDrag = (elemDrag, elemDrop) => {
    // calculate positions
    let pos = elemDrag.getBoundingClientRect();
    let center1X = Math.floor((pos.left + pos.right) / 2);
    let center1Y = Math.floor((pos.top + pos.bottom) / 2);
    pos = elemDrop.getBoundingClientRect();
    let center2X = Math.floor((pos.left + pos.right) / 2);
    let center2Y = Math.floor((pos.top + pos.bottom) / 2);

    // mouse over dragged element and mousedown
    fireMouseEvent("mousemove", elemDrag, center1X, center1Y);
    fireMouseEvent("mouseenter", elemDrag, center1X, center1Y);
    fireMouseEvent("mouseover", elemDrag, center1X, center1Y);
    fireMouseEvent("mousedown", elemDrag, center1X, center1Y);

    // start dragging process over to drop target
    fireMouseEvent("dragstart", elemDrag, center1X, center1Y);
    fireMouseEvent("drag", elemDrag, center1X, center1Y);
    fireMouseEvent("mousemove", elemDrag, center1X, center1Y);
    fireMouseEvent("drag", elemDrag, center2X, center2Y);
    fireMouseEvent("mousemove", elemDrop, center2X, center2Y);

    // trigger dragging process on top of drop target
    fireMouseEvent("mouseenter", elemDrop, center2X, center2Y);
    fireMouseEvent("dragenter", elemDrop, center2X, center2Y);
    fireMouseEvent("mouseover", elemDrop, center2X, center2Y);
    fireMouseEvent("dragover", elemDrop, center2X, center2Y);

    // release dragged element on top of drop target
    fireMouseEvent("drop", elemDrop, center2X, center2Y);
    fireMouseEvent("dragend", elemDrag, center2X, center2Y);
    fireMouseEvent("mouseup", elemDrag, center2X, center2Y);
};

/**
 * Scroll automatically to the bottom of the page
 * @param {number} lastScrollLocation - Last known location for scrollTop
 */
let autoScroll = async (scrollTop = null) => {
    let element = document.scrollingElement;
    let currentScroll = element.scrollTop;
    let scrollDestination = scrollTop !== null ? scrollTop : element.scrollHeight;
    let scrollCount = 0;
    do {
        currentScroll = element.scrollTop;
        element.scrollTop = scrollDestination;
        await new Promise(r => setTimeout(r, scrollLoopTime));
        scrollCount++;
    } while (currentScroll != scrollDestination && scrollCount < 2 && stopSort === false);
};

/**
 * Log activities
 * @param {string=} message
 */
let logActivity = (message) => {
    log.innerText = message;
    if (debug) {
        console.log(message);
    }
};

/**
 * Generate menu container element
 */
let renderContainerElement = () => {
    const element = document.createElement('div')
    element.className = 'sort-playlist sort-playlist-div'
    element.style.paddingBottom = '16px'

    // Add buttonChild container
    const buttonChild = document.createElement('div')
    buttonChild.className = 'sort-playlist-div sort-playlist-button'
    element.appendChild(buttonChild)

    // Add selectChild container
    const selectChild = document.createElement('div')
    selectChild.className = 'sort-playlist-div sort-playlist-select'
    element.appendChild(selectChild)

    document.querySelector('div.thumbnail-and-metadata-wrapper').append(element)
}

/**
 * Generate button element
 * @param {function} click - OnClick handler
 * @param {string=} label - Button Label
 */
let renderButtonElement = (click = () => { }, label = '', red = false) => {
    // Create button
    const element = document.createElement('button')
    if (red) {
        element.className = 'style-scope sort-button-wl sort-button-wl-stop sort-margin-right-3px'
    } else {
        element.className = 'style-scope sort-button-wl sort-button-wl-default sort-margin-right-3px'
    }
    element.innerText = label
    element.onclick = click

    // Render button
    document.querySelector('.sort-playlist-button').appendChild(element)
};

/**
 * Generate select element
 * @param {number} variable - Variable to update
 * @param {Object[]} options - Options to render
 * @param {string=} label - Select Label
 */
let renderSelectElement = (variable = 0, options = [], label = '') => {
    // Create select
    const element = document.createElement('select');
    element.className = 'style-scope sort-margin-right-3px';
    element.onchange = (e) => {
        if (variable === 0) {
            sortMode = e.target.value;
        } else if (variable === 1) {
            autoScrollInitialVideoList = e.target.value;
        }
    };

    // Create options
    options.forEach((option) => {
        const optionElement = document.createElement('option')
        optionElement.value = option.value
        optionElement.innerText = option.label
        element.appendChild(optionElement)
    });

    // Render select
    document.querySelector('.sort-playlist-select').appendChild(element);
};

/**
 * Generate number element
 * @param {number} variable
 * @param {number} defaultValue
 */
let renderNumberElement = (defaultValue = 0, label = '') => {
    // Create div
    const elementDiv = document.createElement('div');
    elementDiv.className = 'sort-playlist-div sort-margin-right-3px';
    elementDiv.innerText = label;

    // Create input
    const element = document.createElement('input');
    element.type = 'number';
    element.value = defaultValue;
    element.className = 'style-scope';
    element.oninput = (e) => { scrollLoopTime = +(e.target.value) };

    // Render input
    elementDiv.appendChild(element);
    document.querySelector('div.sort-playlist').appendChild(elementDiv);
};

/**
 * Generate log element
 */
let renderLogElement = () => {
    // Populate div
    log.className = 'style-scope sort-log';
    log.innerText = 'Logging...';

    // Render input
    document.querySelector('div.sort-playlist').appendChild(log);
};

/**
 * Add CSS styling
 */
let addCssStyle = () => {
    const element = document.createElement('style');
    element.textContent = css;
    document.head.appendChild(element);
};

/**
 * Sort videos by time
 * @param {Element[]} allAnchors - Array of anchors
 * @param {Element[]} allDragPoints - Array of draggable elements
 * @param {number} expectedCount - Expected length for video list
 * @return {number} sorted - Number of videos sorted
 */
let sortVideos = (allAnchors, allDragPoints, expectedCount) => {
    let videos = [];
    let sorted = 0;
    let dragged = false;

    // Sometimes after dragging, the page is not fully loaded yet
    // This can be seen by the number of anchors not being a multiple of 100
    if (allDragPoints.length !== expectedCount || allAnchors.length !== expectedCount) {
        logActivity("Playlist is not fully loaded, waiting...");
        return 0;
    }

    for (let j = 0; j < allDragPoints.length; j++) {
        let thumb = allAnchors[j];
        let drag = allDragPoints[j];

        let timeSpan = thumb.querySelector("#text");
        let timeDigits = timeSpan.innerText.trim().split(":").reverse();
        let time;
        if (timeDigits.length == 1) {
            sortMode == "asc" ? time = 999999999999999999 : time = -1;
        } else {
            time = parseInt(timeDigits[0]);
            if (timeDigits[1]) time += parseInt(timeDigits[1]) * 60;
            if (timeDigits[2]) time += parseInt(timeDigits[2]) * 3600;
        }
        videos.push({ anchor: drag, time: time, originalIndex: j });
    }

    if (sortMode == "asc") {
        videos.sort((a, b) => a.time - b.time);
    } else {
        videos.sort((a, b) => b.time - a.time);
    }

    for (let j = 0; j < videos.length; j++) {
        let originalIndex = videos[j].originalIndex;

        if (debug) {
            console.log("Loaded: " + videos.length + ". Current: " + j + ". Original: " + originalIndex + ".");
        }

        if (originalIndex !== j) {
            let elemDrag = videos[j].anchor;
            let elemDrop = videos.find((v) => v.originalIndex === j).anchor;

            logActivity("Drag " + originalIndex + " to " + j);
            simulateDrag(elemDrag, elemDrop);
            dragged = true;
        }

        sorted = j;

        if (stopSort || dragged) {
            break;
        }
    }

    return sorted;
}

/**
 * There is an inherent limit in how fast you can sort the videos, due to Youtube refreshing
 * This limit also applies if you do it manually
 * It is also much worse if you have a lot of videos, for every 100 videos, it's about an extra 2-4 seconds, maybe longer
 */
let activateSort = async () => {
    let reportedVideoCount = Number(document.querySelector(".metadata-stats span.yt-formatted-string:first-of-type").innerText);
    let allDragPoints = document.querySelectorAll("ytd-item-section-renderer:first-of-type yt-icon#reorder");
    let allAnchors;

    let sortedCount = 0;
    let initialVideoCount = allDragPoints.length;
    let scrollRetryCount = 0;
    stopSort = false;

    while (reportedVideoCount !== initialVideoCount
        && document.URL.includes("playlist?list=")
        && stopSort === false
        && autoScrollInitialVideoList === true) {
        logActivity("Loading more videos - " + allDragPoints.length + " videos loaded");
        if (scrollRetryCount > 5) {
            break;
        } else if (scrollRetryCount > 0) {
            logActivity(log.innerText + "\nReported video count does not match actual video count.\nPlease make sure you remove all unavailable videos.\nAttempt: " + scrollRetryCount + "/5")
        }

        if (allDragPoints.length > 300) {
            logActivity(log.innerText + "\nNumber of videos loaded is high, sorting may take a long time");
        } else if (allDragPoints.length > 600) {
            logActivity(log.innerText + "\nSorting may take extremely long time/is likely to bug out");
        }

        await autoScroll();

        allDragPoints = document.querySelectorAll("ytd-item-section-renderer:first-of-type yt-icon#reorder");
        initialVideoCount = allDragPoints.length;

        if (((reportedVideoCount - initialVideoCount) / 10) < 1) {
            // Here, we already waited for the scrolling so things should already be loaded.
            // However, due to either unavailable video, or other discrepancy, the count do not match.
            // We increment until it's time to break the loop.
            scrollRetryCount++;
        }
    }

    logActivity(initialVideoCount + " videos loaded.");
    if (scrollRetryCount > 5) logActivity(log.innerText + "\nScroll attempt exhausted. Proceeding with sort despite video count mismatch.");
    let loadedLocation = document.scrollingElement.scrollTop;
    scrollRetryCount = 0;

    while (sortedCount < initialVideoCount && stopSort === false) {
        allDragPoints = document.querySelectorAll("ytd-item-section-renderer:first-of-type yt-icon#reorder");
        allAnchors = document.querySelectorAll("ytd-item-section-renderer:first-of-type div#content a#thumbnail.inline-block.ytd-thumbnail");
        scrollRetryCount = 0;

        while (!allAnchors[initialVideoCount - 1].querySelector("#text") && stopSort === false) {
            if (document.scrollingElement.scrollTop < loadedLocation && scrollRetryCount < 3) {
                logActivity("Video " + initialVideoCount + " is not loaded yet, attempting to scroll.");
                await autoScroll(currentLocation);
                scrollRetryCount++;
            } else {
                logActivity("Video " + initialVideoCount + " is still not loaded. Brute forcing scroll.");
                await autoScroll();
            }
        }

        sortedCount = Number(sortVideos(allAnchors, allDragPoints, initialVideoCount) + 1);
        await new Promise(r => setTimeout(r, scrollLoopTime * 4));
    }

    if (stopSort === true) {
        logActivity("Sort cancelled.");
        stopSort = false;
    } else {
        logActivity("Sort complete. Video sorted: " + sortedCount);
    }
};

/**
 * Initialisation wrapper for all on-screen elements.
 */
let init = () => {
    onElementReady('div.thumbnail-and-metadata-wrapper', false, () => {
        renderContainerElement();
        addCssStyle();
        renderButtonElement(async () => { await activateSort() }, 'Sort Videos', false);
        renderButtonElement(() => { stopSort = true }, 'Stop Sort', true);
        renderSelectElement(0, modeAvailable, 'Sort Mode');
        renderSelectElement(1, autoScrollOptions, 'Auto Scroll');
        renderNumberElement(600, 'Scroll Retry Time (ms)');
        renderLogElement();
    });
};

/**
 * Initialise script - IIFE
 */
(() => {
    init();
    navigation.addEventListener('navigate', navigateEvent => {
        const url = new URL(navigateEvent.destination.url);
        if (url.pathname.includes('playlist?')) init();
    });
})();