Skip to content

Instantly share code, notes, and snippets.

@gangefors
Last active July 4, 2026 16:18
Show Gist options
  • Select an option

  • Save gangefors/7e37a9541af2d800489d9d4e2ef515ec to your computer and use it in GitHub Desktop.

Select an option

Save gangefors/7e37a9541af2d800489d9d4e2ef515ec to your computer and use it in GitHub Desktop.
YouTube Playlist UserScripts
// ==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();
})();
// ==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