Skip to content

Instantly share code, notes, and snippets.

@js6pak
Last active November 26, 2024 22:18
Show Gist options
  • Save js6pak/33bdefdefac09c387f55d08c5b9526fa to your computer and use it in GitHub Desktop.
Save js6pak/33bdefdefac09c387f55d08c5b9526fa to your computer and use it in GitHub Desktop.
// ==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();
});
@LosAlamosAl
Copy link

Not working. Illegal return at if (!app) return;

I'm running Chrome version 120.0.6099.71 (Official Build) (arm64) on MacOS 14.2

@js6pak
Copy link
Author

js6pak commented Jan 8, 2024

@LosAlamosAl It still works just fine for me on Brave + Violentmonkey.
What are you using to run the script?
Did this ever work for you before? If so then there might be some YouTube A/B testing breaking it.

@LosAlamosAl
Copy link

LosAlamosAl commented Jan 9, 2024

@js6pak First, my bad. I was trying to run this from the Javascript console in Chrome. I had not heard of user scripts. Your Violentmonkey reference turned me on to these. Thanks! Installed the script into Chrome using Violentmonkey. I run either cleanse or delete and it stops after less than 100 videos. I continue to see "Removed from watch later" pop up, but displayed count and thumbnails cease to update. I revisit page and see that deletion had indeed stopped.

Anyway, thanks for responding and enlightening me on user scripts. My ire rests entirely with Google and YouTube. I may try another browser. If I get it working, I'll post here. Thanks for the rapid reply.

@SbMan1
Copy link

SbMan1 commented Apr 26, 2024

I just had over 4,800 Watch Later in my playlist and was wondering if there was a way to what jackcarey did from July 23, 2023. "I modified this to also remove videos published over X days old, and those that are private or deleted. This currently relies on the language though (English). It could be updated to look for 'no_thumbnail' in the thumbnail source though". This looks very nice and I've also never had a script extension in my browser before either. I'll just do Date Added ( Oldest ) to make it the easiest, if I delete ones I don't want then oh well. Thanks for your time.

@DogSledder
Copy link

DogSledder commented Oct 9, 2024

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment