Last active
November 22, 2023 12:35
-
-
Save jackcarey/ab8b23ebba87f3cf8d0ac4a2fa45eefc to your computer and use it in GitHub Desktop.
YouTube Playlist Cleanser UserScript
This file contains 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.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(); | |
}); |
might need
day: 'Date'
to deal with videos with the relative date1 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
might need
day: 'Date'
to deal with videos with the relative date1 day ago
script works though - i need a 10000ms delay atm, why can't youtube give us the bare minimum :P