Sort Youtube Playlist by Duration

As the name implies, sorts youtube playlist by duration

  1. /**
  2. * Changelog 08/08/2024
  3. * - Attempt to address the most serious of buggy code, script should now work in all but the longest playlist.
  4. *
  5. * Changelog 07/08/2024
  6. * - Emergency fix for innerHTML violations
  7. * - Script is now loaded at any YT page - allowing the script to load whenever user hot-navigates to a playlist page without reloading
  8. *
  9. * Changelog 24/12/2023
  10. * - Fixed an issue where recommended videos at the end of the list breaks sorting (due to the lack of reorder anchors)
  11. * - 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)
  12. * - Renaming the script to more accurately reflects its capability
  13. * - Change license to fit SPDX license list
  14. * - Minor code cleanups
  15. *
  16. * Changelog 11/02/2023
  17. * - Migrated to a full proper repo to better support discussions, issues and pull requests
  18. */
  19.  
  20. /* jshint esversion: 8 */
  21. // ==UserScript==
  22. // @name Sort Youtube Playlist by Duration
  23. // @namespace https://github.com/KohGeek/SortYoutubePlaylistByDuration
  24. // @version 3.1.0
  25. // @description As the name implies, sorts youtube playlist by duration
  26. // @author KohGeek
  27. // @license GPL-2.0-only
  28. // @match http://*.youtube.com/*
  29. // @match https://*.youtube.com/*
  30. // @require https://greasyfork.org/scripts/374849-library-onelementready-es7/code/Library%20%7C%20onElementReady%20ES7.js
  31. // @supportURL https://github.com/KohGeek/SortYoutubePlaylistByDuration/
  32. // @grant none
  33. // @run-at document-start
  34. // ==/UserScript==
  35.  
  36. /**
  37. * Variables and constants
  38. */
  39. const css =
  40. `
  41. .sort-playlist-div {
  42. font-size: 12px;
  43. padding: 3px 1px;
  44. }
  45. .sort-button-wl {
  46. border: 1px #a0a0a0;
  47. border-radius: 2px;
  48. padding: 3px;
  49. cursor: pointer;
  50. }
  51. .sort-button-wl-default {
  52. background-color: #30d030;
  53. }
  54. .sort-button-wl-stop {
  55. background-color: #d03030;
  56. }
  57. .sort-button-wl-default:active {
  58. background-color: #209020;
  59. }
  60. .sort-button-wl-stop:active {
  61. background-color: #902020;
  62. }
  63. .sort-log {
  64. padding: 3px;
  65. margin-top: 3px;
  66. border-radius: 2px;
  67. background-color: #202020;
  68. color: #e0e0e0;
  69. }
  70. .sort-margin-right-3px {
  71. margin-right: 3px;
  72. }
  73. `
  74.  
  75. const modeAvailable = [
  76. { value: 'asc', label: 'Shortest First' },
  77. { value: 'desc', label: 'Longest First' }
  78. ];
  79.  
  80. const autoScrollOptions = [
  81. { value: true, label: 'Sort all' },
  82. { value: false, label: 'Sort only loaded' }
  83. ]
  84.  
  85. const debug = false;
  86.  
  87. var scrollLoopTime = 600;
  88.  
  89. let sortMode = 'asc';
  90.  
  91. let autoScrollInitialVideoList = true;
  92.  
  93. let log = document.createElement('div');
  94.  
  95. let stopSort = false;
  96.  
  97. /**
  98. * Fire a mouse event on an element
  99. * @param {string=} type
  100. * @param {Element} elem
  101. * @param {number} centerX
  102. * @param {number} centerY
  103. */
  104. let fireMouseEvent = (type, elem, centerX, centerY) => {
  105. const event = new MouseEvent(type, {
  106. view: window,
  107. bubbles: true,
  108. cancelable: true,
  109. clientX: centerX,
  110. clientY: centerY
  111. });
  112.  
  113. elem.dispatchEvent(event);
  114. };
  115.  
  116. /**
  117. * Simulate drag and drop
  118. * @see: https://ghostinspector.com/blog/simulate-drag-and-drop-javascript-casperjs/
  119. * @param {Element} elemDrag - Element to drag
  120. * @param {Element} elemDrop - Element to drop
  121. */
  122. let simulateDrag = (elemDrag, elemDrop) => {
  123. // calculate positions
  124. let pos = elemDrag.getBoundingClientRect();
  125. let center1X = Math.floor((pos.left + pos.right) / 2);
  126. let center1Y = Math.floor((pos.top + pos.bottom) / 2);
  127. pos = elemDrop.getBoundingClientRect();
  128. let center2X = Math.floor((pos.left + pos.right) / 2);
  129. let center2Y = Math.floor((pos.top + pos.bottom) / 2);
  130.  
  131. // mouse over dragged element and mousedown
  132. fireMouseEvent("mousemove", elemDrag, center1X, center1Y);
  133. fireMouseEvent("mouseenter", elemDrag, center1X, center1Y);
  134. fireMouseEvent("mouseover", elemDrag, center1X, center1Y);
  135. fireMouseEvent("mousedown", elemDrag, center1X, center1Y);
  136.  
  137. // start dragging process over to drop target
  138. fireMouseEvent("dragstart", elemDrag, center1X, center1Y);
  139. fireMouseEvent("drag", elemDrag, center1X, center1Y);
  140. fireMouseEvent("mousemove", elemDrag, center1X, center1Y);
  141. fireMouseEvent("drag", elemDrag, center2X, center2Y);
  142. fireMouseEvent("mousemove", elemDrop, center2X, center2Y);
  143.  
  144. // trigger dragging process on top of drop target
  145. fireMouseEvent("mouseenter", elemDrop, center2X, center2Y);
  146. fireMouseEvent("dragenter", elemDrop, center2X, center2Y);
  147. fireMouseEvent("mouseover", elemDrop, center2X, center2Y);
  148. fireMouseEvent("dragover", elemDrop, center2X, center2Y);
  149.  
  150. // release dragged element on top of drop target
  151. fireMouseEvent("drop", elemDrop, center2X, center2Y);
  152. fireMouseEvent("dragend", elemDrag, center2X, center2Y);
  153. fireMouseEvent("mouseup", elemDrag, center2X, center2Y);
  154. };
  155.  
  156. /**
  157. * Scroll automatically to the bottom of the page
  158. * @param {number} lastScrollLocation - Last known location for scrollTop
  159. */
  160. let autoScroll = async (scrollTop = null) => {
  161. let element = document.scrollingElement;
  162. let currentScroll = element.scrollTop;
  163. let scrollDestination = scrollTop !== null ? scrollTop : element.scrollHeight;
  164. let scrollCount = 0;
  165. do {
  166. currentScroll = element.scrollTop;
  167. element.scrollTop = scrollDestination;
  168. await new Promise(r => setTimeout(r, scrollLoopTime));
  169. scrollCount++;
  170. } while (currentScroll != scrollDestination && scrollCount < 2 && stopSort === false);
  171. };
  172.  
  173. /**
  174. * Log activities
  175. * @param {string=} message
  176. */
  177. let logActivity = (message) => {
  178. log.innerText = message;
  179. if (debug) {
  180. console.log(message);
  181. }
  182. };
  183.  
  184. /**
  185. * Generate menu container element
  186. */
  187. let renderContainerElement = () => {
  188. const element = document.createElement('div')
  189. element.className = 'sort-playlist sort-playlist-div'
  190. element.style.paddingBottom = '16px'
  191.  
  192. // Add buttonChild container
  193. const buttonChild = document.createElement('div')
  194. buttonChild.className = 'sort-playlist-div sort-playlist-button'
  195. element.appendChild(buttonChild)
  196.  
  197. // Add selectChild container
  198. const selectChild = document.createElement('div')
  199. selectChild.className = 'sort-playlist-div sort-playlist-select'
  200. element.appendChild(selectChild)
  201.  
  202. document.querySelector('div.thumbnail-and-metadata-wrapper').append(element)
  203. }
  204.  
  205. /**
  206. * Generate button element
  207. * @param {function} click - OnClick handler
  208. * @param {string=} label - Button Label
  209. */
  210. let renderButtonElement = (click = () => { }, label = '', red = false) => {
  211. // Create button
  212. const element = document.createElement('button')
  213. if (red) {
  214. element.className = 'style-scope sort-button-wl sort-button-wl-stop sort-margin-right-3px'
  215. } else {
  216. element.className = 'style-scope sort-button-wl sort-button-wl-default sort-margin-right-3px'
  217. }
  218. element.innerText = label
  219. element.onclick = click
  220.  
  221. // Render button
  222. document.querySelector('.sort-playlist-button').appendChild(element)
  223. };
  224.  
  225. /**
  226. * Generate select element
  227. * @param {number} variable - Variable to update
  228. * @param {Object[]} options - Options to render
  229. * @param {string=} label - Select Label
  230. */
  231. let renderSelectElement = (variable = 0, options = [], label = '') => {
  232. // Create select
  233. const element = document.createElement('select');
  234. element.className = 'style-scope sort-margin-right-3px';
  235. element.onchange = (e) => {
  236. if (variable === 0) {
  237. sortMode = e.target.value;
  238. } else if (variable === 1) {
  239. autoScrollInitialVideoList = e.target.value;
  240. }
  241. };
  242.  
  243. // Create options
  244. options.forEach((option) => {
  245. const optionElement = document.createElement('option')
  246. optionElement.value = option.value
  247. optionElement.innerText = option.label
  248. element.appendChild(optionElement)
  249. });
  250.  
  251. // Render select
  252. document.querySelector('.sort-playlist-select').appendChild(element);
  253. };
  254.  
  255. /**
  256. * Generate number element
  257. * @param {number} variable
  258. * @param {number} defaultValue
  259. */
  260. let renderNumberElement = (defaultValue = 0, label = '') => {
  261. // Create div
  262. const elementDiv = document.createElement('div');
  263. elementDiv.className = 'sort-playlist-div sort-margin-right-3px';
  264. elementDiv.innerText = label;
  265.  
  266. // Create input
  267. const element = document.createElement('input');
  268. element.type = 'number';
  269. element.value = defaultValue;
  270. element.className = 'style-scope';
  271. element.oninput = (e) => { scrollLoopTime = +(e.target.value) };
  272.  
  273. // Render input
  274. elementDiv.appendChild(element);
  275. document.querySelector('div.sort-playlist').appendChild(elementDiv);
  276. };
  277.  
  278. /**
  279. * Generate log element
  280. */
  281. let renderLogElement = () => {
  282. // Populate div
  283. log.className = 'style-scope sort-log';
  284. log.innerText = 'Logging...';
  285.  
  286. // Render input
  287. document.querySelector('div.sort-playlist').appendChild(log);
  288. };
  289.  
  290. /**
  291. * Add CSS styling
  292. */
  293. let addCssStyle = () => {
  294. const element = document.createElement('style');
  295. element.textContent = css;
  296. document.head.appendChild(element);
  297. };
  298.  
  299. /**
  300. * Sort videos by time
  301. * @param {Element[]} allAnchors - Array of anchors
  302. * @param {Element[]} allDragPoints - Array of draggable elements
  303. * @param {number} expectedCount - Expected length for video list
  304. * @return {number} sorted - Number of videos sorted
  305. */
  306. let sortVideos = (allAnchors, allDragPoints, expectedCount) => {
  307. let videos = [];
  308. let sorted = 0;
  309. let dragged = false;
  310.  
  311. // Sometimes after dragging, the page is not fully loaded yet
  312. // This can be seen by the number of anchors not being a multiple of 100
  313. if (allDragPoints.length !== expectedCount || allAnchors.length !== expectedCount) {
  314. logActivity("Playlist is not fully loaded, waiting...");
  315. return 0;
  316. }
  317.  
  318. for (let j = 0; j < allDragPoints.length; j++) {
  319. let thumb = allAnchors[j];
  320. let drag = allDragPoints[j];
  321.  
  322. let timeSpan = thumb.querySelector("#text");
  323. let timeDigits = timeSpan.innerText.trim().split(":").reverse();
  324. let time;
  325. if (timeDigits.length == 1) {
  326. sortMode == "asc" ? time = 999999999999999999 : time = -1;
  327. } else {
  328. time = parseInt(timeDigits[0]);
  329. if (timeDigits[1]) time += parseInt(timeDigits[1]) * 60;
  330. if (timeDigits[2]) time += parseInt(timeDigits[2]) * 3600;
  331. }
  332. videos.push({ anchor: drag, time: time, originalIndex: j });
  333. }
  334.  
  335. if (sortMode == "asc") {
  336. videos.sort((a, b) => a.time - b.time);
  337. } else {
  338. videos.sort((a, b) => b.time - a.time);
  339. }
  340.  
  341. for (let j = 0; j < videos.length; j++) {
  342. let originalIndex = videos[j].originalIndex;
  343.  
  344. if (debug) {
  345. console.log("Loaded: " + videos.length + ". Current: " + j + ". Original: " + originalIndex + ".");
  346. }
  347.  
  348. if (originalIndex !== j) {
  349. let elemDrag = videos[j].anchor;
  350. let elemDrop = videos.find((v) => v.originalIndex === j).anchor;
  351.  
  352. logActivity("Drag " + originalIndex + " to " + j);
  353. simulateDrag(elemDrag, elemDrop);
  354. dragged = true;
  355. }
  356.  
  357. sorted = j;
  358.  
  359. if (stopSort || dragged) {
  360. break;
  361. }
  362. }
  363.  
  364. return sorted;
  365. }
  366.  
  367. /**
  368. * There is an inherent limit in how fast you can sort the videos, due to Youtube refreshing
  369. * This limit also applies if you do it manually
  370. * 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
  371. */
  372. let activateSort = async () => {
  373. let reportedVideoCount = Number(document.querySelector(".metadata-stats span.yt-formatted-string:first-of-type").innerText);
  374. let allDragPoints = document.querySelectorAll("ytd-item-section-renderer:first-of-type yt-icon#reorder");
  375. let allAnchors;
  376.  
  377. let sortedCount = 0;
  378. let initialVideoCount = allDragPoints.length;
  379. let scrollRetryCount = 0;
  380. stopSort = false;
  381.  
  382. while (reportedVideoCount !== initialVideoCount
  383. && document.URL.includes("playlist?list=")
  384. && stopSort === false
  385. && autoScrollInitialVideoList === true) {
  386. logActivity("Loading more videos - " + allDragPoints.length + " videos loaded");
  387. if (scrollRetryCount > 5) {
  388. break;
  389. } else if (scrollRetryCount > 0) {
  390. logActivity(log.innerText + "\nReported video count does not match actual video count.\nPlease make sure you remove all unavailable videos.\nAttempt: " + scrollRetryCount + "/5")
  391. }
  392.  
  393. if (allDragPoints.length > 300) {
  394. logActivity(log.innerText + "\nNumber of videos loaded is high, sorting may take a long time");
  395. } else if (allDragPoints.length > 600) {
  396. logActivity(log.innerText + "\nSorting may take extremely long time/is likely to bug out");
  397. }
  398.  
  399. await autoScroll();
  400.  
  401. allDragPoints = document.querySelectorAll("ytd-item-section-renderer:first-of-type yt-icon#reorder");
  402. initialVideoCount = allDragPoints.length;
  403.  
  404. if (((reportedVideoCount - initialVideoCount) / 10) < 1) {
  405. // Here, we already waited for the scrolling so things should already be loaded.
  406. // However, due to either unavailable video, or other discrepancy, the count do not match.
  407. // We increment until it's time to break the loop.
  408. scrollRetryCount++;
  409. }
  410. }
  411.  
  412. logActivity(initialVideoCount + " videos loaded.");
  413. if (scrollRetryCount > 5) logActivity(log.innerText + "\nScroll attempt exhausted. Proceeding with sort despite video count mismatch.");
  414. let loadedLocation = document.scrollingElement.scrollTop;
  415. scrollRetryCount = 0;
  416.  
  417. while (sortedCount < initialVideoCount && stopSort === false) {
  418. allDragPoints = document.querySelectorAll("ytd-item-section-renderer:first-of-type yt-icon#reorder");
  419. allAnchors = document.querySelectorAll("ytd-item-section-renderer:first-of-type div#content a#thumbnail.inline-block.ytd-thumbnail");
  420. scrollRetryCount = 0;
  421.  
  422. while (!allAnchors[initialVideoCount - 1].querySelector("#text") && stopSort === false) {
  423. if (document.scrollingElement.scrollTop < loadedLocation && scrollRetryCount < 3) {
  424. logActivity("Video " + initialVideoCount + " is not loaded yet, attempting to scroll.");
  425. await autoScroll(currentLocation);
  426. scrollRetryCount++;
  427. } else {
  428. logActivity("Video " + initialVideoCount + " is still not loaded. Brute forcing scroll.");
  429. await autoScroll();
  430. }
  431. }
  432.  
  433. sortedCount = Number(sortVideos(allAnchors, allDragPoints, initialVideoCount) + 1);
  434. await new Promise(r => setTimeout(r, scrollLoopTime * 4));
  435. }
  436.  
  437. if (stopSort === true) {
  438. logActivity("Sort cancelled.");
  439. stopSort = false;
  440. } else {
  441. logActivity("Sort complete. Video sorted: " + sortedCount);
  442. }
  443. };
  444.  
  445. /**
  446. * Initialisation wrapper for all on-screen elements.
  447. */
  448. let init = () => {
  449. onElementReady('div.thumbnail-and-metadata-wrapper', false, () => {
  450. renderContainerElement();
  451. addCssStyle();
  452. renderButtonElement(async () => { await activateSort() }, 'Sort Videos', false);
  453. renderButtonElement(() => { stopSort = true }, 'Stop Sort', true);
  454. renderSelectElement(0, modeAvailable, 'Sort Mode');
  455. renderSelectElement(1, autoScrollOptions, 'Auto Scroll');
  456. renderNumberElement(600, 'Scroll Retry Time (ms)');
  457. renderLogElement();
  458. });
  459. };
  460.  
  461. /**
  462. * Initialise script - IIFE
  463. */
  464. (() => {
  465. init();
  466. navigation.addEventListener('navigate', navigateEvent => {
  467. const url = new URL(navigateEvent.destination.url);
  468. if (url.pathname.includes('playlist?')) init();
  469. });
  470. })();