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