您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Sorts youtube playlist by duration
// ==UserScript== // @name Sort Youtube Playlist by Duration (Advanced) // @namespace https://github.com/L0garithmic/ytsort/ // @version 4.5.0 // @description Sorts youtube playlist by duration // @author L0garithmic // @license GPL-2.0-only // @match http://*.youtube.com/* // @match https://*.youtube.com/* // @supportURL https://github.com/L0garithmic/ytsort/ // @grant none // @run-at document-idle // ==/UserScript== /** * Changelog 10/12/2025 (v4.5.0) * - Added Settings Panel for persistent configuration management * - Settings saved to localStorage and persist across sessions * - Settings include: default sort mode, auto-scroll, scroll retry time, log preferences * - Added Dry Run Mode to preview sort order without applying changes * - Dry Run shows before/after comparison with confirmation prompt * - Safer for large playlists - verify sort logic before committing * - Settings panel accessible via dedicated button * * Changelog 10/12/2025 (v4.4.0) * - Added "Export" button to export playlist data as CSV * - Export includes: position, title, duration (formatted & seconds), URL, video ID * - Fixed move counter to show actual move number vs total moves needed * - Move counter now shows (1/10) instead of (9/269) for better clarity * - Auto-generates filename with playlist ID and date * - CSV properly escapes special characters in video titles * * Changelog 10/12/2025 (v4.3.0) * - Added "Only include specific lengths" filter feature * - Filter inputs now use minutes instead of seconds for easier configuration * - Title tiebreaker always enabled (videos with same duration sorted alphabetically) * - Filtered videos are moved to the end of the playlist * - Filter settings shown in log when sorting starts * * Changelog 10/12/2025 (v4.2.0) * - Added "Stats" button (compact size) for playlist analysis * - Shows total duration, average length, shortest/longest videos * - Removed distribution chart for cleaner output * - Auto-opens log console when Stats is clicked * - Counts unavailable/private videos * - Provides quick insights before sorting * * Changelog 10/12/2025 (v4.1.1) * - Fixed random scrolling behavior after sort completion * - Increased log retention from 100 to 1000 messages * - Added warning about not switching back to YouTube's auto-sort methods * - Added version number display (visible when expanded) * - Version number now included in verbose logs for troubleshooting * - Scroll to top after sort completes to stabilize view * * Changelog 10/10/2025 (v4.1.0) * - Added "Copy Console" button to copy all logs to clipboard * - MAJOR FIX: Completely rewrote lazy loading prevention * - Now reloads entire playlist before each sort iteration * - Scrolls to bottom then top to ensure all videos are loaded * - Verifies video count before each sort * - Reduces sort iterations significantly (more reliable) * - Better error messages when playlist cannot be maintained * * Changelog 10/10/2025 (v4.0.3) * - Added scrollable console log with timestamps * - Log auto-scrolls to show latest messages * - Added Clear Log button * - Enhanced logging with emojis and visual separators * - All actions are now logged in real-time * - Console shows up to 100 most recent messages * - Added progress indicators for sorting steps * * Changelog 10/10/2025 (v4.0.2) * - Fixed YouTube's lazy loading causing videos to unload during sorting * - Added smart viewport positioning to keep videos loaded * - Both modes now check for new content before sorting * - Waits for stable state (no new videos for 3 attempts) before sorting * - Increased delays between sort operations for better stability * - Better error handling when playlist state cannot be maintained * * Changelog 10/10/2025 (v4.0.1) * - Fixed annoying continuous scroll/load/refresh loop * - Added max retry limit to prevent infinite scrolling * - Improved early exit when no progress is detected (3 attempts) * - Better scroll detection to stop when already at bottom * - Clear feedback for "Sort only loaded" mode * * Changelog 10/10/2025 (v4.0.0) * - Fixed video count detection to work with new YouTube layout * - Improved "Sort all" mode to reliably load all videos in playlist * - Enhanced progress feedback during video loading * - Modernized UI with better styling (rounded buttons, gradients, smooth transitions) * - Added dark mode support * - Better retry logic with progress tracking * * Changelog 08/08/2024 * - Attempt to address the most serious of buggy code, script should now work in all but the longest playlist. */ /* jshint esversion: 8 */ (function () { 'use strict'; const SCRIPT_VERSION = '4.5.0'; // Settings management with localStorage const SETTINGS_KEY = 'yt_playlist_sorter_settings'; /** * Default settings structure * @type {Object} */ const DEFAULT_SETTINGS = { sortMode: 'asc', autoScrollInitialVideoList: 'true', scrollLoopTime: 600, logVisible: false, dryRunEnabled: false, filterEnabled: false, filterMinDuration: 0, filterMaxDuration: 36000 }; /** * Load settings from localStorage * @returns {Object} Settings object */ const loadSettings = () => { try { const stored = localStorage.getItem(SETTINGS_KEY); if (stored) { const parsed = JSON.parse(stored); return { ...DEFAULT_SETTINGS, ...parsed }; } } catch (e) { console.error('Failed to load settings:', e); } return { ...DEFAULT_SETTINGS }; }; /** * Save settings to localStorage * @param {Object} settings - Settings object to save * @returns {void} */ const saveSettings = (settings) => { try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } catch (e) { console.error('Failed to save settings:', e); } }; /** * Get current settings from UI state * @returns {Object} Current settings */ const getCurrentSettings = () => { return { sortMode, autoScrollInitialVideoList, scrollLoopTime, logVisible, dryRunEnabled, filterEnabled, filterMinDuration, filterMaxDuration }; }; /** * Apply settings to UI state * @param {Object} settings - Settings to apply * @returns {void} */ const applySettings = (settings) => { sortMode = settings.sortMode; autoScrollInitialVideoList = settings.autoScrollInitialVideoList; scrollLoopTime = settings.scrollLoopTime; logVisible = settings.logVisible; dryRunEnabled = settings.dryRunEnabled; filterEnabled = settings.filterEnabled; filterMinDuration = settings.filterMinDuration; filterMaxDuration = settings.filterMaxDuration; }; // Load settings on initialization const savedSettings = loadSettings(); /** * Wait for element(s) to appear in DOM using MutationObserver * @param {string} selector - CSS selector to wait for * @param {boolean} [multiple=false] - Whether to wait for multiple elements * @param {Function} [callback=()=>{}] - Callback function to execute when element(s) found * @returns {void} */ const onElementReady = (selector, multiple = false, callback = () => { }) => { const runCallback = () => { if (multiple) { const elements = document.querySelectorAll(selector); if (elements.length) { callback(elements); return true; } } else { const element = document.querySelector(selector); if (element) { callback(element); return true; } } return false; }; if (runCallback()) { return; } const observer = new MutationObserver(() => { if (runCallback()) { observer.disconnect(); } }); const root = document.documentElement || document; observer.observe(root, { childList: true, subtree: true }); setTimeout(() => observer.disconnect(), 30000); }; /** * Variables and constants */ const css = ` .sort-playlist-wrapper { margin-top: 12px; } .sort-playlist-details { border: 1px solid rgba(48,48,48,0.4); border-radius: 12px; background: rgba(0,0,0,0.03); overflow: hidden; } .sort-playlist-summary { list-style: none; padding: 12px 16px; font-weight: 600; display: flex; align-items: center; gap: 8px; cursor: pointer; color: #0f0f0f; user-select: none; justify-content: space-between; } .sort-playlist-summary::-webkit-details-marker { display: none; } .sort-playlist-summary::before { content: '▶'; font-size: 10px; transition: transform 0.2s ease; flex-shrink: 0; display: flex; align-items: center; transform: translateY(-1px); } .sort-playlist-details[open] .sort-playlist-summary::before { content: '▼'; } .sort-playlist-title { flex: 1; } .sort-playlist-version { font-size: 11px; font-weight: 500; color: #606060; opacity: 0; transition: opacity 0.2s ease; pointer-events: none; margin-left: auto; } .sort-playlist-details[open] .sort-playlist-version { opacity: 1; } .sort-playlist-content { padding: 12px 16px 16px; } .sort-playlist-div { font-size: 13px; padding: 8px 4px; font-family: "Roboto", "Arial", sans-serif; } .sort-button-wl { border: none; border-radius: 18px; padding: 10px 16px; cursor: pointer; font-weight: 500; font-size: 14px; transition: all 0.2s ease; box-shadow: none; white-space: nowrap; } .sort-button-wl-default { background: rgba(255, 255, 255, 0.1); color: #fff; font-weight: 500; border: 1px solid rgba(255, 255, 255, 0.1); } .sort-button-wl-stop { background: rgba(255, 255, 255, 0.1); color: #ff4444; font-weight: 600; border: 1px solid rgba(255, 255, 255, 0.1); } .sort-button-wl-default:hover { background: rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.3); transform: translateY(-1px); } .sort-button-wl-stop:hover { background: rgba(255, 68, 68, 0.15); border: 1px solid rgba(255, 68, 68, 0.3); transform: translateY(-1px); } .sort-button-wl-default:active { background: rgba(62, 166, 255, 0.25); transform: translateY(0); } .sort-button-wl-stop:active { background: rgba(255, 68, 68, 0.25); transform: translateY(0); } .sort-select { border: 1px solid #303030; border-radius: 8px; padding: 8px 12px; background-color: #f9f9f9; color: #0f0f0f; font-size: 13px; cursor: pointer; transition: all 0.2s ease; } .sort-select:hover { border-color: #065fd4; background-color: #fff; } .sort-select:focus { outline: none; border-color: #065fd4; box-shadow: 0 0 0 2px rgba(6,95,212,0.1); } .sort-number-input { border: 1px solid #303030; border-radius: 8px; padding: 8px 12px; background-color: #f9f9f9; color: #0f0f0f; font-size: 13px; width: 100px; transition: all 0.2s ease; } .sort-number-input:hover { border-color: #065fd4; background-color: #fff; } .sort-number-input:focus { outline: none; border-color: #065fd4; box-shadow: 0 0 0 2px rgba(6,95,212,0.1); } .sort-log { padding: 12px; margin-top: 8px; border-radius: 8px; background-color: #0f0f0f; color: #f1f1f1; font-family: 'Roboto Mono', monospace; font-size: 12px; line-height: 1.5; box-shadow: inset 0 1px 3px rgba(0,0,0,0.3); max-height: 200px; overflow-y: auto; white-space: pre-wrap; word-wrap: break-word; } .sort-log::-webkit-scrollbar { width: 8px; } .sort-log::-webkit-scrollbar-track { background: #1a1a1a; border-radius: 4px; } .sort-log::-webkit-scrollbar-thumb { background: #3ea6ff; border-radius: 4px; } .sort-log::-webkit-scrollbar-thumb:hover { background: #4db3ff; } .sort-log-entry { margin-bottom: 4px; padding: 2px 0; } .sort-log.sort-log-empty { color: #888; } .sort-log-timestamp { color: #888; margin-right: 8px; } .sort-margin-right-3px { margin-right: 8px; } .sort-input-label { display: inline-block; margin-right: 6px; color: #0f0f0f; font-weight: 500; } .sort-checkbox-container { display: inline-flex; align-items: center; margin-bottom: 4px; margin-right: 4px; font-size: 11px; color: #0f0f0f; cursor: pointer; } .sort-checkbox { margin-right: 4px; cursor: pointer; } @media (prefers-color-scheme: dark) { .sort-playlist-details { border-color: rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); } .sort-playlist-summary { color: #f1f1f1; } .sort-playlist-version { color: #aaa; } .sort-select, .sort-number-input { background-color: #272727; color: #f1f1f1; border-color: #4f4f4f; } .sort-select:hover, .sort-number-input:hover { background-color: #3f3f3f; border-color: #3ea6ff; } .sort-select:focus, .sort-number-input:focus { border-color: #3ea6ff; box-shadow: 0 0 0 2px rgba(62,166,255,0.1); } .sort-input-label { color: #f1f1f1; } .sort-checkbox-container { color: #f1f1f1; } .sort-button-wl-default { background: rgba(255, 255, 255, 0.08); border-color: rgba(255, 255, 255, 0.15); } .sort-button-wl-default:hover { background: rgba(255, 255, 255, 0.15); } .sort-dry-run-modal { background-color: #212121; border-color: #4f4f4f; } .sort-dry-run-title { color: #f1f1f1; border-bottom-color: #4f4f4f; } .sort-dry-run-comparison { background-color: #181818; border-color: #4f4f4f; } .sort-dry-run-column h4 { color: #f1f1f1; } .sort-dry-run-video { background-color: #272727; border-color: #3f3f3f; color: #f1f1f1; } } .sort-settings-modal { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #212121; border: 1px solid #4f4f4f; border-radius: 12px; padding: 24px; box-shadow: 0 4px 20px rgba(0,0,0,0.5); z-index: 10000; max-width: 500px; width: 90%; max-height: 80vh; overflow-y: auto; } .sort-settings-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 9999; } .sort-settings-title { font-size: 18px; font-weight: 600; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #4f4f4f; color: #f1f1f1; } .sort-settings-section { margin-bottom: 16px; } .sort-settings-section-title { font-size: 13px; font-weight: 600; color: #aaa; margin-bottom: 8px; text-transform: uppercase; } .sort-settings-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } .sort-settings-label { font-size: 14px; color: #f1f1f1; font-weight: normal; } .sort-settings-buttons { display: flex; gap: 8px; margin-top: 20px; justify-content: flex-end; } .sort-settings-buttons button { font-weight: normal; } .sort-settings-modal .sort-select, .sort-settings-modal .sort-number-input, .sort-settings-modal .sort-checkbox { background-color: #272727; color: #f1f1f1; border-color: #4f4f4f; } .sort-settings-modal .sort-select { width: 160px; } .sort-settings-modal .sort-number-input { width: 135px; } .sort-settings-modal .sort-select:hover, .sort-settings-modal .sort-number-input:hover { background-color: #3f3f3f; border-color: #3ea6ff; } .sort-settings-modal .sort-select:focus, .sort-settings-modal .sort-number-input:focus { outline: none; border-color: #3ea6ff; box-shadow: 0 0 0 2px rgba(62,166,255,0.2); } .sort-dry-run-modal { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; border: 1px solid #ccc; border-radius: 12px; padding: 24px; box-shadow: 0 4px 20px rgba(0,0,0,0.3); z-index: 10001; max-width: 800px; width: 95%; max-height: 85vh; overflow-y: auto; } .sort-dry-run-title { font-size: 18px; font-weight: 600; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #e0e0e0; color: #0f0f0f; } .sort-dry-run-info { margin-bottom: 16px; padding: 12px; background: #f0f7ff; border-radius: 8px; font-size: 13px; color: #065fd4; } .sort-dry-run-comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; max-height: 400px; overflow-y: auto; border: 1px solid #e0e0e0; border-radius: 8px; padding: 12px; background: #fafafa; } .sort-dry-run-column { min-width: 0; } .sort-dry-run-column h4 { font-size: 14px; font-weight: 600; margin-bottom: 8px; position: sticky; top: 0; background: #fafafa; padding: 8px 0; color: #0f0f0f; } .sort-dry-run-video { font-size: 12px; padding: 6px 8px; margin-bottom: 4px; background: white; border: 1px solid #e0e0e0; border-radius: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sort-dry-run-buttons { display: flex; gap: 8px; justify-content: flex-end; } `; 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' } ]; // NEW YouTube architecture selectors const NEW_PAGE_HEADER_SELECTOR = 'yt-flexible-actions-view-model'; const NEW_ACTIONS_ROW_SELECTOR = '.ytFlexibleActionsViewModelActionRow'; // OLD YouTube architecture selectors const PLAYLIST_HEADER_SELECTOR = 'ytd-playlist-header-renderer'; const PLAYLIST_ACTIONS_SELECTOR = 'ytd-playlist-header-renderer #actions'; const PLAYLIST_VIDEO_LIST_SELECTOR = 'ytd-playlist-video-list-renderer'; const PLAYLIST_VIDEO_ITEM_SELECTOR = 'ytd-playlist-video-renderer'; const debug = false; const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms)); let scrollLoopTime = savedSettings.scrollLoopTime; let sortMode = savedSettings.sortMode; let autoScrollInitialVideoList = savedSettings.autoScrollInitialVideoList; const DEFAULT_LOG_MESSAGE = '[Ready] Waiting for sort action...'; const MAX_LOG_MESSAGES = 1000; // Increased from 100 to retain more logs let log = document.createElement('div'); let logEntries = []; // Store all log messages with metadata let verboseMode = false; // Default to non-verbose mode let autoScrollLog = true; // Default to auto-scroll enabled let logVisible = savedSettings.logVisible; // Load from settings let stopSort = false; // Global move counter for tracking progress across all sort iterations let globalMoveCounter = 0; let globalTotalMoves = 0; let dryRunEnabled = savedSettings.dryRunEnabled; // Load from settings // Filter settings (load from saved settings) let filterEnabled = savedSettings.filterEnabled; let filterMinDuration = savedSettings.filterMinDuration; // in seconds let filterMaxDuration = savedSettings.filterMaxDuration; // in seconds (max) // Multi-criteria sorting let useTitleTiebreaker = true; // Always enabled by default /** * Get all playlist video pairs (drag handle, anchor, item) * @returns {{drag: Element, anchor: Element, item: Element}[]} Array of video pair objects */ const getPlaylistVideoPairs = () => { const scope = document.querySelector(PLAYLIST_VIDEO_LIST_SELECTOR) || document; const videoItems = scope.querySelectorAll(PLAYLIST_VIDEO_ITEM_SELECTOR); const pairs = []; videoItems.forEach(item => { const drag = item.querySelector('yt-icon#reorder'); const anchor = item.querySelector('a#thumbnail'); if (drag && anchor) { pairs.push({ drag, anchor, item }); } }); return pairs; }; /** * Get video duration in seconds from a video anchor element * @param {Element} anchor - Video anchor element * @return {number|null} Duration in seconds, or null if unavailable */ const getVideoDuration = (anchor) => { const timeSpan = anchor.querySelector("#text"); if (!timeSpan || !timeSpan.innerText.trim()) { return null; } const timeDigits = timeSpan.innerText.trim().split(":").reverse(); if (timeDigits.length === 1) { return null; } let seconds = parseInt(timeDigits[0]) || 0; if (timeDigits[1]) seconds += parseInt(timeDigits[1]) * 60; if (timeDigits[2]) seconds += parseInt(timeDigits[2]) * 3600; return seconds; }; /** * Get video title from a video item element * @param {Element} item - Video item element * @return {string} Video title */ const getVideoTitle = (item) => { const titleElement = item.querySelector('#video-title'); return titleElement ? titleElement.innerText.trim() : ''; }; /** * Check if a video passes the duration filter * @param {number|null} duration - Video duration in seconds * @return {boolean} True if video passes filter */ const passesFilter = (duration) => { if (!filterEnabled) return true; if (duration === null) return false; // Exclude videos without duration return duration >= filterMinDuration && duration <= filterMaxDuration; }; /** * Fire a mouse event on an element * @param {string} type - Event type (e.g., 'mousemove', 'mousedown', 'dragstart') * @param {Element} elem - Target element to fire event on * @param {number} centerX - X coordinate for the event * @param {number} centerY - Y coordinate for the event * @returns {void} */ 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 operation between two elements * Fires a sequence of mouse events to replicate user drag and drop * @see https://ghostinspector.com/blog/simulate-drag-and-drop-javascript-casperjs/ * @param {Element} elemDrag - Element to drag from * @param {Element} elemDrop - Element to drop onto * @returns {void} */ 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 to keep a specific video in view (to prevent lazy unloading) * @param {number} videoIndex - Index of video to keep in view * @param {NodeList|Element[]} allAnchors - All video anchor elements * @returns {void} */ let keepVideoInView = (videoIndex, allAnchors) => { if (!allAnchors || videoIndex >= allAnchors.length) return; try { // Scroll to keep the video in the middle of the viewport const targetElement = allAnchors[videoIndex]; if (targetElement) { targetElement.scrollIntoView({ behavior: 'auto', block: 'center' }); } } catch (e) { // Ignore errors if element is not found if (debug) console.log("Could not scroll to video:", e); } }; /** * Scroll automatically to the bottom of the page (or specific scroll position) * @param {number|null} scrollTop - Target scroll position (null for bottom of page) * @returns {Promise<void>} Resolves when scrolling is complete */ let autoScroll = async (scrollTop = null) => { let element = document.scrollingElement; if (!element) return; let currentScroll = element.scrollTop; let scrollDestination = scrollTop !== null ? scrollTop : element.scrollHeight; let scrollCount = 0; let maxAttempts = 3; // Reduced from implicit infinite to 3 attempts do { if (stopSort) break; // Check stopSort at the start of each iteration currentScroll = element.scrollTop; element.scrollTop = scrollDestination; await wait(scrollLoopTime); scrollCount++; // If we haven't moved in 2 attempts, we're probably at the bottom if (scrollCount > 1 && currentScroll === element.scrollTop) { break; } } while (currentScroll !== scrollDestination && scrollCount < maxAttempts && stopSort === false); }; /** * Get current timestamp for log entries in HH:MM:SS format * @returns {string} Formatted timestamp string */ let getTimestamp = () => { const now = new Date(); const hours = String(now.getHours()).padStart(2, '0'); const minutes = String(now.getMinutes()).padStart(2, '0'); const seconds = String(now.getSeconds()).padStart(2, '0'); return `${hours}:${minutes}:${seconds}`; }; /** * Log activities with scrollable console * @param {string} message - Message to log * @param {boolean} [append=true] - If true, append to log; if false, replace * @param {boolean} [isVerbose=false] - If true, only show when verbose mode is on * @returns {void} */ let logActivity = (message, append = true, isVerbose = false) => { const timestamp = getTimestamp(); const entry = { raw: `[${timestamp}] ${message}`, message, isVerbose }; // Always log to console for debugging console.log(entry.raw); if (append) { logEntries.push(entry); if (logEntries.length > MAX_LOG_MESSAGES) { logEntries.shift(); } } else { logEntries = [entry]; } const shouldAutoScroll = !isVerbose || verboseMode; renderLogDisplay(shouldAutoScroll); }; /** * Render log display by filtering and showing appropriate log entries * @param {boolean} [shouldAutoScroll=true] - Whether to auto-scroll to bottom * @returns {void} */ function renderLogDisplay(shouldAutoScroll = true) { if (!logEntries.length) { log.textContent = DEFAULT_LOG_MESSAGE; log.classList.add('sort-log-empty'); return; } const displayMessages = logEntries .filter(entry => !entry.isVerbose || verboseMode) .map(entry => entry.message); if (displayMessages.length === 0) { log.textContent = DEFAULT_LOG_MESSAGE; log.classList.add('sort-log-empty'); } else { log.textContent = displayMessages.join('\n'); log.classList.remove('sort-log-empty'); if (shouldAutoScroll && autoScrollLog) { log.scrollTop = log.scrollHeight; } } } /** * Clear the log console and reset to default message * @returns {void} */ let clearLog = () => { logEntries = []; log.textContent = DEFAULT_LOG_MESSAGE; log.classList.add('sort-log-empty'); }; /** * Generate menu container element and inject into YouTube page * Handles both NEW and OLD YouTube architecture * @returns {Element|null} Container element or null if parent not found */ let renderContainerElement = () => { // Try NEW YouTube architecture first let actionsRow = document.querySelector(NEW_ACTIONS_ROW_SELECTOR); let parent = null; if (actionsRow) { // NEW architecture: Insert BELOW the button row parent = actionsRow.parentElement; } else { // Try OLD architecture selectors (for Watch Later / older layouts) // Use thumbnail-and-metadata-wrapper which appears below the buttons on Watch Later parent = document.querySelector('div.thumbnail-and-metadata-wrapper'); if (!parent) { parent = document.querySelector(PLAYLIST_ACTIONS_SELECTOR) || document.querySelector(`${PLAYLIST_HEADER_SELECTOR} #container`) || document.querySelector(PLAYLIST_HEADER_SELECTOR); } } // Fallback for regular playlists (sidebar layout) if (!parent || parent.hasAttribute('hidden')) { parent = document.querySelector('ytd-playlist-sidebar-primary-info-renderer #menu'); } if (!parent) { if (debug) console.warn('Sort Playlist: container parent not found.'); return null; } const existing = document.querySelector('.sort-playlist-wrapper'); if (existing) { existing.remove(); } const wrapper = document.createElement('div'); wrapper.className = 'sort-playlist-wrapper'; const details = document.createElement('details'); details.className = 'sort-playlist-details'; details.open = false; const summary = document.createElement('summary'); summary.className = 'sort-playlist-summary'; const titleSpan = document.createElement('span'); titleSpan.className = 'sort-playlist-title'; titleSpan.innerText = 'Sort playlist by duration'; const versionSpan = document.createElement('span'); versionSpan.className = 'sort-playlist-version'; versionSpan.innerText = 'v' + SCRIPT_VERSION; summary.appendChild(titleSpan); summary.appendChild(versionSpan); details.appendChild(summary); const element = document.createElement('div'); element.className = 'sort-playlist sort-playlist-div sort-playlist-content'; // 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); details.appendChild(element); wrapper.appendChild(details); if (actionsRow) { // NEW architecture: Insert wrapper as a sibling AFTER the actions row actionsRow.insertAdjacentElement('afterend', wrapper); } else { // OLD architecture: Append to parent (thumbnail-and-metadata-wrapper) parent.append(wrapper); } return element; }; /** * Generate button element and add to button container * @param {Function} [click=()=>{}] - OnClick handler function * @param {string} [label=''] - Button label text * @param {boolean} [red=false] - Whether to use red styling (for stop/danger actions) * @returns {void} */ 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 dropdown element * @param {number} [variable=0] - Variable to update (0 for sortMode, 1 for autoScrollInitialVideoList) * @param {Array<{value: string, label: string}>} [options=[]] - Options to render * @param {string} [label=''] - Select label (currently unused) * @returns {void} */ let renderSelectElement = (variable = 0, options = [], label = '') => { // Create select const element = document.createElement('select'); element.className = 'style-scope sort-select sort-margin-right-3px'; element.onchange = (e) => { if (variable === 0) { sortMode = e.target.value; } else if (variable === 1) { autoScrollInitialVideoList = e.target.value; } }; // Create options and set initial selection options.forEach((option) => { const optionElement = document.createElement('option'); optionElement.value = option.value; optionElement.innerText = option.label; // Set selected based on current variable value if (variable === 0 && option.value === sortMode) { optionElement.selected = true; } else if (variable === 1 && option.value === autoScrollInitialVideoList) { optionElement.selected = true; } element.appendChild(optionElement); }); // Render select document.querySelector('.sort-playlist-select').appendChild(element); }; /** * Generate number input element for scroll retry time configuration * @param {number} [defaultValue=0] - Default value for the input * @param {string} [label=''] - Label text for the input * @returns {void} */ let renderNumberElement = (defaultValue = 0, label = '') => { // Create div const elementDiv = document.createElement('div'); elementDiv.className = 'sort-playlist-div sort-margin-right-3px'; // Create label const labelElement = document.createElement('span'); labelElement.className = 'sort-input-label'; labelElement.innerText = label; elementDiv.appendChild(labelElement); // Create input const element = document.createElement('input'); element.type = 'number'; element.value = defaultValue; element.min = '100'; element.step = '100'; element.className = 'style-scope sort-number-input'; element.oninput = (e) => { scrollLoopTime = Math.max(100, +(e.target.value)); }; // Render input elementDiv.appendChild(element); document.querySelector('div.sort-playlist').appendChild(elementDiv); }; /** * Render filter controls (checkbox + min/max duration inputs) * Allows users to filter videos by duration before sorting * @returns {void} */ let renderFilterControls = () => { const filterDiv = document.createElement('div'); filterDiv.className = 'sort-playlist-div'; filterDiv.style.marginTop = '8px'; filterDiv.style.padding = '8px'; filterDiv.style.borderTop = '1px solid rgba(0,0,0,0.1)'; // Filter enable checkbox const filterCheckboxContainer = document.createElement('label'); filterCheckboxContainer.className = 'sort-checkbox-container'; filterCheckboxContainer.style.display = 'block'; filterCheckboxContainer.style.marginBottom = '8px'; const filterCheckbox = document.createElement('input'); filterCheckbox.type = 'checkbox'; filterCheckbox.className = 'sort-checkbox'; filterCheckbox.checked = filterEnabled; filterCheckbox.onchange = (e) => { filterEnabled = e.target.checked; filterInputsContainer.style.display = filterEnabled ? 'block' : 'none'; }; const filterLabel = document.createElement('span'); filterLabel.innerText = 'Only include specific lengths'; filterLabel.style.fontWeight = '600'; filterCheckboxContainer.appendChild(filterCheckbox); filterCheckboxContainer.appendChild(filterLabel); filterDiv.appendChild(filterCheckboxContainer); // Container for filter inputs (hidden by default) const filterInputsContainer = document.createElement('div'); filterInputsContainer.style.display = filterEnabled ? 'block' : 'none'; filterInputsContainer.style.marginLeft = '20px'; // Min duration input const minDurationDiv = document.createElement('div'); minDurationDiv.style.marginBottom = '4px'; const minLabel = document.createElement('span'); minLabel.className = 'sort-input-label'; minLabel.innerText = 'Min duration (minutes): '; minLabel.style.fontSize = '12px'; const minInput = document.createElement('input'); minInput.type = 'number'; minInput.value = Math.floor(filterMinDuration / 60); minInput.min = '0'; minInput.step = '1'; minInput.className = 'style-scope sort-number-input'; minInput.style.width = '80px'; minInput.oninput = (e) => { filterMinDuration = Math.max(0, parseInt(e.target.value) || 0) * 60; }; minDurationDiv.appendChild(minLabel); minDurationDiv.appendChild(minInput); filterInputsContainer.appendChild(minDurationDiv); // Max duration input const maxDurationDiv = document.createElement('div'); const maxLabel = document.createElement('span'); maxLabel.className = 'sort-input-label'; maxLabel.innerText = 'Max duration (minutes): '; maxLabel.style.fontSize = '12px'; const maxInput = document.createElement('input'); maxInput.type = 'number'; maxInput.value = Math.floor(filterMaxDuration / 60); maxInput.min = '0'; maxInput.step = '1'; maxInput.className = 'style-scope sort-number-input'; maxInput.style.width = '80px'; maxInput.oninput = (e) => { filterMaxDuration = Math.max(0, parseInt(e.target.value) || 600) * 60; }; maxDurationDiv.appendChild(maxLabel); maxDurationDiv.appendChild(maxInput); filterInputsContainer.appendChild(maxDurationDiv); filterDiv.appendChild(filterInputsContainer); document.querySelector('div.sort-playlist').appendChild(filterDiv); }; /** * Generate log element with toggle, copy, and scroll controls * Creates the main logging console UI * @returns {void} */ let renderLogElement = () => { // Create container for log and buttons const logContainer = document.createElement('div'); logContainer.style.marginTop = '8px'; // Create container for log controls (copy button and checkboxes) const logControlsRow = document.createElement('div'); logControlsRow.style.display = logVisible ? 'flex' : 'none'; logControlsRow.style.gap = '12px'; logControlsRow.style.alignItems = 'center'; logControlsRow.style.marginBottom = '4px'; // Create copy log button const copyButton = document.createElement('button'); copyButton.className = 'style-scope sort-button-wl sort-button-wl-default'; copyButton.style.fontSize = '11px'; copyButton.style.padding = '4px 8px'; copyButton.innerText = 'Copy Log'; copyButton.onclick = () => { const logText = logEntries.length ? logEntries.map(entry => entry.raw).join('\n') : ''; navigator.clipboard.writeText(logText).then(() => { // Temporarily change button text const originalText = copyButton.innerText; copyButton.innerText = '✓ Copied!'; setTimeout(() => { copyButton.innerText = originalText; }, 2000); }).catch(err => { logActivity('❌ Failed to copy to clipboard'); console.error('Copy failed:', err); }); }; // Create scroll log checkbox container const scrollContainer = document.createElement('label'); scrollContainer.className = 'sort-checkbox-container'; const scrollCheckbox = document.createElement('input'); scrollCheckbox.type = 'checkbox'; scrollCheckbox.className = 'sort-checkbox'; scrollCheckbox.checked = autoScrollLog; scrollCheckbox.onchange = (e) => { autoScrollLog = e.target.checked; if (autoScrollLog) { log.scrollTop = log.scrollHeight; } }; const scrollLabel = document.createElement('span'); scrollLabel.innerText = 'Scroll Log'; scrollContainer.appendChild(scrollCheckbox); scrollContainer.appendChild(scrollLabel); // Create verbose checkbox container const verboseContainer = document.createElement('label'); verboseContainer.className = 'sort-checkbox-container'; const verboseCheckbox = document.createElement('input'); verboseCheckbox.type = 'checkbox'; verboseCheckbox.className = 'sort-checkbox'; verboseCheckbox.checked = verboseMode; verboseCheckbox.onchange = (e) => { verboseMode = e.target.checked; renderLogDisplay(false); logActivity(verboseMode ? 'Verbose mode enabled' : 'Verbose mode disabled'); }; const verboseLabel = document.createElement('span'); verboseLabel.innerText = 'Verbose'; verboseContainer.appendChild(verboseCheckbox); verboseContainer.appendChild(verboseLabel); // Populate log div log.className = 'style-scope sort-log'; log.textContent = DEFAULT_LOG_MESSAGE; log.classList.add('sort-log-empty'); log.style.display = logVisible ? 'block' : 'none'; // Add controls to log controls row logControlsRow.appendChild(copyButton); logControlsRow.appendChild(scrollContainer); logControlsRow.appendChild(verboseContainer); // Render elements logContainer.appendChild(logControlsRow); logContainer.appendChild(log); document.querySelector('div.sort-playlist').appendChild(logContainer); // Store reference to controls row for toggling window.logControlsRow = logControlsRow; }; /** * Add CSS styling to the page * Injects custom styles for the playlist sorter UI * @returns {void} */ let addCssStyle = () => { if (document.head.querySelector('#sort-playlist-style')) { return; } const element = document.createElement('style'); element.id = 'sort-playlist-style'; element.textContent = css; document.head.appendChild(element); }; /** * Show settings panel modal for configuration management * Allows users to configure and save default settings * @returns {void} */ let showSettingsPanel = () => { // Create overlay const overlay = document.createElement('div'); overlay.className = 'sort-settings-overlay'; // Create modal const modal = document.createElement('div'); modal.className = 'sort-settings-modal'; // Title with close button const titleBar = document.createElement('div'); titleBar.className = 'sort-settings-title'; titleBar.style.display = 'flex'; titleBar.style.justifyContent = 'space-between'; titleBar.style.alignItems = 'center'; const titleText = document.createElement('span'); titleText.textContent = '⚙️ Settings'; const closeButton = document.createElement('button'); closeButton.innerHTML = '✕'; closeButton.className = 'sort-settings-close-btn'; closeButton.style.background = 'none'; closeButton.style.border = 'none'; closeButton.style.color = '#aaa'; closeButton.style.fontSize = '24px'; closeButton.style.cursor = 'pointer'; closeButton.style.padding = '0'; closeButton.style.lineHeight = '1'; closeButton.style.transition = 'color 0.2s'; closeButton.onmouseover = () => { closeButton.style.color = '#f1f1f1'; }; closeButton.onmouseout = () => { closeButton.style.color = '#aaa'; }; closeButton.onclick = () => { document.body.removeChild(overlay); document.body.removeChild(modal); }; titleBar.appendChild(titleText); titleBar.appendChild(closeButton); modal.appendChild(titleBar); // Sort Settings Section const sortSection = document.createElement('div'); sortSection.className = 'sort-settings-section'; const sortTitle = document.createElement('div'); sortTitle.className = 'sort-settings-section-title'; sortTitle.textContent = 'Sort Preferences'; sortSection.appendChild(sortTitle); // Sort Mode const sortModeRow = document.createElement('div'); sortModeRow.className = 'sort-settings-row'; const sortModeLabel = document.createElement('span'); sortModeLabel.className = 'sort-settings-label'; sortModeLabel.textContent = 'Default Sort Mode:'; const sortModeSelect = document.createElement('select'); sortModeSelect.className = 'sort-select'; modeAvailable.forEach(opt => { const option = document.createElement('option'); option.value = opt.value; option.textContent = opt.label; option.selected = sortMode === opt.value; sortModeSelect.appendChild(option); }); sortModeRow.appendChild(sortModeLabel); sortModeRow.appendChild(sortModeSelect); sortSection.appendChild(sortModeRow); // Auto Scroll Mode const autoScrollRow = document.createElement('div'); autoScrollRow.className = 'sort-settings-row'; const autoScrollLabel = document.createElement('span'); autoScrollLabel.className = 'sort-settings-label'; autoScrollLabel.textContent = 'Default Auto-Scroll:'; const autoScrollSelect = document.createElement('select'); autoScrollSelect.className = 'sort-select'; autoScrollOptions.forEach(opt => { const option = document.createElement('option'); option.value = opt.value; option.textContent = opt.label; option.selected = autoScrollInitialVideoList === opt.value; autoScrollSelect.appendChild(option); }); autoScrollRow.appendChild(autoScrollLabel); autoScrollRow.appendChild(autoScrollSelect); sortSection.appendChild(autoScrollRow); // Scroll Retry Time const scrollTimeRow = document.createElement('div'); scrollTimeRow.className = 'sort-settings-row'; const scrollTimeLabel = document.createElement('span'); scrollTimeLabel.className = 'sort-settings-label'; scrollTimeLabel.textContent = 'Scroll Retry Time (ms):'; const scrollTimeInput = document.createElement('input'); scrollTimeInput.type = 'number'; scrollTimeInput.className = 'sort-number-input'; scrollTimeInput.value = scrollLoopTime; scrollTimeInput.min = '100'; scrollTimeInput.step = '100'; scrollTimeRow.appendChild(scrollTimeLabel); scrollTimeRow.appendChild(scrollTimeInput); sortSection.appendChild(scrollTimeRow); modal.appendChild(sortSection); // Filter Settings Section const filterSection = document.createElement('div'); filterSection.className = 'sort-settings-section'; const filterTitle = document.createElement('div'); filterTitle.className = 'sort-settings-section-title'; filterTitle.textContent = 'Filter Preferences'; filterSection.appendChild(filterTitle); // Enable Filter const filterEnableRow = document.createElement('div'); filterEnableRow.className = 'sort-settings-row'; const filterEnableLabel = document.createElement('span'); filterEnableLabel.className = 'sort-settings-label'; filterEnableLabel.textContent = 'Only Include Specific Lengths:'; const filterEnableCheckbox = document.createElement('input'); filterEnableCheckbox.type = 'checkbox'; filterEnableCheckbox.className = 'sort-checkbox'; filterEnableCheckbox.checked = filterEnabled; filterEnableRow.appendChild(filterEnableLabel); filterEnableRow.appendChild(filterEnableCheckbox); filterSection.appendChild(filterEnableRow); // Min Duration const minDurationRow = document.createElement('div'); minDurationRow.className = 'sort-settings-row sort-filter-duration-row'; minDurationRow.style.marginLeft = '20px'; minDurationRow.style.display = filterEnabled ? 'flex' : 'none'; const minLabel = document.createElement('span'); minLabel.className = 'sort-settings-label'; minLabel.textContent = 'Min Duration (minutes):'; const minInput = document.createElement('input'); minInput.type = 'number'; minInput.className = 'sort-number-input'; minInput.value = Math.floor(filterMinDuration / 60); minInput.min = '0'; minInput.step = '1'; minDurationRow.appendChild(minLabel); minDurationRow.appendChild(minInput); filterSection.appendChild(minDurationRow); // Max Duration const maxDurationRow = document.createElement('div'); maxDurationRow.className = 'sort-settings-row sort-filter-duration-row'; maxDurationRow.style.marginLeft = '20px'; maxDurationRow.style.display = filterEnabled ? 'flex' : 'none'; const maxLabel = document.createElement('span'); maxLabel.className = 'sort-settings-label'; maxLabel.textContent = 'Max Duration (minutes):'; const maxInput = document.createElement('input'); maxInput.type = 'number'; maxInput.className = 'sort-number-input'; maxInput.value = Math.floor(filterMaxDuration / 60); maxInput.min = '0'; maxInput.step = '1'; maxDurationRow.appendChild(maxLabel); maxDurationRow.appendChild(maxInput); filterSection.appendChild(maxDurationRow); // Add change handler to toggle filter inputs visibility filterEnableCheckbox.onchange = (e) => { const display = e.target.checked ? 'flex' : 'none'; minDurationRow.style.display = display; maxDurationRow.style.display = display; }; modal.appendChild(filterSection); // Other Settings Section const otherSection = document.createElement('div'); otherSection.className = 'sort-settings-section'; const otherTitle = document.createElement('div'); otherTitle.className = 'sort-settings-section-title'; otherTitle.textContent = 'Other Preferences'; otherSection.appendChild(otherTitle); // Dry Run Mode const dryRunRow = document.createElement('div'); dryRunRow.className = 'sort-settings-row'; const dryRunLabel = document.createElement('span'); dryRunLabel.className = 'sort-settings-label'; dryRunLabel.textContent = 'Enable Dry Run (Preview Before Sort):'; const dryRunCheckbox = document.createElement('input'); dryRunCheckbox.type = 'checkbox'; dryRunCheckbox.className = 'sort-checkbox'; dryRunCheckbox.checked = dryRunEnabled; dryRunRow.appendChild(dryRunLabel); dryRunRow.appendChild(dryRunCheckbox); otherSection.appendChild(dryRunRow); // Log Visible by Default const logVisibleRow = document.createElement('div'); logVisibleRow.className = 'sort-settings-row'; const logVisibleLabel = document.createElement('span'); logVisibleLabel.className = 'sort-settings-label'; logVisibleLabel.textContent = 'Show Log by Default:'; const logVisibleCheckbox = document.createElement('input'); logVisibleCheckbox.type = 'checkbox'; logVisibleCheckbox.className = 'sort-checkbox'; logVisibleCheckbox.checked = logVisible; logVisibleRow.appendChild(logVisibleLabel); logVisibleRow.appendChild(logVisibleCheckbox); otherSection.appendChild(logVisibleRow); modal.appendChild(otherSection); // Buttons const buttonsDiv = document.createElement('div'); buttonsDiv.className = 'sort-settings-buttons'; const saveButton = document.createElement('button'); saveButton.className = 'sort-button-wl sort-button-wl-default'; saveButton.textContent = 'Save Settings'; saveButton.onclick = () => { // Update current values sortMode = sortModeSelect.value; autoScrollInitialVideoList = autoScrollSelect.value; scrollLoopTime = parseInt(scrollTimeInput.value); dryRunEnabled = dryRunCheckbox.checked; filterEnabled = filterEnableCheckbox.checked; filterMinDuration = Math.max(0, parseInt(minInput.value) || 0) * 60; filterMaxDuration = Math.max(0, parseInt(maxInput.value) || 600) * 60; logVisible = logVisibleCheckbox.checked; // Save to localStorage saveSettings(getCurrentSettings()); // Update UI (this will show/hide log based on settings) updateUIFromSettings(); // Log success message (before closing modal so log is visible) logActivity('✅ Settings saved successfully'); // Close modal document.body.removeChild(overlay); document.body.removeChild(modal); }; const cancelButton = document.createElement('button'); cancelButton.className = 'sort-button-wl sort-button-wl-default'; cancelButton.style.background = '#888'; cancelButton.textContent = 'Cancel'; cancelButton.onclick = () => { document.body.removeChild(overlay); document.body.removeChild(modal); }; const resetButton = document.createElement('button'); resetButton.className = 'sort-button-wl sort-button-wl-stop'; resetButton.textContent = 'Reset to Defaults'; resetButton.onclick = () => { if (confirm('Reset all settings to default values?')) { applySettings(DEFAULT_SETTINGS); saveSettings(DEFAULT_SETTINGS); document.body.removeChild(overlay); document.body.removeChild(modal); updateUIFromSettings(); logActivity('🔄 Settings reset to defaults'); } }; buttonsDiv.appendChild(resetButton); buttonsDiv.appendChild(saveButton); modal.appendChild(buttonsDiv); // Close on overlay click overlay.onclick = () => { document.body.removeChild(overlay); document.body.removeChild(modal); }; document.body.appendChild(overlay); document.body.appendChild(modal); }; /** * Update UI elements to reflect current settings * Updates log visibility based on saved settings * @returns {void} */ let updateUIFromSettings = () => { // Update log visibility based on saved settings const toggleButton = document.querySelector('.sort-log-toggle-btn'); if (toggleButton) { toggleButton.innerText = logVisible ? 'Hide Log' : 'Show Log'; } const logControlsContainer = document.querySelector('.sort-log-controls'); if (logControlsContainer) { logControlsContainer.style.display = logVisible ? 'block' : 'none'; } if (log) { log.style.display = logVisible ? 'block' : 'none'; } }; /** * Perform a dry run simulation of the sort * Shows before/after comparison without actually moving videos * @returns {Promise<void>} Resolves when dry run is complete */ let performDryRun = async () => { logActivity('🔍 Starting Dry Run Mode...'); logActivity('📋 Analyzing current playlist order...'); // Get current videos let videoPairs = getPlaylistVideoPairs(); if (videoPairs.length === 0) { logActivity('❌ No videos found for dry run'); return; } // Create before/after arrays const beforeOrder = []; const afterOrder = []; videoPairs.forEach((pair, index) => { const duration = getVideoDuration(pair.anchor); const title = getVideoTitle(pair.item); const passes = passesFilter(duration); const videoInfo = { index: index + 1, title: title || 'Unknown Title', duration: duration, durationFormatted: duration !== null ? formatSeconds(duration) : 'N/A', passes: passes }; beforeOrder.push(videoInfo); }); // Sort the after order based on current settings const sortedVideos = [...beforeOrder]; // Separate filtered and non-filtered videos const passedVideos = sortedVideos.filter(v => v.passes); const filteredVideos = sortedVideos.filter(v => !v.passes); // Sort passed videos by duration (and title tiebreaker) passedVideos.sort((a, b) => { const durationA = a.duration !== null ? a.duration : -1; const durationB = b.duration !== null ? b.duration : -1; if (sortMode === 'asc') { if (durationA === durationB) { return a.title.localeCompare(b.title); } return durationA - durationB; } else { if (durationA === durationB) { return a.title.localeCompare(b.title); } return durationB - durationA; } }); // Combine: sorted passed videos + filtered videos at end afterOrder.push(...passedVideos, ...filteredVideos); // Renumber after order afterOrder.forEach((video, index) => { video.newIndex = index + 1; }); // Show modal with comparison showDryRunComparison(beforeOrder, afterOrder); logActivity('✅ Dry run complete - review changes in popup'); }; /** * Format seconds to readable time string * @param {number} seconds - Seconds to format * @returns {string} Formatted time string */ let formatSeconds = (seconds) => { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = seconds % 60; if (hours > 0) { return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; } else { return `${minutes}:${String(secs).padStart(2, '0')}`; } }; /** * Show dry run comparison modal * @param {Array} beforeOrder - Original video order * @param {Array} afterOrder - Predicted sorted order * @returns {void} */ let showDryRunComparison = (beforeOrder, afterOrder) => { // Create overlay const overlay = document.createElement('div'); overlay.className = 'sort-settings-overlay'; // Create modal const modal = document.createElement('div'); modal.className = 'sort-dry-run-modal'; // Title const title = document.createElement('div'); title.className = 'sort-dry-run-title'; title.textContent = '🔍 Dry Run - Sort Preview'; modal.appendChild(title); // Info box const info = document.createElement('div'); info.className = 'sort-dry-run-info'; info.innerHTML = ` <strong>Preview Mode:</strong> This shows how your playlist will be sorted without making any changes.<br> <strong>Mode:</strong> ${sortMode === 'asc' ? 'Shortest First' : 'Longest First'}<br> <strong>Videos:</strong> ${beforeOrder.length} total${filterEnabled ? ` (filter: ${Math.floor(filterMinDuration / 60)}-${Math.floor(filterMaxDuration / 60)} min)` : ''} `; modal.appendChild(info); // Comparison grid const comparison = document.createElement('div'); comparison.className = 'sort-dry-run-comparison'; // Before column const beforeColumn = document.createElement('div'); beforeColumn.className = 'sort-dry-run-column'; const beforeTitle = document.createElement('h4'); beforeTitle.textContent = '📋 Current Order'; beforeColumn.appendChild(beforeTitle); beforeOrder.forEach(video => { const videoDiv = document.createElement('div'); videoDiv.className = 'sort-dry-run-video'; videoDiv.textContent = `${video.index}. ${video.title.substring(0, 40)}${video.title.length > 40 ? '...' : ''} (${video.durationFormatted})`; if (!video.passes) { videoDiv.style.opacity = '0.5'; videoDiv.style.textDecoration = 'line-through'; } beforeColumn.appendChild(videoDiv); }); // After column const afterColumn = document.createElement('div'); afterColumn.className = 'sort-dry-run-column'; const afterTitle = document.createElement('h4'); afterTitle.textContent = '✨ After Sort'; afterColumn.appendChild(afterTitle); afterOrder.forEach(video => { const videoDiv = document.createElement('div'); videoDiv.className = 'sort-dry-run-video'; videoDiv.textContent = `${video.newIndex}. ${video.title.substring(0, 40)}${video.title.length > 40 ? '...' : ''} (${video.durationFormatted})`; if (!video.passes) { videoDiv.style.opacity = '0.5'; videoDiv.title = 'Filtered out - will be moved to end'; } // Highlight if position changed if (video.index !== video.newIndex) { videoDiv.style.borderLeft = '3px solid #3ea6ff'; } afterColumn.appendChild(videoDiv); }); comparison.appendChild(beforeColumn); comparison.appendChild(afterColumn); modal.appendChild(comparison); // Buttons const buttonsDiv = document.createElement('div'); buttonsDiv.className = 'sort-dry-run-buttons'; const applyButton = document.createElement('button'); applyButton.className = 'sort-button-wl sort-button-wl-default'; applyButton.textContent = '✓ Apply Sort'; applyButton.onclick = async () => { document.body.removeChild(overlay); document.body.removeChild(modal); logActivity('✅ Dry run approved - starting actual sort...'); // Temporarily disable dry run to perform actual sort const wasDryRunEnabled = dryRunEnabled; dryRunEnabled = false; await activateSort(); dryRunEnabled = wasDryRunEnabled; // Restore dry run setting }; const cancelButton = document.createElement('button'); cancelButton.className = 'sort-button-wl sort-button-wl-default'; cancelButton.style.background = '#888'; cancelButton.textContent = 'Cancel'; cancelButton.onclick = () => { document.body.removeChild(overlay); document.body.removeChild(modal); logActivity('🚫 Dry run cancelled - no changes made'); }; buttonsDiv.appendChild(cancelButton); buttonsDiv.appendChild(applyButton); modal.appendChild(buttonsDiv); // Close on overlay click overlay.onclick = () => { document.body.removeChild(overlay); document.body.removeChild(modal); logActivity('🚫 Dry run cancelled - no changes made'); }; document.body.appendChild(overlay); document.body.appendChild(modal); }; /** * Analyze playlist and show statistics (total duration, average, min/max, etc.) * Loads all videos in the playlist first, then calculates and displays statistics * @returns {Promise<void>} Resolves when analysis is complete */ let analyzePlaylist = async () => { // Show log if it's hidden if (!logVisible) { logVisible = true; const toggleButton = document.querySelector('.sort-log-toggle-btn'); if (toggleButton) { toggleButton.innerText = 'Hide Log'; } const logControlsContainer = document.querySelector('.sort-log-controls'); if (logControlsContainer) { logControlsContainer.style.display = 'block'; } if (log) { log.style.display = 'block'; } } clearLog(); const details = document.querySelector('.sort-playlist-details'); if (details && !details.open) { details.open = true; } logActivity("📊 Analyzing playlist..."); logActivity("🔄 Loading all videos in playlist..."); // Load ALL videos first (same logic as sort) let videoPairs = getPlaylistVideoPairs(); let initialCount = videoPairs.length; let scrollRetryCount = 0; let maxScrollRetries = 10; let noProgressCount = 0; // Scroll to load all videos while (scrollRetryCount < maxScrollRetries && stopSort === false) { let previousCount = videoPairs.length; logActivity("Loading videos... " + videoPairs.length + " loaded so far", true, true); await autoScroll(); await wait(scrollLoopTime * 2); videoPairs = getPlaylistVideoPairs(); if (previousCount === videoPairs.length) { noProgressCount++; scrollRetryCount++; if (noProgressCount >= 3) { logActivity("✓ All available videos loaded"); break; } } else { noProgressCount = 0; scrollRetryCount = 0; logActivity("📈 Loaded " + (videoPairs.length - previousCount) + " more videos"); } } // Get all video pairs after loading let allAnchors = videoPairs.map(pair => pair.anchor); if (allAnchors.length === 0) { logActivity("❌ No videos found to analyze"); return; } logActivity("📥 Found " + allAnchors.length + " loaded videos"); // Extract duration data let durations = []; let totalSeconds = 0; let validVideos = 0; let unavailableVideos = 0; for (let i = 0; i < allAnchors.length; i++) { let thumb = allAnchors[i]; let timeSpan = thumb.querySelector("#text"); if (!timeSpan || !timeSpan.innerText.trim()) { unavailableVideos++; continue; } let timeDigits = timeSpan.innerText.trim().split(":").reverse(); // Skip if no valid time if (timeDigits.length === 1) { unavailableVideos++; continue; } let seconds = parseInt(timeDigits[0]) || 0; if (timeDigits[1]) seconds += parseInt(timeDigits[1]) * 60; if (timeDigits[2]) seconds += parseInt(timeDigits[2]) * 3600; durations.push(seconds); totalSeconds += seconds; validVideos++; } if (validVideos === 0) { logActivity("❌ No videos with valid durations found"); return; } // Calculate statistics const avgSeconds = Math.floor(totalSeconds / validVideos); const minSeconds = Math.min(...durations); const maxSeconds = Math.max(...durations); // Format time helper const formatTime = (sec) => { const hours = Math.floor(sec / 3600); const minutes = Math.floor((sec % 3600) / 60); const seconds = sec % 60; if (hours > 0) { return hours + "h " + minutes + "m " + seconds + "s"; } else if (minutes > 0) { return minutes + "m " + seconds + "s"; } else { return seconds + "s"; } }; const formatTimeShort = (sec) => { const hours = Math.floor(sec / 3600); const minutes = Math.floor((sec % 3600) / 60); const seconds = sec % 60; if (hours > 0) { return hours + ":" + String(minutes).padStart(2, '0') + ":" + String(seconds).padStart(2, '0'); } else { return minutes + ":" + String(seconds).padStart(2, '0'); } }; // Display results logActivity("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); logActivity("📊 PLAYLIST ANALYSIS"); logActivity("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); logActivity("📹 Videos analyzed: " + validVideos); if (unavailableVideos > 0) { logActivity("⚠️ Unavailable/Private: " + unavailableVideos); } logActivity(""); logActivity("⏱️ DURATION STATISTICS:"); logActivity(" Total Duration: " + formatTime(totalSeconds)); logActivity(" Average Length: " + formatTime(avgSeconds)); logActivity(" Shortest Video: " + formatTimeShort(minSeconds)); logActivity(" Longest Video: " + formatTimeShort(maxSeconds)); logActivity("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); logActivity("✅ Analysis complete!"); }; /** * Export playlist data to CSV file * Loads all videos and generates a CSV with position, title, duration, and URL * @returns {Promise<void>} Resolves when export is complete and file is downloaded */ let exportPlaylist = async () => { // Show log if it's hidden if (!logVisible) { logVisible = true; const toggleButton = document.querySelector('.sort-log-toggle-btn'); if (toggleButton) { toggleButton.innerText = 'Hide Log'; } const logControlsContainer = document.querySelector('.sort-log-controls'); if (logControlsContainer) { logControlsContainer.style.display = 'block'; } if (log) { log.style.display = 'block'; } } clearLog(); const details = document.querySelector('.sort-playlist-details'); if (details && !details.open) { details.open = true; } logActivity("📤 Exporting playlist..."); logActivity("🔄 Loading all videos in playlist..."); // Load ALL videos first (same logic as sort) let videoPairs = getPlaylistVideoPairs(); let scrollRetryCount = 0; let maxScrollRetries = 10; let noProgressCount = 0; // Scroll to load all videos while (scrollRetryCount < maxScrollRetries && stopSort === false) { let previousCount = videoPairs.length; logActivity("Loading videos... " + videoPairs.length + " loaded so far", true, true); await autoScroll(); await wait(scrollLoopTime * 2); videoPairs = getPlaylistVideoPairs(); if (previousCount === videoPairs.length) { noProgressCount++; scrollRetryCount++; if (noProgressCount >= 3) { logActivity("✓ All available videos loaded"); break; } } else { noProgressCount = 0; scrollRetryCount = 0; logActivity("📈 Loaded " + (videoPairs.length - previousCount) + " more videos"); } } if (videoPairs.length === 0) { logActivity("❌ No videos found to export"); return; } logActivity("📥 Found " + videoPairs.length + " loaded videos"); logActivity("📝 Generating CSV file..."); // Build CSV content let csvContent = "Position,Title,Duration,URL\n"; for (let i = 0; i < videoPairs.length; i++) { const pair = videoPairs[i]; const anchor = pair.anchor; const item = pair.item; // Get video title const title = getVideoTitle(item); const escapedTitle = '"' + (title || 'Unknown').replace(/"/g, '""') + '"'; // Get duration const durationSeconds = getVideoDuration(anchor); let durationFormatted = "N/A"; if (durationSeconds !== null) { const hours = Math.floor(durationSeconds / 3600); const minutes = Math.floor((durationSeconds % 3600) / 60); const seconds = durationSeconds % 60; // Always format as HH:MM:SS for consistency durationFormatted = String(hours).padStart(2, '0') + ":" + String(minutes).padStart(2, '0') + ":" + String(seconds).padStart(2, '0'); } // Get clean video URL (remove query parameters after video ID) let videoUrl = anchor.href || ""; if (videoUrl.includes('v=')) { const videoId = videoUrl.split('v=')[1].split('&')[0]; videoUrl = "https://www.youtube.com/watch?v=" + videoId; } // Build CSV row csvContent += (i + 1) + ","; csvContent += escapedTitle + ","; csvContent += durationFormatted + ","; csvContent += videoUrl + "\n"; } // Create download link const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); // Generate filename with current date const now = new Date(); const dateStr = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0'); const playlistId = window.location.search.match(/list=([^&]+)/); const filename = 'youtube_playlist_' + (playlistId ? playlistId[1] : 'export') + '_' + dateStr + '.csv'; link.href = url; link.download = filename; link.style.display = 'none'; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); logActivity("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); logActivity("✅ Export complete!"); logActivity("📁 File: " + filename); logActivity("📊 Exported " + videoPairs.length + " videos"); logActivity("💾 CSV file downloaded to your Downloads folder"); logActivity("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); }; /** * Sort videos by duration (and optionally by title as tiebreaker) * Applies filtering if enabled and performs drag-and-drop operations * @param {Element[]} allAnchors - Array of video anchor elements * @param {Element[]} allDragPoints - Array of draggable handle elements * @param {Element[]} allItems - Array of video item elements * @param {number} expectedCount - Expected number of videos (for verification) * @returns {number} Number of videos sorted (position of last sorted video) */ let sortVideos = (allAnchors, allDragPoints, allItems, 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 item = allItems[j]; const duration = getVideoDuration(thumb); const title = getVideoTitle(item); // Check if video passes duration filter if (filterEnabled && !passesFilter(duration)) { // Videos that don't pass filter are pushed to the end videos.push({ anchor: drag, time: sortMode === "asc" ? 999999999999999999 : -1, title: title, originalIndex: j, filtered: true }); continue; } let time; if (duration === null) { // Videos without duration (unavailable/private) go to the end time = sortMode === "asc" ? 999999999999999999 : -1; } else { time = duration; } videos.push({ anchor: drag, time: time, title: title, originalIndex: j, filtered: false }); } // Sort by duration (and optionally title as tiebreaker) if (sortMode === "asc") { videos.sort((a, b) => { if (a.time !== b.time) { return a.time - b.time; } // If durations are equal and tiebreaker is enabled, sort by title if (useTitleTiebreaker && a.title && b.title) { return a.title.localeCompare(b.title); } return 0; }); } else { videos.sort((a, b) => { if (a.time !== b.time) { return b.time - a.time; } // If durations are equal and tiebreaker is enabled, sort by title if (useTitleTiebreaker && a.title && b.title) { return a.title.localeCompare(b.title); } return 0; }); } // Calculate total number of moves needed (videos out of place) // This is calculated per iteration but we'll use the global counter for display let totalMovesNeeded = 0; for (let j = 0; j < videos.length; j++) { if (videos[j].originalIndex !== j) { totalMovesNeeded++; } } 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) { globalMoveCounter++; // Increment global counter for each move let elemDrag = videos[j].anchor; let elemDrop = null; for (let k = 0; k < videos.length; k++) { if (videos[k].originalIndex === j) { elemDrop = videos[k].anchor; break; } } if (!elemDrop) { continue; } simulateDrag(elemDrag, elemDrop); dragged = true; } sorted = j; if (stopSort || dragged) { break; } } if (sorted > 0 && globalMoveCounter > 0) { // Calculate remaining moves let remainingMoves = globalTotalMoves - globalMoveCounter; if (remainingMoves < 0) remainingMoves = 0; logActivity("🔄 Moved #" + videos[sorted].originalIndex + " → #" + sorted + " (" + globalMoveCounter + "/" + globalTotalMoves + ")"); } return sorted; }; /** * Main sort activation function * Handles the complete sort process including: * - Loading all videos (or keeping current loaded set) * - Verifying playlist state * - Iteratively sorting videos by duration * - Managing lazy loading prevention * - Progress logging and error recovery * * Note: There is an inherent limit in how fast you can sort videos due to YouTube's * refresh behavior. This limit also applies to manual sorting. Performance degrades * with playlist size (approximately +2-4 seconds per 100 videos). * * @returns {Promise<void>} Resolves when sort is complete or stopped */ let activateSort = async () => { // Check if dry run mode is enabled if (dryRunEnabled) { await performDryRun(); return; // Exit early - performDryRun will call activateSort again if user confirms } // Proceed with actual sort clearLog(); const details = document.querySelector('.sort-playlist-details'); if (details && !details.open) { details.open = true; } logActivity("🚀 Starting sort process..."); logActivity("ℹ️ Script version: " + SCRIPT_VERSION, true, true); // Try multiple selectors to get video count let reportedVideoCountElement = null; let reportedVideoCount = 0; // Try NEW YouTube architecture first (yt-content-metadata-view-model) const metadataRows = document.querySelectorAll('.yt-content-metadata-view-model__metadata-row'); for (const row of metadataRows) { const spans = row.querySelectorAll('span.yt-core-attributed-string'); for (const span of spans) { if (span.textContent.includes('video')) { reportedVideoCountElement = span; break; } } if (reportedVideoCountElement) break; } // Fallback to old selectors if (!reportedVideoCountElement) { reportedVideoCountElement = document.querySelector("ytd-playlist-byline-renderer .metadata-stats .byline-item.style-scope.ytd-playlist-byline-renderer span"); } if (!reportedVideoCountElement) { reportedVideoCountElement = document.querySelector(".metadata-stats span.yt-formatted-string:first-of-type"); } // Try to parse the video count if (reportedVideoCountElement) { reportedVideoCount = parseInt(reportedVideoCountElement.innerText.replace(/[^0-9]/g, '')); } // If we still don't have a count, we'll estimate it by loading all videos if (isNaN(reportedVideoCount) || reportedVideoCount === 0) { logActivity("⚠️ Could not find video count in page. Will load and count videos..."); reportedVideoCount = -1; // Flag to indicate we need to count manually } logActivity("📊 Detected " + reportedVideoCount + " videos in playlist"); logActivity("🔧 Sort mode: " + (sortMode === 'asc' ? 'Shortest First' : 'Longest First')); logActivity("📜 Auto-scroll: " + (autoScrollInitialVideoList === 'true' ? 'Sort all videos' : 'Sort only loaded')); if (filterEnabled) { const formatTime = (sec) => { const hours = Math.floor(sec / 3600); const minutes = Math.floor((sec % 3600) / 60); if (hours > 0) return hours + "h " + minutes + "m"; if (minutes > 0) return minutes + "m"; return "0m"; }; logActivity("🎯 Filter: " + formatTime(filterMinDuration) + " - " + formatTime(filterMaxDuration)); } let videoPairs = getPlaylistVideoPairs(); let allDragPoints = videoPairs.map(pair => pair.drag); let allAnchors = videoPairs.map(pair => pair.anchor); let allItems = videoPairs.map(pair => pair.item); let sortedCount = 0; let initialVideoCount = videoPairs.length; logActivity("📥 Currently loaded: " + initialVideoCount + " videos"); let scrollRetryCount = 0; let maxScrollRetries = 10; // Maximum number of scroll retries let noProgressCount = 0; // Track consecutive attempts with no progress stopSort = false; // Always check for new content first (whether "Sort all" or "Sort only loaded") // Keep scrolling until no new videos load for 3 consecutive attempts while ( document.URL.includes("playlist?list=") && stopSort === false && scrollRetryCount < maxScrollRetries ) { let previousCount = videoPairs.length; if (autoScrollInitialVideoList === 'true') { logActivity("Loading more videos - " + allDragPoints.length + " / " + reportedVideoCount + " videos loaded", true, true); if (initialVideoCount > 600) { logActivity("⚠️ Sorting may take extremely long time/is likely to bug out"); } else if (initialVideoCount > 300) { logActivity("⚠️ Number of videos loaded is high, sorting may take a long time"); } await autoScroll(); // Wait for YouTube to load more content await wait(scrollLoopTime * 2); } else { logActivity("Checking for videos - " + allDragPoints.length + " loaded (will sort when stable)", true, true); // Give YouTube a moment to stabilise without loading more content await wait(scrollLoopTime); } videoPairs = getPlaylistVideoPairs(); allDragPoints = videoPairs.map(pair => pair.drag); allAnchors = videoPairs.map(pair => pair.anchor); allItems = videoPairs.map(pair => pair.item); initialVideoCount = videoPairs.length; // Check if we're making progress if (previousCount === initialVideoCount) { noProgressCount++; scrollRetryCount++; logActivity("No new videos loaded. Attempt " + noProgressCount + "/3", true, true); // If no progress after 3 attempts, we're done loading if (noProgressCount >= 3) { logActivity("✓ No new content detected. Ready to sort!"); break; } } else { noProgressCount = 0; // Reset counter if we're making progress scrollRetryCount = 0; logActivity("📈 Progress: Loaded " + (initialVideoCount - previousCount) + " new videos"); } // For "Sort all" mode, check if we've reached the target if (autoScrollInitialVideoList === 'true') { // If we're close to the target (within 10 videos), give it one more try if (((reportedVideoCount - initialVideoCount) <= 10) && noProgressCount < 2) { logActivity("Almost there! " + (reportedVideoCount - initialVideoCount) + " videos remaining...", true, true); continue; } // If count matches, we're done! if (reportedVideoCount === initialVideoCount) { logActivity("🎉 All " + reportedVideoCount + " videos loaded successfully!"); break; } } // For "Sort only loaded" mode, stop after we confirm no new content if (autoScrollInitialVideoList === 'false' && noProgressCount >= 3) { break; } } if (scrollRetryCount >= maxScrollRetries) { logActivity("⚠️ Max retry attempts reached. Proceeding with " + initialVideoCount + " videos."); } logActivity("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); logActivity(initialVideoCount + " videos loaded. Starting sort..."); if (scrollRetryCount > 5) logActivity("ℹ️ Note: Video count mismatch. This may be due to unavailable/private videos."); // For large playlists, we need to ensure all videos stay loaded logActivity("Preparing playlist for sorting..."); // Calculate total moves needed for progress tracking // This gives us the initial count of videos that need to be moved const calculateTotalMoves = (anchors, items) => { let videos = []; for (let i = 0; i < anchors.length; i++) { const duration = getVideoDuration(anchors[i]); const title = getVideoTitle(items[i]); let time; if (filterEnabled && !passesFilter(duration)) { time = sortMode === "asc" ? 999999999999999999 : -1; } else if (duration === null) { time = sortMode === "asc" ? 999999999999999999 : -1; } else { time = duration; } videos.push({ time, title, originalIndex: i }); } // Sort to determine final order if (sortMode === "asc") { videos.sort((a, b) => { if (a.time !== b.time) return a.time - b.time; if (useTitleTiebreaker && a.title && b.title) { return a.title.localeCompare(b.title); } return 0; }); } else { videos.sort((a, b) => { if (a.time !== b.time) return b.time - a.time; if (useTitleTiebreaker && a.title && b.title) { return a.title.localeCompare(b.title); } return 0; }); } // Count videos that need to move let totalMoves = 0; for (let i = 0; i < videos.length; i++) { if (videos[i].originalIndex !== i) { totalMoves++; } } return totalMoves; }; // Initialize global move counters globalMoveCounter = 0; globalTotalMoves = calculateTotalMoves(allAnchors, allItems); if (globalTotalMoves > 0) { logActivity("📊 Total moves needed: " + globalTotalMoves); } scrollRetryCount = 0; let reloadFailures = 0; // Track consecutive reload failures let maxReloadFailures = 3; // Max times we can fail to reload before giving up while (sortedCount < initialVideoCount && stopSort === false) { // CRITICAL: Re-load entire playlist before each sort iteration to prevent lazy unloading logActivity("⚙️ Ensuring all videos are loaded before sort iteration...", true, true); if (autoScrollInitialVideoList === 'true') { // Scroll to bottom to load all videos await autoScroll(); await wait(scrollLoopTime); // Scroll to top to ensure top videos are loaded too if (document.scrollingElement) { document.scrollingElement.scrollTop = 0; } await wait(scrollLoopTime); // Now get fresh references videoPairs = getPlaylistVideoPairs(); allDragPoints = videoPairs.map(pair => pair.drag); allAnchors = videoPairs.map(pair => pair.anchor); allItems = videoPairs.map(pair => pair.item); const loadedCount = videoPairs.length; // Verify we have all videos if (loadedCount !== initialVideoCount) { logActivity("⚠️ Video count mismatch! Expected: " + initialVideoCount + ", Got: " + loadedCount, true, true); logActivity("Re-loading playlist (attempt " + (reloadFailures + 1) + "/" + maxReloadFailures + ")...", true, true); // Try to reload with more aggressive scrolling await autoScroll(); await wait(scrollLoopTime * 3); // Longer wait if (document.scrollingElement) { document.scrollingElement.scrollTop = 0; } await wait(scrollLoopTime * 3); // Scroll one more time to bottom and back await autoScroll(); await wait(scrollLoopTime * 2); if (document.scrollingElement) { document.scrollingElement.scrollTop = 0; } await wait(scrollLoopTime * 2); videoPairs = getPlaylistVideoPairs(); allDragPoints = videoPairs.map(pair => pair.drag); allAnchors = videoPairs.map(pair => pair.anchor); allItems = videoPairs.map(pair => pair.item); const reloadedCount = videoPairs.length; if (reloadedCount !== initialVideoCount) { reloadFailures++; if (reloadFailures >= maxReloadFailures) { logActivity("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); logActivity("❌ YouTube keeps unloading videos - playlist too large for current method"); logActivity("💡 Solutions:"); logActivity(" 1. Refresh the page and try 'Sort only loaded' mode"); logActivity(" 2. Refresh and try again with a higher 'Scroll Retry Time' (1000-2000ms)"); logActivity(" 3. For playlists >200 videos, sort in smaller batches"); logActivity("📊 Progress saved: Sorted " + sortedCount + " of " + initialVideoCount + " videos"); return; } // If not max failures yet, continue the loop and try again logActivity("⚠️ Still can't load all videos. Will retry on next iteration...", true, true); continue; // Skip this iteration and try again } else { // Successfully reloaded, reset failure counter reloadFailures = 0; } } else { // Video count is correct, reset failure counter reloadFailures = 0; } logActivity("✓ All " + initialVideoCount + " videos confirmed loaded", true, true); } else { // Keep working set limited to what was already loaded await wait(scrollLoopTime / 2); videoPairs = getPlaylistVideoPairs(); allDragPoints = videoPairs.map(pair => pair.drag); allAnchors = videoPairs.map(pair => pair.anchor); allItems = videoPairs.map(pair => pair.item); const loadedCount = videoPairs.length; if (loadedCount !== initialVideoCount) { logActivity("ℹ️ Loaded video count changed from " + initialVideoCount + " to " + loadedCount + ". Using current loaded set.", true, true); initialVideoCount = loadedCount; if (sortedCount >= initialVideoCount) { sortedCount = Math.max(0, initialVideoCount - 1); } } if (initialVideoCount === 0) { logActivity("❌ No videos remain loaded. Stopping sort."); return; } reloadFailures = 0; logActivity("✓ Using currently loaded " + initialVideoCount + " videos", true, true); } // Position viewport near current sort position let viewportTarget = Math.max(0, Math.min(sortedCount, initialVideoCount - 1)); keepVideoInView(viewportTarget, allAnchors); await wait(scrollLoopTime / 2); logActivity("Running sort iteration on position " + sortedCount + "...", true, true); sortedCount = Number(sortVideos(allAnchors, allDragPoints, allItems, initialVideoCount) + 1); // After each sort operation, give YouTube time to process await wait(scrollLoopTime * 3); } if (stopSort === true) { logActivity("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); logActivity("⛔ Sort cancelled by user."); stopSort = false; } else { // Scroll to top to stabilize the view after sorting if (document.scrollingElement) { document.scrollingElement.scrollTop = 0; } logActivity("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); logActivity("✅ Sort complete! Videos sorted: " + sortedCount); logActivity("🎉 Playlist is now sorted by duration!"); logActivity("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); logActivity("⚠️ WARNING: Do NOT switch back to any of YouTube's"); logActivity(" automatic sorting methods (Date added, Most popular, etc.)"); logActivity(" or it will break your custom sort!"); logActivity(" Keep it on 'Manual' to preserve your duration sort."); } }; /** * Initialization wrapper for all on-screen elements * Sets up the UI, buttons, and event listeners for the playlist sorter * Handles both NEW and OLD YouTube architectures * @returns {void} */ let init = () => { // Wait for either NEW or OLD architecture to load const waitForPlaylist = () => { const newArch = document.querySelector(NEW_PAGE_HEADER_SELECTOR); const oldArch = document.querySelector(PLAYLIST_HEADER_SELECTOR); if (newArch || oldArch) { if (!renderContainerElement()) { return; } addCssStyle(); // Apply saved settings to UI applySettings(savedSettings); // Create button container const buttonContainer = document.querySelector('.sort-playlist-button'); // Main action buttons row (50% width each) const mainButtonsRow = document.createElement('div'); mainButtonsRow.style.display = 'flex'; mainButtonsRow.style.gap = '8px'; mainButtonsRow.style.marginBottom = '12px'; const sortButton = document.createElement('button'); sortButton.className = 'style-scope sort-button-wl sort-button-wl-default'; sortButton.style.flex = '1'; sortButton.style.fontWeight = '600'; sortButton.style.padding = '9px 16px'; sortButton.innerText = '▶ Sort Videos'; sortButton.onclick = async () => { await activateSort(); }; const stopButton = document.createElement('button'); stopButton.className = 'style-scope sort-button-wl sort-button-wl-stop'; stopButton.style.flex = '1'; stopButton.style.fontWeight = '600'; stopButton.style.padding = '9px 16px'; stopButton.innerText = '🛑 Stop Sort'; stopButton.onclick = () => { stopSort = true; }; mainButtonsRow.appendChild(sortButton); mainButtonsRow.appendChild(stopButton); buttonContainer.appendChild(mainButtonsRow); // Small utility buttons row const utilityButtonsRow = document.createElement('div'); utilityButtonsRow.style.display = 'flex'; utilityButtonsRow.style.gap = '8px'; // Create Stats button const statsButton = document.createElement('button'); statsButton.className = 'style-scope sort-button-wl sort-button-wl-default'; statsButton.style.fontSize = '11px'; statsButton.style.padding = '4px 8px'; statsButton.innerText = '📊 Stats'; statsButton.onclick = async () => { await analyzePlaylist(); }; // Create Export button const exportButton = document.createElement('button'); exportButton.className = 'style-scope sort-button-wl sort-button-wl-default'; exportButton.style.fontSize = '11px'; exportButton.style.padding = '4px 8px'; exportButton.innerText = '📥 Export'; exportButton.onclick = async () => { await exportPlaylist(); }; // Create Settings button const settingsButton = document.createElement('button'); settingsButton.className = 'style-scope sort-button-wl sort-button-wl-default'; settingsButton.style.fontSize = '11px'; settingsButton.style.padding = '4px 8px'; settingsButton.innerText = '⚙️ Settings'; settingsButton.title = 'Configure default settings'; settingsButton.onclick = () => { showSettingsPanel(); }; // Create Show Log button const showLogButton = document.createElement('button'); showLogButton.className = 'style-scope sort-button-wl sort-button-wl-default sort-log-toggle-btn'; showLogButton.style.fontSize = '11px'; showLogButton.style.padding = '4px 8px'; showLogButton.innerText = logVisible ? 'Hide Log' : 'Show Log'; showLogButton.onclick = () => { logVisible = !logVisible; showLogButton.innerText = logVisible ? 'Hide Log' : 'Show Log'; if (window.logControlsRow) { window.logControlsRow.style.display = logVisible ? 'flex' : 'none'; } log.style.display = logVisible ? 'block' : 'none'; }; utilityButtonsRow.appendChild(statsButton); utilityButtonsRow.appendChild(exportButton); utilityButtonsRow.appendChild(settingsButton); utilityButtonsRow.appendChild(showLogButton); buttonContainer.appendChild(utilityButtonsRow); // Only render log element (all settings now in Settings panel) renderLogElement(); // Apply saved settings to UI (ensures log visibility matches saved preference) updateUIFromSettings(); } }; // Try immediate initialization waitForPlaylist(); // Also set up observer for dynamic loading onElementReady(NEW_PAGE_HEADER_SELECTOR, false, waitForPlaylist); onElementReady(PLAYLIST_HEADER_SELECTOR, false, waitForPlaylist); }; /** * Initialise script - IIFE */ (() => { init(); if (window.navigation && typeof window.navigation.addEventListener === 'function') { window.navigation.addEventListener('navigate', navigateEvent => { const url = new URL(navigateEvent.destination.url); if (url.pathname.includes('playlist?')) { init(); } }); } })(); })(); // Close the main IIFE wrapper