Last active
July 20, 2026 04:31
-
-
Save ashleyconnor/9901905ad06c408da5fd1d04d1da214e to your computer and use it in GitHub Desktop.
Remove watched videos from a YouTube playlist
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
| /** | |
| * 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