Skip to content

Instantly share code, notes, and snippets.

@jackcarey
Last active November 22, 2023 12:35
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jackcarey/ab8b23ebba87f3cf8d0ac4a2fa45eefc to your computer and use it in GitHub Desktop.
Save jackcarey/ab8b23ebba87f3cf8d0ac4a2fa45eefc to your computer and use it in GitHub Desktop.
YouTube Playlist Cleanser UserScript
// ==UserScript==
// @name Youtube Playlist Cleanser
// @version 2.3.0
// @description Removes watched videos from playlist either by %watched, date or all
// @author jackcarey / js6pak
// @match http*://*.youtube.com/*
// @match http*://youtube.com/*
// @run-at document-idle
// @homepageURL https://gist.github.com/jackcarey/ab8b23ebba87f3cf8d0ac4a2fa45eefc
// @downloadURL https://gist.github.com/jackcarey/ab8b23ebba87f3cf8d0ac4a2fa45eefc/raw/youtube-playlist-cleanser.user.js
// @updateURL https://gist.github.com/jackcarey/ab8b23ebba87f3cf8d0ac4a2fa45eefc/raw/youtube-playlist-cleanser.user.js
// ==/UserScript==
const config = {
// Delete if watched more or equal to % of video
threshold: 75,
//Delete if published more than this many days ago
published: 45,
//Delete videos if they are private or deleted
privateDeleted: true,
// Delay between delete requests (may need to be quite high for consistent results)
delay: 800,
// Randomize the delay +- upto this number of milliseconds to try to avoid YT disabling/debouncing the delete functionality
delayVariation: 250,
//add a random sleep of 1-5 seconds every so often
useRandomSleep: true
};
const app = document.querySelector("ytd-app");
if (!app) return;
const sleep = (timeout) => new Promise((res) => setTimeout(res, timeout));
const logError = (msg) => {console.log("🛑",msg)};
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");
const strictCleanseButton = document.createElement("button");
const deleteAllButton = document.createElement("button");
const commonStyles = {
padding: "10px",
color: "white",
textAlign: "center",
fontSize: "14px",
border: "0",
cursor: "pointer",
fontFamily: "Roboto, Arial, sans-serif",
borderRadius: "6px",
marginRight: "10px",
};
Object.entries(commonStyles).forEach(([k,v])=>{
cleanseButton.style[k] = v;
strictCleanseButton.style[k] = v;
deleteAllButton.style[k] = v;
});
cleanseButton.style.backgroundColor= "#ff0000";
strictCleanseButton.style.backgroundColor= "#ff0000";
deleteAllButton.style.backgroundColor = "#181717";
cleanseButton.textContent = `Cleanse ${config.threshold}% | ${config.published}d`;
strictCleanseButton.textContent = `Cleanse ${Math.ceil(config.threshold/2)}% | ${Math.ceil(config.published/2)}d`;
deleteAllButton.textContent = "Delete all";
cleanseButton.addEventListener("click", function () {
cleanse();
});
strictCleanseButton.addEventListener("click", function () {
cleanse(false,true);
});
deleteAllButton.addEventListener("click", function () {
cleanse(true);
});
menu.prepend(cleanseButton, strictCleanseButton, deleteAllButton);
}
function parseRelativeDate(relativeDate) {
const currentDate = new Date();
const re = new RegExp("[0-9]+\s\w+\sago","gmi");
const index = relativeDate.toLowerCase().search(re);
const dateStr = index>=0 ? relativeDate.substring(index) : relativeDate;
const dateParts = dateStr.trim().split(' ');
if (dateParts.length !== 3 || dateParts[2] !== 'ago') {
logError(`Invalid relative date format '${dateStr}' from '${relativeDate}'. Please use "X units ago" format, e.g., "3 weeks ago".`);
}
let amount = parseInt(dateParts[0]);
const unit = dateParts[1];
const unitToMethodMap = {
second: 'Seconds',
seconds: 'Seconds',
minute: 'Minutes',
minutes: 'Minutes',
hour: 'Hours',
hours: 'Hours',
day: 'Date',
days: 'Date',
week: 'Date',
weeks: 'Date',
month: 'Month',
months: 'Month',
year: 'FullYear',
years: 'FullYear',
};
if (isNaN(amount) || amount <= 0) {
logError('Invalid relative date amount. Please provide a positive numeric value.');
}
if (!unitToMethodMap.hasOwnProperty(unit)) {
logError(`Invalid relative date unit '${unit}'. Please use one of the following units: second,seconds, minute,minutes, hour,hours, day,days, week,weeks, month,months, year,years.`);
}
if (unit === 'weeks' || unit === 'week') {
amount *= 7; // Convert weeks to days
}
const method = unitToMethodMap[unit];
currentDate["set"+method](currentDate["get"+method]() - amount);
return currentDate;
}
function* getVideos() {
const videos = document.querySelectorAll("ytd-playlist-video-renderer");
for (const video of videos) {
const title = video.querySelector("#video-title").innerText.trim();
const progress = video.querySelector("ytd-thumbnail-overlay-resume-playback-renderer")?.data.percentDurationWatched ?? 0;
const publishedStr = video.querySelector("#video-info span:nth-child(3)")?.innerText ?? "";
const published = publishedStr ? parseRelativeDate(publishedStr.replace("Streamed","").trim()) : new Date();
const menu = video.querySelector("ytd-menu-renderer");
const menuButton = menu.querySelector("yt-icon-button#button");
yield {
container: video,
title,
publishedStr,
progress,
published,
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 + (Math.floor(Math.random()*config.delayVariation) * Math.random()<0.5?-1:-1));
}
function getDaysDifference(date1, date2 = null) {
// Get the time difference in milliseconds
date1 = date1 ?? new Date();
date2 = date2 ?? new Date();
const timeDifference = Math.abs(date1.getTime() - date2.getTime());
// Convert milliseconds to days
const oneDayInMilliseconds = 24 * 60 * 60 * 1000;
const daysDifference = Math.floor(timeDifference / oneDayInMilliseconds);
return daysDifference;
}
async function cleanse(deleteAll = false,strictMode=false) {
console.log("Cleansing...");
let deletedCount = 0;
const handleVideo= async (video)=>{
console.log(` ${video.title},${video.publishedStr} (${video.progress}%)`);
const overWatchThreshold = video.progress >= (strictMode?config.threshold/2:config.threshold);
const olderThanPublishedThreshold = video.published && config.published >= 1 && getDaysDifference(video.published) >= (strictMode?config.published/2:config.published);
const isPrivateDeleted = (strictMode||config?.privateDeleted) && (video?.title == "[Private video]" || video?.title =="[Deleted video]");
if (deleteAll || overWatchThreshold || olderThanPublishedThreshold || isPrivateDeleted) {
console.log(" - Deleting...");
await deleteVideo(video);
deletedCount++;
if(config?.useRandomSleep && Math.random()<0.1){
await sleep(1000+Math.floor(Math.random()*4000));
}
} else {
console.log(" - Skipping because its under threshold");
}
}
//check for videos three times
for(const i=0;i<3;++i){
for (const video of getVideos()) {
await handleVideo(video);
}
}
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();
});
@jnguyen1098
Copy link

might need day: 'Date' to deal with videos with the relative date 1 day ago

script works though - i need a 10000ms delay atm, why can't youtube give us the bare minimum :P

@jackcarey
Copy link
Author

might need day: 'Date' to deal with videos with the relative date 1 day ago

script works though - i need a 10000ms delay atm, why can't youtube give us the bare minimum :P

fixed, thanks

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