Last active
July 4, 2026 16:18
-
-
Save gangefors/7e37a9541af2d800489d9d4e2ef515ec to your computer and use it in GitHub Desktop.
YouTube Playlist UserScripts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name YouTube Playlist Sort by Runtime | |
| // @namespace https://tampermonkey.net/ | |
| // @version 1.0.0 | |
| // @description Permanently sorts a playlist you own by video runtime, via YouTube's internal edit_playlist API. Includes Undo. | |
| // @author Claude Fable 5 | |
| // @match https://youtube.com/* | |
| // @match https://www.youtube.com/* | |
| // @run-at document-idle | |
| // @grant none | |
| // @downloadURL https://gist.github.com/gangefors/7e37a9541af2d800489d9d4e2ef515ec/raw/youtube-playlist-sort-by-runtime.user.js | |
| // @updateURL https://gist.github.com/gangefors/7e37a9541af2d800489d9d4e2ef515ec/raw/youtube-playlist-sort-by-runtime.user.js | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // --- Config --------------------------------------------------------------- | |
| const VERSION = '1.0.0'; | |
| const DEBUG = false; | |
| const MOVE_DELAY_MS = 400; // pause between move API requests | |
| const MOVE_DELAY_JITTER_MS = 200; | |
| // Number of move actions sent per edit_playlist request. 1 = one request | |
| // per move (safest, slow). Higher values assume YouTube applies the actions | |
| // array sequentially within a request — verified experimentally; if a batch | |
| // ever misbehaves, final verification catches it and Undo restores order. | |
| const BATCH_SIZE = 10; | |
| // Read-after-write settling for post-sort verification. | |
| const VERIFY_SETTLE_MS = 2000; | |
| const VERIFY_RETRIES = 3; | |
| const MAX_PAGES = 200; // safety cap on pagination (~20k videos) | |
| const SNAP_PREFIX = 'ypsr-snap-'; | |
| // Move-action schema, captured from a real drag-and-drop request: | |
| // { "action": "ACTION_MOVE_VIDEO_AFTER", | |
| // "setVideoId": "<entry being moved>", | |
| // "movedSetVideoIdPredecessor": "<entry it is placed after>" } | |
| // Omitting movedSetVideoIdPredecessor moves the entry to the top | |
| // (empirically verified). | |
| const ACTION_AFTER = 'ACTION_MOVE_VIDEO_AFTER'; | |
| const MOVED_FIELD = 'setVideoId'; | |
| const ANCHOR_FIELD = 'movedSetVideoIdPredecessor'; | |
| const CONTROLS_CLASS = 'ypsr-controls'; | |
| const STATUS_CLASS = 'ypsr-status'; | |
| const BTN_CLASS = 'ypsr-btn'; | |
| const CANCEL_CLASS = 'ypsr-cancel'; | |
| // ensureControls debouncing: on busy pages mutations can arrive faster | |
| // than the debounce interval forever, so a max-wait backstop guarantees a | |
| // run regardless (lesson learned the hard way in the runtime script). | |
| const ENSURE_DEBOUNCE_MS = 500; | |
| const ENSURE_MAX_WAIT_MS = 1500; | |
| let running = false; | |
| let cancelRequested = false; | |
| let lastStatus = ''; | |
| let observer = null; | |
| let debounceTimer = null; | |
| let maxWaitTimer = null; | |
| function log(...args) { | |
| if (DEBUG) console.debug('[ypsr]', ...args); | |
| } | |
| // --- Small helpers ---------------------------------------------------------- | |
| const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| function isPlaylistPage() { | |
| return location.pathname === '/playlist'; | |
| } | |
| function playlistId() { | |
| return new URLSearchParams(location.search).get('list'); | |
| } | |
| // Playlists that YouTube itself does not allow manual reordering of: | |
| // LL (Liked videos) and auto-generated mixes (RD…). No controls there. | |
| function isSortablePlaylist() { | |
| const id = playlistId(); | |
| if (!id) return false; | |
| if (id === 'LL') return false; | |
| if (id.startsWith('RD')) return false; | |
| return true; | |
| } | |
| function pageRoot() { | |
| // YouTube's page manager keeps previously visited pages hidden in the | |
| // DOM; only ever look inside the visible playlist browse. | |
| return document.querySelector('ytd-browse[page-subtype="playlist"]:not([hidden])') | |
| || document; | |
| } | |
| // --- InnerTube API access ------------------------------------------------------ | |
| function cfg(name) { | |
| try { | |
| if (window.ytcfg && typeof window.ytcfg.get === 'function') return window.ytcfg.get(name); | |
| return window.ytcfg && window.ytcfg.data_ ? window.ytcfg.data_[name] : undefined; | |
| } catch (e) { | |
| return undefined; | |
| } | |
| } | |
| function getCookie(name) { | |
| const m = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)')); | |
| return m ? decodeURIComponent(m[1]) : null; | |
| } | |
| async function sapisidHash() { | |
| const sapisid = getCookie('SAPISID') || getCookie('__Secure-3PAPISID') || getCookie('__Secure-1PAPISID'); | |
| if (!sapisid) throw new Error('SAPISID cookie not found — are you signed in?'); | |
| const ts = Math.floor(Date.now() / 1000); | |
| const data = new TextEncoder().encode(`${ts} ${sapisid} https://www.youtube.com`); | |
| const digest = await crypto.subtle.digest('SHA-1', data); | |
| const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); | |
| return `SAPISIDHASH ${ts}_${hex}`; | |
| } | |
| async function apiPost(endpoint, payload) { | |
| const key = cfg('INNERTUBE_API_KEY'); | |
| const context = cfg('INNERTUBE_CONTEXT'); | |
| if (!key || !context) throw new Error('ytcfg not available (INNERTUBE_API_KEY / INNERTUBE_CONTEXT)'); | |
| const headers = { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': await sapisidHash(), | |
| 'X-Origin': 'https://www.youtube.com', | |
| 'X-Goog-AuthUser': String(cfg('SESSION_INDEX') ?? '0'), | |
| }; | |
| const pageId = cfg('DELEGATED_SESSION_ID'); | |
| if (pageId) headers['X-Goog-PageId'] = pageId; | |
| const url = `https://www.youtube.com/youtubei/v1/${endpoint}?key=${encodeURIComponent(key)}&prettyPrint=false`; | |
| const res = await fetch(url, { | |
| method: 'POST', | |
| headers, | |
| credentials: 'same-origin', | |
| body: JSON.stringify({ context, ...payload }), | |
| }); | |
| if (!res.ok) throw new Error(`${endpoint} failed: HTTP ${res.status}`); | |
| return res.json(); | |
| } | |
| // --- Deep JSON scanning (robust against wrapper-structure changes) ---------------- | |
| function collectByKey(node, key, out) { | |
| if (!node || typeof node !== 'object') return out; | |
| if (Array.isArray(node)) { | |
| for (const x of node) collectByKey(x, key, out); | |
| return out; | |
| } | |
| if (Object.prototype.hasOwnProperty.call(node, key)) out.push(node[key]); | |
| for (const k in node) collectByKey(node[k], key, out); | |
| return out; | |
| } | |
| function extractEntries(json) { | |
| const renderers = collectByKey(json, 'playlistVideoRenderer', []); | |
| return renderers.map((r) => { | |
| const secRaw = parseInt(r.lengthSeconds, 10); | |
| return { | |
| videoId: r.videoId || null, | |
| setVideoId: r.setVideoId || null, | |
| sec: Number.isFinite(secRaw) && secRaw > 0 ? secRaw : null, | |
| title: (r.title && r.title.runs && r.title.runs[0] && r.title.runs[0].text) || '', | |
| }; | |
| }); | |
| } | |
| function extractContinuation(json) { | |
| const tokens = collectByKey(json, 'continuationCommand', []); | |
| return tokens.length ? tokens[0].token : null; | |
| } | |
| // Fetch every entry of the playlist through the same paginated API the page | |
| // uses. This sidesteps DOM lazy-loading entirely. | |
| // | |
| // IMPORTANT SCOPING: the browse response can contain more than the playlist | |
| // itself — notably a "Recommended videos" shelf whose items are also | |
| // playlistVideoRenderer objects (without setVideoId), loaded through a | |
| // section-list continuation. Real playlist items live inside | |
| // playlistVideoListRenderer, and the genuine in-list continuation (for | |
| // >100-video playlists) lives inside its contents. So: page 1 is scoped to | |
| // playlistVideoListRenderer subtrees, and continuation pages are scoped to | |
| // the appended continuationItems. | |
| async function fetchAllEntries(listId, onProgress) { | |
| const entries = []; | |
| let scopes; | |
| { | |
| const json = await apiPost('browse', { browseId: 'VL' + listId }); | |
| scopes = collectByKey(json, 'playlistVideoListRenderer', []); | |
| if (!scopes.length) { | |
| log('WARNING: no playlistVideoListRenderer in browse response; scanning whole response'); | |
| scopes = [json]; | |
| } | |
| } | |
| for (let page = 1; page <= MAX_PAGES; page++) { | |
| const batch = []; | |
| let token = null; | |
| for (const s of scopes) { | |
| batch.push(...extractEntries(s)); | |
| if (!token) token = extractContinuation(s); | |
| } | |
| entries.push(...batch); | |
| if (onProgress) onProgress(entries.length); | |
| log(`fetched page ${page}: +${batch.length} entries (total ${entries.length}), continuation: ${!!token}`); | |
| if (!token || batch.length === 0) break; | |
| const json = await apiPost('browse', { continuation: token }); | |
| const appended = collectByKey(json, 'appendContinuationItemsAction', []); | |
| scopes = appended.length | |
| ? appended.map((a) => ({ contents: a.continuationItems || [] })) | |
| : [json]; | |
| } | |
| return entries; | |
| } | |
| // --- Sort planning --------------------------------------------------------------- | |
| // Moves are abstract { moved, anchor } where anchor is the setVideoId of the | |
| // entry the moved one is placed AFTER, or null for "move to top". | |
| function sortedTarget(entries, direction) { | |
| // Stable sort by duration; entries without a known duration go last in | |
| // their original relative order. | |
| const withDur = entries.filter((e) => e.sec !== null); | |
| const withoutDur = entries.filter((e) => e.sec === null); | |
| const sorted = withDur | |
| .map((e, i) => [e, i]) | |
| .sort((a, b) => (direction === 'asc' ? a[0].sec - b[0].sec : b[0].sec - a[0].sec) || a[1] - b[1]) | |
| .map(([e]) => e); | |
| return sorted.concat(withoutDur); | |
| } | |
| // Longest increasing subsequence (patience sorting, O(n log n)). | |
| // Returns the indices of one LIS of `seq`. | |
| function lisIndices(seq) { | |
| const tails = []; | |
| const parent = new Array(seq.length).fill(-1); | |
| for (let i = 0; i < seq.length; i++) { | |
| let lo = 0, hi = tails.length; | |
| while (lo < hi) { | |
| const mid = (lo + hi) >> 1; | |
| if (seq[tails[mid]] < seq[i]) lo = mid + 1; | |
| else hi = mid; | |
| } | |
| if (lo > 0) parent[i] = tails[lo - 1]; | |
| tails[lo] = i; | |
| } | |
| const result = []; | |
| let k = tails.length ? tails[tails.length - 1] : -1; | |
| while (k !== -1) { | |
| result.push(k); | |
| k = parent[k]; | |
| } | |
| return result.reverse(); | |
| } | |
| // Compute the minimal set of move actions turning `current` into `target`, | |
| // then SIMULATE them locally and verify the simulated end state matches the | |
| // target. If the simulation does not check out we refuse to touch the | |
| // playlist at all. | |
| function planMoves(current, target) { | |
| const pos = new Map(target.map((e, i) => [e.setVideoId, i])); | |
| const seq = current.map((e) => pos.get(e.setVideoId)); | |
| const fixed = new Set(lisIndices(seq).map((i) => current[i].setVideoId)); | |
| const sim = current.map((e) => e.setVideoId); | |
| const moves = []; | |
| for (let i = 0; i < target.length; i++) { | |
| const svid = target[i].setVideoId; | |
| if (fixed.has(svid)) continue; | |
| if (i === 0) { | |
| if (sim[0] === svid) continue; | |
| moves.push({ moved: svid, anchor: null }); // anchorless = move to top | |
| sim.splice(sim.indexOf(svid), 1); | |
| sim.unshift(svid); | |
| } else { | |
| const prev = target[i - 1].setVideoId; | |
| const cur = sim.indexOf(svid); | |
| if (sim[cur - 1] === prev) continue; // already in place | |
| sim.splice(cur, 1); | |
| moves.push({ moved: svid, anchor: prev }); | |
| sim.splice(sim.indexOf(prev) + 1, 0, svid); | |
| } | |
| } | |
| const valid = sim.length === target.length && sim.every((s, i) => s === target[i].setVideoId); | |
| return { moves, valid }; | |
| } | |
| // --- Snapshots (Undo) -------------------------------------------------------------- | |
| function saveSnapshot(listId, entries) { | |
| try { | |
| localStorage.setItem(SNAP_PREFIX + listId, JSON.stringify({ | |
| ts: Date.now(), | |
| order: entries.map((e) => e.setVideoId), | |
| })); | |
| log('snapshot saved:', entries.length, 'entries'); | |
| } catch (e) { | |
| log('snapshot save failed:', e); | |
| } | |
| } | |
| function loadSnapshot(listId) { | |
| try { | |
| const raw = localStorage.getItem(SNAP_PREFIX + listId); | |
| return raw ? JSON.parse(raw) : null; | |
| } catch (e) { | |
| return null; | |
| } | |
| } | |
| // --- Execution --------------------------------------------------------------------- | |
| function encodeMove(move) { | |
| const action = { action: ACTION_AFTER, [MOVED_FIELD]: move.moved }; | |
| if (move.anchor !== null) action[ANCHOR_FIELD] = move.anchor; | |
| return action; | |
| } | |
| async function executeMoves(listId, moves, onProgress) { | |
| const batchSize = Math.max(1, BATCH_SIZE); | |
| let done = 0; | |
| for (let start = 0; start < moves.length; start += batchSize) { | |
| if (cancelRequested) throw new Error(`Cancelled after ${done} of ${moves.length} moves.`); | |
| const batch = moves.slice(start, start + batchSize); | |
| const res = await apiPost('browse/edit_playlist', { | |
| playlistId: listId, | |
| actions: batch.map(encodeMove), | |
| }); | |
| if (res.status !== 'STATUS_SUCCEEDED') { | |
| throw new Error(`Batch of moves ${done + 1}–${done + batch.length} of ${moves.length} failed ` + | |
| `(status: ${res.status || 'unknown'}). ` + | |
| 'The playlist is partially sorted; use Undo to restore the previous order.'); | |
| } | |
| done += batch.length; | |
| if (onProgress) onProgress(done, moves.length); | |
| if (done < moves.length) { | |
| await sleep(MOVE_DELAY_MS + Math.random() * MOVE_DELAY_JITTER_MS); | |
| } | |
| } | |
| } | |
| // Verify with patience: YouTube's reads can lag its writes, so settle | |
| // first and retry a few times before calling it a mismatch. | |
| async function verifyOrder(listId, target, setStatusFn) { | |
| for (let attempt = 1; attempt <= VERIFY_RETRIES; attempt++) { | |
| await sleep(VERIFY_SETTLE_MS); | |
| const fresh = await fetchAllEntries(listId); | |
| const ok = fresh.length === target.length && | |
| fresh.every((e, i) => e.setVideoId === target[i].setVideoId); | |
| if (ok) return { ok: true, count: fresh.length }; | |
| // Log the first divergence to distinguish lag from real bugs. | |
| let d = 0; | |
| while (d < Math.min(fresh.length, target.length) && | |
| fresh[d].setVideoId === target[d].setVideoId) d++; | |
| log(`verify attempt ${attempt}/${VERIFY_RETRIES}: mismatch at index ${d}` + | |
| ` (expected ${target[d] ? target[d].setVideoId : '<none>'},` + | |
| ` got ${fresh[d] ? fresh[d].setVideoId : '<none>'};` + | |
| ` lengths ${target.length}/${fresh.length})`); | |
| if (setStatusFn && attempt < VERIFY_RETRIES) { | |
| setStatusFn(`Verifying… order still settling (attempt ${attempt + 1})`); | |
| } | |
| } | |
| return { ok: false, count: -1 }; | |
| } | |
| // --- Top-level operations -------------------------------------------------------------- | |
| async function runOperation(kind) { | |
| if (running) return; | |
| const listId = playlistId(); | |
| if (!listId) return; | |
| running = true; | |
| cancelRequested = false; | |
| setButtonsEnabled(false); | |
| setCancelVisible(true); | |
| try { | |
| setStatus('Fetching playlist…'); | |
| const entries = await fetchAllEntries(listId, (n) => setStatus(`Fetching playlist… ${n} videos`)); | |
| log('fetched', entries.length, 'entries'); | |
| if (entries.length === 0) throw new Error('No videos found in this playlist.'); | |
| if (entries.some((e) => !e.setVideoId)) { | |
| throw new Error('This playlist is not editable by you ' + | |
| '(only your own playlists and Watch Later can be sorted).'); | |
| } | |
| // Build target order | |
| let target; | |
| let describe; | |
| if (kind === 'undo') { | |
| const snap = loadSnapshot(listId); | |
| if (!snap) throw new Error('No saved order to restore for this playlist.'); | |
| const byId = new Map(entries.map((e) => [e.setVideoId, e])); | |
| target = snap.order.map((svid) => byId.get(svid)).filter(Boolean); | |
| const inSnap = new Set(snap.order); | |
| target = target.concat(entries.filter((e) => !inSnap.has(e.setVideoId))); | |
| describe = `Restore the order saved ${new Date(snap.ts).toLocaleString()}`; | |
| } else { | |
| target = sortedTarget(entries, kind); | |
| describe = `Sort by runtime, ${kind === 'asc' ? 'shortest' : 'longest'} first`; | |
| } | |
| // Plan + simulate | |
| const { moves, valid } = planMoves(entries, target); | |
| if (!valid) { | |
| throw new Error('Internal consistency check failed (simulated result does not match target). ' + | |
| 'No changes were made to the playlist.'); | |
| } | |
| log('planned', moves.length, 'moves for', entries.length, 'entries'); | |
| if (moves.length === 0) { | |
| setStatus('Already in that order ✓'); | |
| return; | |
| } | |
| // Confirm | |
| const noDur = entries.filter((e) => e.sec === null).length; | |
| const batches = Math.ceil(moves.length / Math.max(1, BATCH_SIZE)); | |
| const estSec = Math.ceil(batches * (MOVE_DELAY_MS + MOVE_DELAY_JITTER_MS / 2) / 1000) + 1; | |
| const msg = `${describe}?\n\n` + | |
| `${entries.length} videos, ${moves.length} move operation${moves.length === 1 ? '' : 's'} ` + | |
| `in ${batches} request${batches === 1 ? '' : 's'} (est. ${estSec}s).\n` + | |
| (noDur && kind !== 'undo' ? `${noDur} video${noDur === 1 ? '' : 's'} without duration will be placed last.\n` : '') + | |
| `\nThis PERMANENTLY reorders the playlist on YouTube.\n` + | |
| `The current order is saved locally so you can Undo.`; | |
| if (!window.confirm(msg)) { | |
| setStatus('Cancelled.'); | |
| return; | |
| } | |
| // Snapshot current order for Undo, then execute | |
| saveSnapshot(listId, entries); | |
| await executeMoves(listId, moves, (done, total) => setStatus(`Sorting… ${done}/${total} moves`)); | |
| // Verify | |
| setStatus('Verifying…'); | |
| const check = await verifyOrder(listId, target, setStatus); | |
| if (check.ok) { | |
| setStatus('Done ✓ reloading…'); | |
| log('verification OK'); | |
| } else { | |
| setStatus('Done, but verification mismatched — reloading…'); | |
| console.warn('[ypsr] Post-sort verification mismatch; the order may still be settling.'); | |
| } | |
| await sleep(1500); | |
| location.reload(); | |
| } catch (err) { | |
| console.error('[ypsr]', err); | |
| setStatus('Error: ' + err.message); | |
| window.alert('[Playlist Sort] ' + err.message); | |
| } finally { | |
| running = false; | |
| cancelRequested = false; | |
| setButtonsEnabled(true); | |
| setCancelVisible(false); | |
| } | |
| } | |
| // --- UI -------------------------------------------------------------------------------- | |
| function makeButton(label, title, onClick, extraClass) { | |
| const b = document.createElement('button'); | |
| b.textContent = label; | |
| b.title = title; | |
| b.className = `${BTN_CLASS} ${extraClass || ''}`; | |
| b.style.cssText = | |
| 'margin-right:6px;padding:2px 10px;border:1px solid rgba(128,128,128,.5);' + | |
| 'border-radius:14px;background:transparent;color:inherit;cursor:pointer;' + | |
| 'font:inherit;font-size:1.2rem;line-height:1.8;'; | |
| b.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| onClick(); | |
| }); | |
| return b; | |
| } | |
| function buildControls() { | |
| const wrap = document.createElement('div'); | |
| wrap.className = CONTROLS_CLASS; | |
| wrap.style.cssText = 'display:block;width:100%;flex-basis:100%;margin-top:6px;'; | |
| wrap.appendChild(makeButton('⏱ Shortest first', 'Permanently sort this playlist by runtime, ascending', | |
| () => runOperation('asc'))); | |
| wrap.appendChild(makeButton('⏱ Longest first', 'Permanently sort this playlist by runtime, descending', | |
| () => runOperation('desc'))); | |
| wrap.appendChild(makeButton('↩ Undo', 'Restore the previously saved order', | |
| () => runOperation('undo'))); | |
| const cancel = makeButton('✕ Cancel', 'Stop after the current move', () => { | |
| cancelRequested = true; | |
| setStatus('Cancelling…'); | |
| }, CANCEL_CLASS); | |
| cancel.style.display = 'none'; | |
| wrap.appendChild(cancel); | |
| const status = document.createElement('span'); | |
| status.className = STATUS_CLASS; | |
| status.style.cssText = 'margin-left:4px;opacity:.8;font-size:1.2rem;'; | |
| status.textContent = lastStatus; | |
| wrap.appendChild(status); | |
| return wrap; | |
| } | |
| function setStatus(text) { | |
| lastStatus = text; | |
| for (const el of document.querySelectorAll(`.${STATUS_CLASS}`)) el.textContent = text; | |
| log('status:', text); | |
| } | |
| function setButtonsEnabled(enabled) { | |
| for (const el of document.querySelectorAll(`.${BTN_CLASS}:not(.${CANCEL_CLASS})`)) { | |
| el.disabled = !enabled; | |
| el.style.opacity = enabled ? '1' : '.5'; | |
| el.style.cursor = enabled ? 'pointer' : 'default'; | |
| } | |
| } | |
| function setCancelVisible(visible) { | |
| for (const el of document.querySelectorAll(`.${CANCEL_CLASS}`)) { | |
| el.style.display = visible ? '' : 'none'; | |
| } | |
| } | |
| // Insert the controls into every header variant of the visible browse | |
| // (hidden duplicates are harmless; YouTube swaps which header is shown). | |
| function ensureControls() { | |
| if (!isPlaylistPage()) return; | |
| if (!isSortablePlaylist()) { removeControls(); return; } | |
| const root = pageRoot(); | |
| let found = 0; | |
| let insertedNow = 0; | |
| // Variant 1: new page header — own row below the metadata rows. | |
| for (const meta of root.querySelectorAll('yt-page-header-renderer yt-content-metadata-view-model')) { | |
| if (!meta.querySelector('.ytContentMetadataViewModelMetadataRow')) continue; | |
| found++; | |
| if (!meta.querySelector(`.${CONTROLS_CLASS}`)) { meta.appendChild(buildControls()); insertedNow++; } | |
| } | |
| // Variant 2: immersive header byline — own div below the stats. | |
| for (const byline of root.querySelectorAll('ytd-playlist-byline-renderer')) { | |
| if (!byline.querySelector('.metadata-stats')) continue; | |
| found++; | |
| if (!byline.querySelector(`.${CONTROLS_CLASS}`)) { byline.appendChild(buildControls()); insertedNow++; } | |
| } | |
| // Variant 3: legacy sidebar header. | |
| const stats = root.querySelector('ytd-playlist-sidebar-primary-info-renderer #stats'); | |
| if (stats) { | |
| found++; | |
| if (!stats.querySelector(`.${CONTROLS_CLASS}`)) { stats.appendChild(buildControls()); insertedNow++; } | |
| } | |
| if (insertedNow > 0 || found === 0) { | |
| log(`ensureControls: ${found} header target(s) found, inserted into ${insertedNow}`, | |
| found === 0 ? '(NO TARGETS — header not rendered yet or structure changed)' : ''); | |
| } | |
| } | |
| function removeControls() { | |
| for (const el of document.querySelectorAll(`.${CONTROLS_CLASS}`)) el.remove(); | |
| } | |
| // --- Wiring ------------------------------------------------------------------------------- | |
| function runEnsure() { | |
| clearTimeout(debounceTimer); | |
| clearTimeout(maxWaitTimer); | |
| debounceTimer = null; | |
| maxWaitTimer = null; | |
| ensureControls(); | |
| } | |
| function scheduleEnsure() { | |
| clearTimeout(debounceTimer); | |
| debounceTimer = setTimeout(runEnsure, ENSURE_DEBOUNCE_MS); | |
| // Backstop: if mutations keep resetting the debounce, run anyway. | |
| if (maxWaitTimer === null) maxWaitTimer = setTimeout(runEnsure, ENSURE_MAX_WAIT_MS); | |
| } | |
| function startObserver() { | |
| stopObserver(); | |
| const target = document.querySelector('ytd-app') || document.body; | |
| observer = new MutationObserver((mutations) => { | |
| const root = pageRoot(); | |
| for (const m of mutations) { | |
| const t = m.target; | |
| if (t.nodeType === 1 && t.closest && t.closest(`.${CONTROLS_CLASS}`)) continue; | |
| if (root !== document && !root.contains(t)) continue; | |
| scheduleEnsure(); | |
| return; | |
| } | |
| }); | |
| observer.observe(target, { childList: true, subtree: true }); | |
| log('observer started on', target.tagName); | |
| } | |
| function stopObserver() { | |
| if (observer) { | |
| observer.disconnect(); | |
| observer = null; | |
| } | |
| clearTimeout(debounceTimer); | |
| clearTimeout(maxWaitTimer); | |
| debounceTimer = null; | |
| maxWaitTimer = null; | |
| } | |
| function onNavigate() { | |
| log('navigate:', location.pathname + location.search, | |
| '| playlistPage:', isPlaylistPage(), | |
| '| listId:', playlistId(), | |
| '| sortable:', isPlaylistPage() ? isSortablePlaylist() : '-'); | |
| if (isPlaylistPage() && isSortablePlaylist()) { | |
| if (!running) lastStatus = ''; | |
| startObserver(); | |
| scheduleEnsure(); | |
| } else { | |
| stopObserver(); | |
| removeControls(); | |
| } | |
| } | |
| log(`YouTube Playlist Sort by Runtime v${VERSION} loaded`); | |
| window.addEventListener('yt-navigate-finish', onNavigate); | |
| onNavigate(); | |
| })(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name YouTube Playlist Total Runtime | |
| // @namespace https://tampermonkey.net/ | |
| // @version 1.0.0 | |
| // @description Shows total playlist runtime in the playlist header. Toggle format between h:mm:ss or 1d2h/2h3m/3m4s. | |
| // @author Claude Fable 5 | |
| // @match https://youtube.com/* | |
| // @match https://www.youtube.com/* | |
| // @run-at document-idle | |
| // @grant none | |
| // @downloadURL https://gist.github.com/gangefors/7e37a9541af2d800489d9d4e2ef515ec/raw/youtube-playlist-total-runtime.user.js | |
| // @updateURL https://gist.github.com/gangefors/7e37a9541af2d800489d9d4e2ef515ec/raw/youtube-playlist-total-runtime.user.js | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // --- Config --------------------------------------------------------------- | |
| // Set to false to silence console output once everything works. | |
| const DEBUG = false; | |
| // 'hms' -> 26:33:44 | |
| // 'dhms' -> 1d2h (two biggest units, truncated) | |
| // Click the runtime text on the page to toggle; choice is persisted. | |
| const FORMAT_KEY = 'yprt-format'; | |
| const DEFAULT_FORMAT = 'hms'; | |
| const RUNTIME_CLASS = 'yprt-runtime'; | |
| const DELIM_CLASS = 'yprt-delimiter'; // legacy, kept for cleanup | |
| const ROW_CLASS = 'yprt-row'; | |
| const DURATION_RE = /^(\d+:)?\d{1,2}:\d{2}$/; // M:SS, MM:SS, H:MM:SS, HHH:MM:SS | |
| const DEBOUNCE_MS = 250; | |
| // Guarantee an update at most this long after the first pending trigger, | |
| // even if the page mutates continuously (debounce starvation). | |
| const MAX_WAIT_MS = 1000; | |
| let observer = null; | |
| let debounceTimer = null; | |
| let maxWaitTimer = null; | |
| // videoId -> duration in seconds. When hovering a video, YouTube swaps the | |
| // thumbnail for an inline preview and hides the duration badge; this cache | |
| // lets us keep counting that video so the total doesn't flicker. | |
| const durationCache = new Map(); | |
| // Change detection: skip re-rendering and logging when nothing changed. | |
| let lastLabel = null; | |
| let unchangedChecks = 0; | |
| function log(...args) { | |
| if (DEBUG) console.debug('[yprt]', ...args); | |
| } | |
| // --- Helpers --------------------------------------------------------------- | |
| function getFormat() { | |
| try { return localStorage.getItem(FORMAT_KEY) || DEFAULT_FORMAT; } | |
| catch (e) { return DEFAULT_FORMAT; } | |
| } | |
| function toggleFormat() { | |
| const next = getFormat() === 'hms' ? 'dhms' : 'hms'; | |
| try { localStorage.setItem(FORMAT_KEY, next); } catch (e) { /* ignore */ } | |
| log('format toggled to', next); | |
| lastLabel = null; | |
| update(); | |
| } | |
| function parseDuration(text) { | |
| // "3:11" -> 191, "1:02:33" -> 3753 | |
| const parts = text.trim().split(':').map(Number); | |
| if (parts.some(isNaN)) return 0; | |
| return parts.reduce((acc, v) => acc * 60 + v, 0); | |
| } | |
| function formatHMS(totalSec) { | |
| const h = Math.floor(totalSec / 3600); | |
| const m = Math.floor((totalSec % 3600) / 60); | |
| const s = totalSec % 60; | |
| const pad = (n) => String(n).padStart(2, '0'); | |
| return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; | |
| } | |
| function formatDHMS(totalSec) { | |
| const units = [['d', 86400], ['h', 3600], ['m', 60], ['s', 1]]; | |
| // Round to the unit below the largest one (the second displayed unit), | |
| // so e.g. 1d2h33m44s shows as "1d3h" rather than truncating to "1d2h". | |
| let i = units.findIndex(([, size]) => totalSec >= size); | |
| if (i === -1) return '0s'; | |
| const sub = units[Math.min(i + 1, units.length - 1)][1]; | |
| let rest = Math.round(totalSec / sub) * sub; | |
| // Rounding can carry into a bigger unit (23h59m40s -> 1d), so decompose | |
| // the rounded value from scratch. | |
| const parts = []; | |
| for (const [label, size] of units) { | |
| const v = Math.floor(rest / size); | |
| rest %= size; | |
| if (v > 0 || parts.length > 0) parts.push(`${v}${label}`); | |
| if (parts.length === 2) break; // only the two biggest units | |
| } | |
| if (parts.length === 2 && /^0/.test(parts[1])) parts.pop(); // "3d0h" -> "3d" | |
| return parts.length ? parts.join('') : '0s'; | |
| } | |
| function formatRuntime(totalSec) { | |
| return getFormat() === 'hms' ? formatHMS(totalSec) : formatDHMS(totalSec); | |
| } | |
| function isPlaylistPage() { | |
| return location.pathname === '/playlist'; | |
| } | |
| function pageRoot() { | |
| // YouTube's page manager keeps previously visited pages hidden in the | |
| // DOM, so there can be several ytd-browse elements (even several | |
| // playlist ones). Only ever look inside the visible one. | |
| return document.querySelector('ytd-browse[page-subtype="playlist"]:not([hidden])') | |
| || document; | |
| } | |
| function isVisible(el) { | |
| if (!el || !el.isConnected) return false; | |
| if (typeof el.checkVisibility === 'function') return el.checkVisibility(); | |
| return el.getClientRects().length > 0; | |
| } | |
| // --- Duration collection ---------------------------------------------------- | |
| // Some playlists (often music ones) get a "Recommended videos" shelf that | |
| // uses the same video renderers as real playlist items. Its section header | |
| // carries a locale-independent title-style marker we can key on. | |
| function inRecommendations(el) { | |
| const section = el.closest('ytd-item-section-renderer, yt-item-section-renderer'); | |
| return !!(section && section.querySelector('[title-style*="RECOMMENDATIONS"]')); | |
| } | |
| function videoIdOf(item) { | |
| const a = item.querySelector('a[href*="v="]'); | |
| if (!a) return null; | |
| const m = a.getAttribute('href').match(/[?&]v=([\w-]{11})/); | |
| return m ? m[1] : null; | |
| } | |
| // Duration of one playlist item, in seconds, or null if unknown. | |
| // Prefers the visible badge; falls back to the cached value when the badge | |
| // is temporarily gone (e.g. hidden by YouTube's hover preview). | |
| function itemDuration(item, badgeSelector) { | |
| const el = item.querySelector(badgeSelector); | |
| const text = el && el.textContent.trim(); | |
| const id = videoIdOf(item); | |
| if (text && DURATION_RE.test(text)) { | |
| const sec = parseDuration(text); | |
| if (id) durationCache.set(id, sec); | |
| return sec; | |
| } | |
| if (id && durationCache.has(id)) return durationCache.get(id); | |
| return null; | |
| } | |
| function collectRuntime() { | |
| const root = pageRoot(); | |
| let totalSec = 0; | |
| let countOld = 0; | |
| let countNew = 0; | |
| let skippedRec = 0; | |
| // Old UI items. Real playlist items live inside | |
| // ytd-playlist-video-list-renderer; "Recommended videos" shelves use the | |
| // same renderers but live outside it. Prefer that scope when present. | |
| // The overlay holds either a badge-shape ".ytBadgeShapeText" or a legacy | |
| // "span#text"; the selector tries both. | |
| const lists = root.querySelectorAll('ytd-playlist-video-list-renderer'); | |
| const oldScopes = lists.length ? lists : [root]; | |
| for (const scope of oldScopes) { | |
| for (const item of scope.querySelectorAll('ytd-playlist-video-renderer')) { | |
| if (inRecommendations(item)) { skippedRec++; continue; } | |
| const sec = itemDuration(item, | |
| 'ytd-thumbnail-overlay-time-status-renderer .ytBadgeShapeText, ' + | |
| 'ytd-thumbnail-overlay-time-status-renderer span#text'); | |
| if (sec !== null) { | |
| totalSec += sec; | |
| countOld++; | |
| } | |
| } | |
| } | |
| // New UI items (yt-lockup-view-model with a yt-thumbnail-badge-view-model). | |
| for (const item of root.querySelectorAll('yt-lockup-view-model')) { | |
| if (inRecommendations(item)) { skippedRec++; continue; } | |
| const sec = itemDuration(item, 'yt-thumbnail-badge-view-model .ytBadgeShapeText'); | |
| if (sec !== null) { | |
| totalSec += sec; | |
| countNew++; | |
| } | |
| } | |
| return { | |
| totalSec, | |
| count: countOld + countNew, | |
| detail: `old-UI=${countOld}, new-UI=${countNew}, total=${formatHMS(totalSec)}` | |
| + (skippedRec ? ` (skipped ${skippedRec} recommended)` : '') | |
| + (root === document ? ' (WARNING: no visible playlist browse found)' : ''), | |
| }; | |
| } | |
| // Total number of videos according to the header, so we can flag a partially | |
| // loaded playlist. Returns null if we can't figure it out. | |
| // | |
| // YouTube reuses the same browse element across playlists and only | |
| // refreshes the header variant it currently shows, so HIDDEN header | |
| // variants can contain stale counts from a previously viewed playlist. | |
| // Therefore: only trust visible elements, and fall back to hidden ones | |
| // just if no visible candidate yields a count. | |
| function totalVideoCount() { | |
| const root = pageRoot(); | |
| const visibleTexts = []; | |
| const hiddenTexts = []; | |
| const push = (el) => (isVisible(el) ? visibleTexts : hiddenTexts).push(el.textContent); | |
| // New page header metadata texts, e.g. "55 videos" | |
| for (const el of root.querySelectorAll( | |
| 'yt-page-header-renderer .ytContentMetadataViewModelMetadataText' | |
| )) push(el); | |
| // Immersive header byline, e.g. "80 videos" | |
| for (const el of root.querySelectorAll( | |
| 'ytd-playlist-byline-renderer yt-formatted-string.byline-item' | |
| )) push(el); | |
| // Legacy sidebar header stats | |
| for (const el of root.querySelectorAll( | |
| 'ytd-playlist-sidebar-primary-info-renderer #stats yt-formatted-string' | |
| )) push(el); | |
| const parse = (texts) => { | |
| for (const text of texts) { | |
| const m = text.match(/([\d\s.,\u00A0]+)\s*video/i); | |
| if (m) { | |
| const n = parseInt(m[1].replace(/[\s.,\u00A0]/g, ''), 10); | |
| if (!isNaN(n)) return n; | |
| } | |
| } | |
| return null; | |
| }; | |
| const fromVisible = parse(visibleTexts); | |
| if (fromVisible !== null) return fromVisible; | |
| return parse(hiddenTexts); | |
| } | |
| // --- Rendering --------------------------------------------------------------- | |
| function buildLabel(totalSec, count) { | |
| const total = totalVideoCount(); | |
| let label = formatRuntime(totalSec); | |
| if (total !== null && count < total) { | |
| label += ` (${count}/${total})`; // partially loaded playlist | |
| } | |
| return label; | |
| } | |
| function tooltip(count) { | |
| return `Total runtime of ${count} loaded video${count === 1 ? '' : 's'} - click to change format`; | |
| } | |
| function makeClickable(el) { | |
| el.style.cursor = 'pointer'; | |
| el.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| toggleFormat(); | |
| }); | |
| } | |
| function setSpan(span, label, title) { | |
| if (span.textContent !== label) span.textContent = label; | |
| span.title = title; | |
| } | |
| // YouTube keeps several header variants in the DOM at once (only one is | |
| // visible), so insert the runtime into ALL of them. Hidden duplicates are | |
| // harmless, and this survives YouTube swapping which header is shown. | |
| function renderRuntime(label, title) { | |
| const root = pageRoot(); | |
| const placed = []; | |
| // --- Variant 1: new page header --------------------------------------- | |
| // The stats row ("Playlist • Public • N videos • views • updated") can | |
| // get crowded and truncate, so the runtime goes on its own metadata row | |
| // below it. The header can contain more than one metadata block | |
| // (inline + collapsed/overlay variants). | |
| for (const meta of root.querySelectorAll( | |
| 'yt-page-header-renderer yt-content-metadata-view-model' | |
| )) { | |
| if (!meta.querySelector('.ytContentMetadataViewModelMetadataRow')) continue; | |
| let span = meta.querySelector(`.${RUNTIME_CLASS}`); | |
| if (!span) { | |
| const row = document.createElement('div'); | |
| row.className = `ytContentMetadataViewModelMetadataRow ${ROW_CLASS}`; | |
| row.setAttribute('role', 'group'); | |
| // Header rows can be laid out inline or as flex items; make sure | |
| // ours always breaks onto its own line. | |
| row.style.cssText = 'display:block;width:100%;flex-basis:100%;'; | |
| span = document.createElement('span'); | |
| span.className = | |
| 'ytAttributedStringHost ytContentMetadataViewModelMetadataText ' + | |
| 'ytAttributedStringWhiteSpacePreWrap ' + RUNTIME_CLASS; | |
| span.setAttribute('dir', 'auto'); | |
| span.setAttribute('role', 'text'); | |
| makeClickable(span); | |
| row.appendChild(span); | |
| meta.appendChild(row); | |
| } | |
| setSpan(span, label, title); | |
| placed.push(['page-header', isVisible(meta)]); | |
| } | |
| // --- Variant 2: immersive header byline -------------------------------- | |
| // <ytd-playlist-byline-renderer> holds .metadata-stats rows of | |
| // yt-formatted-string.byline-item entries ("80 videos", "No views", ...). | |
| // The runtime gets its own .metadata-stats row so it never gets | |
| // truncated by a crowded stats line. | |
| for (const byline of root.querySelectorAll('ytd-playlist-byline-renderer')) { | |
| if (!byline.querySelector('.metadata-stats')) continue; | |
| let span = byline.querySelector(`.${RUNTIME_CLASS}`); | |
| if (!span) { | |
| const row = document.createElement('div'); | |
| row.className = `metadata-stats style-scope ytd-playlist-byline-renderer ${ROW_CLASS}`; | |
| span = document.createElement('span'); | |
| span.className = `byline-item style-scope ytd-playlist-byline-renderer ${RUNTIME_CLASS}`; | |
| makeClickable(span); | |
| row.appendChild(span); | |
| byline.appendChild(row); | |
| } | |
| setSpan(span, label, title); | |
| placed.push(['byline', isVisible(byline)]); | |
| } | |
| // --- Variant 3: legacy sidebar header ----------------------------------- | |
| const stats = root.querySelector( | |
| 'ytd-playlist-sidebar-primary-info-renderer #stats' | |
| ); | |
| if (stats) { | |
| let span = stats.querySelector(`.${RUNTIME_CLASS}`); | |
| if (!span) { | |
| span = document.createElement('span'); | |
| span.className = RUNTIME_CLASS; | |
| span.style.marginRight = '8px'; | |
| makeClickable(span); | |
| stats.appendChild(span); | |
| } | |
| setSpan(span, label, title); | |
| placed.push(['sidebar', isVisible(stats)]); | |
| } | |
| log(`rendered "${label}" into:`, | |
| placed.map(([name, vis]) => `${name}(${vis ? 'visible' : 'hidden'})`).join(', ') || 'NO TARGETS FOUND'); | |
| if (DEBUG && placed.length && !placed.some(([, vis]) => vis)) { | |
| console.warn('[yprt] runtime inserted, but no target section is visible.', | |
| 'Please report the header element names on this page.'); | |
| } | |
| } | |
| function removeRuntime() { | |
| for (const el of document.querySelectorAll( | |
| `.${RUNTIME_CLASS}, .${DELIM_CLASS}, .${ROW_CLASS}` | |
| )) el.remove(); | |
| } | |
| // --- Main loop ----------------------------------------------------------------- | |
| function spansIntact(label) { | |
| const spans = pageRoot().querySelectorAll(`.${RUNTIME_CLASS}`); | |
| if (!spans.length) return false; | |
| for (const s of spans) if (s.textContent !== label) return false; | |
| return true; | |
| } | |
| function update() { | |
| if (!isPlaylistPage()) return; | |
| const { totalSec, count, detail } = collectRuntime(); | |
| if (count === 0) { | |
| if (lastLabel !== 'EMPTY') { | |
| lastLabel = 'EMPTY'; | |
| log('no video durations found on page (yet)'); | |
| } | |
| return; | |
| } | |
| const label = buildLabel(totalSec, count); | |
| // Nothing changed and our spans are still in place: do nothing, quietly. | |
| if (label === lastLabel && spansIntact(label)) { | |
| unchangedChecks++; | |
| return; | |
| } | |
| if (unchangedChecks > 0) { | |
| log(`(${unchangedChecks} unchanged re-checks since last render, not logged)`); | |
| unchangedChecks = 0; | |
| } | |
| lastLabel = label; | |
| log('collected durations:', detail); | |
| renderRuntime(label, tooltip(count)); | |
| } | |
| function runUpdate() { | |
| clearTimeout(debounceTimer); | |
| clearTimeout(maxWaitTimer); | |
| debounceTimer = null; | |
| maxWaitTimer = null; | |
| update(); | |
| } | |
| function scheduleUpdate() { | |
| clearTimeout(debounceTimer); | |
| debounceTimer = setTimeout(runUpdate, DEBOUNCE_MS); | |
| // Backstop: if mutations keep resetting the debounce, run anyway. | |
| if (maxWaitTimer === null) maxWaitTimer = setTimeout(runUpdate, MAX_WAIT_MS); | |
| } | |
| function startObserver() { | |
| stopObserver(); | |
| const target = document.querySelector('ytd-app') || document.body; | |
| observer = new MutationObserver((mutations) => { | |
| // Only react to mutations inside the visible playlist browse; the rest | |
| // of the app (miniplayer, guide, popups, hover previews elsewhere) | |
| // churns constantly and is irrelevant to us. Also ignore our own nodes. | |
| const root = pageRoot(); | |
| if (root === document) { scheduleUpdate(); return; } // browse not found yet | |
| for (const m of mutations) { | |
| const t = m.target; | |
| if (t.nodeType === 1 && (t.classList.contains(RUNTIME_CLASS) || | |
| t.classList.contains(DELIM_CLASS) || | |
| t.classList.contains(ROW_CLASS) || | |
| (t.closest && t.closest(`.${ROW_CLASS}`)))) continue; | |
| if (!root.contains(t)) continue; | |
| scheduleUpdate(); | |
| return; | |
| } | |
| }); | |
| observer.observe(target, { childList: true, subtree: true }); | |
| log('observer started on', target.tagName); | |
| } | |
| function stopObserver() { | |
| if (observer) { | |
| observer.disconnect(); | |
| observer = null; | |
| } | |
| clearTimeout(debounceTimer); | |
| clearTimeout(maxWaitTimer); | |
| debounceTimer = null; | |
| maxWaitTimer = null; | |
| } | |
| function onNavigate() { | |
| lastLabel = null; | |
| unchangedChecks = 0; | |
| durationCache.clear(); | |
| log('navigate:', location.pathname + location.search, | |
| isPlaylistPage() ? '(playlist page)' : '(not a playlist page)'); | |
| if (isPlaylistPage()) { | |
| startObserver(); | |
| scheduleUpdate(); | |
| } else { | |
| stopObserver(); | |
| removeRuntime(); | |
| } | |
| } | |
| // YouTube is a SPA; re-check on its navigation events. | |
| window.addEventListener('yt-navigate-finish', onNavigate); | |
| onNavigate(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment