Skip to content

Instantly share code, notes, and snippets.

@ashleyconnor
Last active July 20, 2026 04:31
Show Gist options
  • Select an option

  • Save ashleyconnor/9901905ad06c408da5fd1d04d1da214e to your computer and use it in GitHub Desktop.

Select an option

Save ashleyconnor/9901905ad06c408da5fd1d04d1da214e to your computer and use it in GitHub Desktop.
Remove watched videos from a YouTube playlist
/**
* Remove watched videos from a YouTube playlist.
*
* HOW TO USE:
* 1. Open the playlist page: https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID
* 2. Open Firefox DevTools console (F12 or Ctrl+Shift+K) on that tab.
* 3. Paste this whole script and press Enter.
* 4. It first runs in DRY_RUN mode — it will only log which videos it WOULD
* remove, without actually removing anything. Check the log looks right.
* 5. Set DRY_RUN to false below (or run `DRY_RUN = false` in the console,
* since the variable is exposed on window) and run the script again to
* actually remove them.
*
* "Watched" is detected via the red progress bar YouTube overlays on a
* thumbnail once you've played some of a video. WATCH_THRESHOLD controls how
* much of the video must be played before it counts as "watched":
* 0 -> remove anything with ANY playback progress (even a few seconds)
* 0.9 -> only remove videos played to ~90%+ (near/fully watched)
*/
(async function removeWatchedFromPlaylist() {
const DRY_RUN = true; // set to false to actually remove videos
const WATCH_THRESHOLD = 0.9; // fraction watched (0 to 1) to count as "watched"
const CLICK_DELAY = 700; // ms to wait after each click for menus to render
const SCROLL_DELAY = 800; // ms to wait between scroll steps while loading list
window.DRY_RUN = DRY_RUN;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
if (!location.pathname.startsWith('/playlist')) {
console.error('Run this on a playlist page: https://www.youtube.com/playlist?list=...');
return;
}
// 1. Scroll to force-load the entire playlist (YouTube lazy-loads rows).
console.log('Loading full playlist (scrolling)...');
let lastCount = -1;
let stableRounds = 0;
while (stableRounds < 3) {
window.scrollTo(0, document.documentElement.scrollHeight);
await sleep(SCROLL_DELAY);
const count = document.querySelectorAll('ytd-playlist-video-renderer').length;
if (count === lastCount) {
stableRounds++;
} else {
stableRounds = 0;
lastCount = count;
}
}
window.scrollTo(0, 0);
console.log(`Loaded ${lastCount} videos total.`);
// 2. Helper: does a row show a watched-progress bar past the threshold?
function getWatchFraction(row) {
const progressEl = row.querySelector(
'ytd-thumbnail-overlay-resume-playback-renderer #progress'
);
if (!progressEl) return 0; // no overlay at all = never played
const width = progressEl.style.width || '';
const pct = parseFloat(width); // e.g. "42%" -> 42
if (isNaN(pct)) return 0;
return pct / 100;
}
// 3. Helper: remove a single row via its "..." menu.
async function removeRow(row) {
const menuButton = row.querySelector('ytd-menu-renderer #button');
if (!menuButton) {
console.warn('No menu button found for a row, skipping:', row);
return false;
}
menuButton.click();
await sleep(CLICK_DELAY);
// The popup is appended to the document, not inside the row.
const items = Array.from(
document.querySelectorAll(
'ytd-menu-service-item-renderer, tp-yt-paper-item'
)
);
const removeItem = items.find((el) =>
/remove from/i.test(el.textContent || '')
);
if (!removeItem) {
console.warn('Could not find "Remove from..." option, closing menu.');
document.body.click();
await sleep(200);
return false;
}
removeItem.click();
await sleep(CLICK_DELAY);
return true;
}
// 4. Walk the list. Re-query each time since removing a row changes the DOM.
let removedCount = 0;
let checkedTitles = new Set();
while (true) {
const rows = Array.from(document.querySelectorAll('ytd-playlist-video-renderer'));
const target = rows.find((row) => {
const titleEl = row.querySelector('#video-title');
const title = titleEl ? titleEl.textContent.trim() : null;
if (!title || checkedTitles.has(title)) return false;
const fraction = getWatchFraction(row);
return fraction >= WATCH_THRESHOLD;
});
if (!target) break;
const titleEl = target.querySelector('#video-title');
const title = titleEl ? titleEl.textContent.trim() : '(unknown title)';
checkedTitles.add(title);
if (DRY_RUN) {
console.log(`[DRY RUN] Would remove: "${title}"`);
continue; // don't actually remove, keep scanning for more matches
}
console.log(`Removing: "${title}"`);
const ok = await removeRow(target);
if (ok) removedCount++;
await sleep(CLICK_DELAY);
}
console.log(
DRY_RUN
? 'Dry run complete. Set DRY_RUN = false and re-run to actually remove these.'
: `Done. Removed ${removedCount} watched video(s).`
);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment