Last active
March 3, 2025 17:18
-
-
Save js6pak/33bdefdefac09c387f55d08c5b9526fa to your computer and use it in GitHub Desktop.
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 Cleanser | |
// @version 2.1.0 | |
// @description Removes watched videos from playlist either by %watched or all | |
// @author js6pak | |
// @include http*://*.youtube.com/* | |
// @include http*://youtube.com/* | |
// @run-at document-idle | |
// @homepageURL https://gist.github.com/js6pak/33bdefdefac09c387f55d08c5b9526fa | |
// @downloadURL https://gist.github.com/js6pak/33bdefdefac09c387f55d08c5b9526fa/raw/youtube-playlist-cleanser.user.js | |
// ==/UserScript== | |
const config = { | |
// Delete if watched more or equal to % of video | |
threshold: 80, | |
// Delay between delete requests (seems to need to be quite high for consistent results) | |
delay: 600, | |
}; | |
const app = document.querySelector("ytd-app"); | |
if (!app) return; | |
const sleep = (timeout) => new Promise((res) => setTimeout(res, timeout)); | |
function waitForElement(selector) { | |
return new Promise((resolve) => { | |
if (document.querySelector(selector)) { | |
return resolve(document.querySelector(selector)); | |
} | |
const observer = new MutationObserver(() => { | |
if (document.querySelector(selector)) { | |
resolve(document.querySelector(selector)); | |
observer.disconnect(); | |
} | |
}); | |
observer.observe(app, { | |
childList: true, | |
subtree: true, | |
}); | |
}); | |
} | |
function createButtons(menu) { | |
const cleanseButton = document.createElement("button"); | |
{ | |
cleanseButton.textContent = "Cleanse"; | |
cleanseButton.style.padding = "10px"; | |
cleanseButton.style.backgroundColor = "#181717"; | |
cleanseButton.style.color = "white"; | |
cleanseButton.style.textAlign = "center"; | |
cleanseButton.style.fontSize = "14px"; | |
cleanseButton.style.border = "0"; | |
cleanseButton.style.cursor = "pointer"; | |
cleanseButton.style.fontFamily = "Roboto, Arial, sans-serif"; | |
cleanseButton.style.borderRadius = "2px"; | |
cleanseButton.style.marginRight = "10px"; | |
cleanseButton.addEventListener("click", function () { | |
cleanse(); | |
}); | |
} | |
const deleteAllButton = document.createElement("button"); | |
{ | |
deleteAllButton.textContent = "Delete all"; | |
deleteAllButton.style.padding = "10px"; | |
deleteAllButton.style.backgroundColor = "#ff0000"; | |
deleteAllButton.style.color = "white"; | |
deleteAllButton.style.textAlign = "center"; | |
deleteAllButton.style.fontSize = "14px"; | |
deleteAllButton.style.border = "0"; | |
deleteAllButton.style.cursor = "pointer"; | |
deleteAllButton.style.fontFamily = "Roboto, Arial, sans-serif"; | |
deleteAllButton.style.marginRight = "10px"; | |
deleteAllButton.addEventListener("click", function () { | |
cleanse(true); | |
}); | |
} | |
menu.prepend(cleanseButton, deleteAllButton); | |
} | |
function* getVideos() { | |
const videos = document.querySelectorAll("ytd-playlist-video-renderer"); | |
for (const video of videos) { | |
const title = video.querySelector("#video-title").innerText; | |
const progress = video.querySelector("ytd-thumbnail-overlay-resume-playback-renderer")?.data.percentDurationWatched ?? 0; | |
const menu = video.querySelector("ytd-menu-renderer"); | |
const menuButton = menu.querySelector("yt-icon-button#button"); | |
yield { | |
container: video, | |
title, | |
progress, | |
menu, | |
menuButton, | |
}; | |
} | |
} | |
async function deleteVideo(video) { | |
video.menuButton.click(); | |
const popup = await waitForElement("ytd-menu-popup-renderer"); | |
Array.from(popup.querySelectorAll("ytd-menu-service-item-renderer")) | |
.find((x) => x.icon === "DELETE") | |
.click(); | |
await sleep(config.delay); | |
} | |
async function cleanse(deleteAll = false) { | |
console.log("Cleansing..."); | |
let deletedCount = 0; | |
for (const video of getVideos()) { | |
console.log(` ${video.title} (${video.progress}%)`); | |
if (deleteAll || video.progress >= config.threshold) { | |
console.log(" Deleting..."); | |
await deleteVideo(video); | |
deletedCount++; | |
} else { | |
console.log(" Skipping because its under threshold"); | |
} | |
} | |
console.log(`Done! Deleted ${deletedCount} videos`); | |
} | |
waitForElement("ytd-playlist-header-renderer ytd-menu-renderer").then((menu) => { | |
createButtons(menu); | |
}); | |
function createQuickDeleteButtons() { | |
for (const video of getVideos()) { | |
const quickDeleteButton = document.createElement("yt-icon-button"); | |
quickDeleteButton.className = "style-scope ytd-menu-renderer"; | |
quickDeleteButton.setAttribute("style-target", "button"); | |
quickDeleteButton.style.marginRight = "10px"; | |
video.menu.insertBefore(quickDeleteButton, video.menuButton); | |
const deleteIcon = document.createElement("yt-icon"); | |
deleteIcon.className = "style-scope ytd-menu-renderer"; | |
deleteIcon.icon = "DELETE"; | |
deleteIcon.style.color = "#F44336"; | |
quickDeleteButton.querySelector("#button").appendChild(deleteIcon); | |
quickDeleteButton.addEventListener("click", function () { | |
console.log("Quick deleting " + video.title); | |
deleteVideo(video); | |
}); | |
} | |
} | |
waitForElement("ytd-playlist-video-renderer ytd-menu-renderer").then(() => { | |
createQuickDeleteButtons(); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This script quit working for me recently. I no longer had the Delete and Cleanse buttons appearing on my playlists.
I'm not a JavaScript programmer, but I took a stab at the code. I modified line 142 as follows:
original:
waitForElement("ytd-playlist-header-renderer ytd-menu-renderer").then((menu) => {
changed to:
waitForElement("ytd-item-section-renderer ytd-menu-renderer").then((menu) => {
Now the buttons appeared in the first item displayed, but they also appeared anywhere there was a list of YouTube videos, so I also changed the include scope comments at the top of the code, lines 6 and 7, as follows:
original:
// @include http*://*.youtube.com/*
// @include http*://youtube.com/*
changed to:
// @include http*://*.youtube.com/playlist*
// @include http*://youtube.com/playlist*
I'll leave it to someone more experienced in JavaScript than I am to do a proper fix if necessary.