-
-
Save KohGeek/65ad9e0118ee5f5ee484676731bcd092 to your computer and use it in GitHub Desktop.
| /** THE SCRIPT HAS BEEN MIGRATED TO A PROPER REPOSITORY **/ | |
| /** https://github.com/KohGeek/SortYoutubePlaylistByDuration/ **/ | |
| /** The script is left here just for legacy purpose, please direct all downloads and questions to the new repository **/ | |
| /** 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 | |
| */ | |
| /* jshint esversion: 8 */ | |
| // ==UserScript== | |
| // @name Sort Youtube Playlist by Duration | |
| // @namespace https://gist.github.com/KohGeek/65ad9e0118ee5f5ee484676731bcd092 | |
| // @version 3.0.0 | |
| // @description As the name implies, sorts youtube playlist by duration | |
| // @author KohGeek | |
| // @license GPL-2.0-only | |
| // @match http://*.youtube.com/playlist* | |
| // @match https://*.youtube.com/playlist* | |
| // @require https://greasyfork.org/scripts/374849-library-onelementready-es7/code/Library%20%7C%20onElementReady%20ES7.js | |
| // @supportURL https://gist.github.com/KohGeek/65ad9e0118ee5f5ee484676731bcd092/ | |
| // @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 = 500; | |
| var waitTimeAfterDrag = 1800; | |
| 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 | |
| */ | |
| let autoScroll = async () => { | |
| let element = document.scrollingElement; | |
| let currentScroll = element.scrollTop; | |
| do { | |
| currentScroll = element.scrollTop; | |
| element.scrollTop = element.scrollHeight; | |
| await new Promise((r) => setTimeout(r, scrollLoopTime)); | |
| } while (currentScroll != element.scrollTop); | |
| }; | |
| /** | |
| * 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 | |
| * @param {string=} label | |
| */ | |
| let renderNumberElement = (variable = 0, 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) => { | |
| if (variable === 0) { | |
| scrollLoopTime = +(e.target.value); | |
| } else if (variable === 1) { | |
| waitTimeAfterDrag = +(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.innerHTML = css; | |
| document.head.appendChild(element); | |
| }; | |
| /** | |
| * Sort videos by time | |
| * @param {Element[]} allAnchors - Array of anchors | |
| * @param {Element[]} allDragPoints - Array of draggable elements | |
| * @return {number} - Number of videos sorted | |
| */ | |
| let sortVideos = (allAnchors, allDragPoints) => { | |
| 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 (allAnchors.length % 100 !== 0 && document.querySelector(".ytd-continuation-item-renderer") !== null) { | |
| logActivity("Playlist is not fully loaded, waiting..."); | |
| return 0; | |
| } | |
| for (let j = 0; j < allAnchors.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 allAnchors = document.querySelectorAll("ytd-item-section-renderer:first-of-type div#content a#thumbnail.inline-block.ytd-thumbnail"); | |
| let allDragPoints; | |
| let sortedCount = 0; | |
| let initialVideoCount = allAnchors.length; | |
| stopSort = false; | |
| while (document.querySelector(".ytd-continuation-item-renderer") !== null | |
| && document.URL.includes("playlist?list=") | |
| && stopSort === false | |
| && autoScrollInitialVideoList === true) { | |
| logActivity("Loading more videos - " + allAnchors.length + " videos loaded"); | |
| if (allAnchors.length > 300) { | |
| logActivity(log.innerText + "\nNumber of videos loaded is high, sorting may take a long time"); | |
| } else if (allAnchors.length > 600) { | |
| logActivity(log.innerText + "\nSorting may take extremely long time/is likely to bug out"); | |
| } | |
| await autoScroll(); | |
| allAnchors = document.querySelectorAll("ytd-item-section-renderer:first-of-type div#content a#thumbnail.inline-block.ytd-thumbnail"); | |
| initialVideoCount = allAnchors.length; | |
| } | |
| logActivity(initialVideoCount + " videos loaded."); | |
| while (sortedCount < initialVideoCount && stopSort === false) { | |
| allAnchors = document.querySelectorAll("ytd-item-section-renderer:first-of-type div#content a#thumbnail.inline-block.ytd-thumbnail"); | |
| allDragPoints = document.querySelectorAll("ytd-item-section-renderer:first-of-type yt-icon#reorder"); | |
| let anchorListLength = allAnchors.length; | |
| if (!allAnchors[anchorListLength - 1].querySelector("#text")) { | |
| logActivity("Video " + anchorListLength + " is not loaded yet, waiting " + waitTimeAfterDrag + "ms"); | |
| await new Promise((r) => setTimeout(r, waitTimeAfterDrag)); | |
| continue; | |
| } | |
| sortedCount = sortVideos(allAnchors, allDragPoints) + 1; | |
| await new Promise((r) => setTimeout(r, waitTimeAfterDrag)); | |
| } | |
| if (stopSort === true) { | |
| logActivity("Sort cancelled"); | |
| stopSort = false; | |
| } else { | |
| logActivity("Sort complete. Video sorted: " + sortedCount); | |
| } | |
| }; | |
| /** | |
| * Initialise script - IIFE | |
| */ | |
| (() => { | |
| onElementReady('div.thumbnail-and-metadata-wrapper', false, () => { | |
| renderContainerElement(); | |
| addCssStyle(); | |
| renderButtonElement(activateSort, 'Sort Videos', false); | |
| renderButtonElement(() => { stopSort = true }, 'Stop Sort', true); | |
| renderSelectElement(0, modeAvailable, 'Sort Mode'); | |
| renderSelectElement(1, autoScrollOptions, 'Auto Scroll'); | |
| renderNumberElement(1, 1800, 'Wait Time After Drag (ms)'); | |
| renderLogElement(); | |
| }); | |
| })(); |
ah sounds like it could be an oversight in programming, i'll look later, but I won't be available until new year's eve
@KohGeek cool, i'd like to assist but i'm not much of a coder. happy new year!
@KohGeek Do you review pull requests, or would be interested in seeing some enhancements?
@KohGeek Do you review pull requests, or would be interested in seeing some enhancements?
I'm very much interested! But unfortunately gist isn't setup for these kind of work, I might consider moving it to an actual repo if there's demand for this. It's a little hard for me to spare time to implement all the small details I would love to
I have moved the script to the new repo, here:
https://github.com/KohGeek/SortYoutubePlaylistByDuration/
The script will be frozen here just for reference purposes.
Awesome, this is so useful. On the watch later it's working even better, like a charm.
On the other playlists, however, it still got stuck on loading:
