// ==UserScript==
// @name YT Watch Later Delete Enhancer
// @version 0.1
// @description Add a button to remove videos watched with more than X percent from watch later playlist.
// @author avallete
// @homepage https://github.com/avallete/yt-watch-later-delete-enhancer
// @support https://github.com/avallete/yt-watch-later-delete-enhancer/issues
// @require https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.26.0/babel.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/7.8.7/polyfill.min.js
// @grant none
// @include *//www.youtube.com/*
// @namespace https://greasyfork.org/fr/users/70224-avallete
// @noframes false
// @run-at document-idle
// @licence MIT
// ==/UserScript==
class GMScript {
constructor(ytcfgdata, twoColumnBrowseResultsRenderer) {
this.twoColumnBrowseResultsRenderer = twoColumnBrowseResultsRenderer;
this.ytcfgdata = ytcfgdata;
this.playlistVideos = [];
}
createUrlQueryString(queryDict) {
let qs = [];
for (const [key, value] of Object.entries(queryDict)) {
qs.push(`${encodeURI(key)}=${encodeURI(value)}`);
}
return qs.join('&');
}
JSON_to_URLEncoded(element,key,list){
let dlist = list || [];
if(typeof(element)=='object'){
for (let idx in element) {
this.JSON_to_URLEncoded(element[idx],key?key+'['+idx+']':idx,dlist);
}
} else {
dlist.push(key+'='+encodeURIComponent(element));
}
return dlist.join('&');
}
enableRemoveButton() {
const button = document.getElementById("removeVideosEnhancerButton");
if (button) {
button.disabled = false;
}
}
disableRemoveButton() {
const button = document.getElementById("removeVideosEnhancerButton");
if (button) {
button.disabled = true;
}
}
getContinuationUrl(continuationData) {
const {continuation} = continuationData;
return `https://www.youtube.com/browse_ajax?${this.createUrlQueryString({
ctoken: continuation,
continuation: continuation
})}`;
}
async getAllPlaylistVideos() {
let continuations = this.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].itemSectionRenderer.contents[0].playlistVideoListRenderer.continuations;
let playlistContent = this.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].itemSectionRenderer.contents[0].playlistVideoListRenderer.contents;
// If there is continuations, it mean that the playlist is not fully loaded,
// Request additional data until not futher videos to fetch
while (continuations && continuations.length > 0) {
let resp = await fetch(this.getContinuationUrl(continuations[0].nextContinuationData), {
"credentials": "include",
"headers": {
"X-YouTube-Client-Name": this.ytcfgdata["INNERTUBE_CONTEXT_CLIENT_NAME"],
"X-YouTube-Client-Version": this.ytcfgdata["INNERTUBE_CONTEXT_CLIENT_VERSION"],
"X-YouTube-Device": this.ytcfgdata["DEVICE"],
"X-Youtube-Identity-Token": this.ytcfgdata["ID_TOKEN"],
"X-YouTube-Page-CL": this.ytcfgdata["PAGE_CL"],
"X-YouTube-Page-Label": this.ytcfgdata["PAGE_BUILD_LABEL"],
"X-YouTube-Variants-Checksum": this.ytcfgdata["VARIANTS_CHECKSUM"],
},
"referrer": "https://www.youtube.com/playlist?list=WL",
"method": "GET",
"mode": "cors"
});
if (resp.status === 200) {
const respjson = await resp.json();
const data = respjson[1].response.continuationContents.playlistVideoListContinuation.contents;
playlistContent = playlistContent.concat(data);
continuations = respjson[1].response.continuationContents.playlistVideoListContinuation.continuations;
}
}
return playlistContent;
}
async removeVideosFromPlaylist(playlistId, videoIds) {
const urlparams = {
'sej': JSON.stringify({
"commandMetadata": {
"webCommandMetadata": {
"url": "/service_ajax",
"sendPost": true,
"apiUrl": "/youtubei/v1/browse/edit_playlist"
}
},
"playlistEditEndpoint": {
"playlistId": playlistId,
"actions": videoIds.map((vid) => ({"setVideoId": vid, "action": "ACTION_REMOVE_VIDEO"})),
"params": "CAE%3D",
"clientActions": [
{
"playlistRemoveVideosAction": {
"setVideoIds": videoIds.map((vid) => vid)
}
}
]
}
}),
'csn': this.ytcfgdata["client-screen-nonce"],
'session_token': this.ytcfgdata["XSRF_TOKEN"],
};
const params = {
"credentials": "include",
"headers": {
"Content-Type": "application/x-www-form-urlencoded",
"X-YouTube-Client-Name": this.ytcfgdata["INNERTUBE_CONTEXT_CLIENT_NAME"],
"X-YouTube-Client-Version": this.ytcfgdata["INNERTUBE_CONTEXT_CLIENT_VERSION"],
"X-YouTube-Device": this.ytcfgdata["DEVICE"],
"X-Youtube-Identity-Token": this.ytcfgdata["ID_TOKEN"],
"X-YouTube-Page-CL": this.ytcfgdata["PAGE_CL"],
"X-YouTube-Page-Label": this.ytcfgdata["PAGE_BUILD_LABEL"],
"X-YouTube-Variants-Checksum": this.ytcfgdata["VARIANTS_CHECKSUM"],
},
"referrer": "https://www.youtube.com/playlist?list=WL",
"body": this.JSON_to_URLEncoded(urlparams),
"method": "POST",
"mode": "cors"
};
const resp = await fetch("https://www.youtube.com/service_ajax?name=playlistEditEndpoint", params);
if (resp.status === 200) {
return await resp.json();
}
return false;
}
getVideosIdsToDelete(watchTimeValue, playlistVideos) {
const idsToDelete = playlistVideos
.filter(
({playlistVideoRenderer: { thumbnailOverlays: [ overlay, ] }}) => (
// If it's not the first element in array, the videos haven't been played yet
overlay.thumbnailOverlayResumePlaybackRenderer
&& overlay.thumbnailOverlayResumePlaybackRenderer.percentDurationWatched >= watchTimeValue
)
)
.map(({playlistVideoRenderer: {setVideoId: vid }}) => vid);
return idsToDelete;
}
async handleRemoveVideosClickedEvent(watchTimeValue) {
this.disableRemoveButton();
let idsToDelete = this.getVideosIdsToDelete(watchTimeValue, this.playlistVideos);
const respjson = await this.removeVideosFromPlaylist("WL", idsToDelete);
if (respjson.code === "SUCCESS") {
// TODO propagate the change directly to YT UI instead of reloading the all page
location.reload();
}
this.enableRemoveButton();
}
constructDOM() {
return document.createRange().createContextualFragment(`
<div id="yt-remove-video-enhancer-container" class="style-scope ytd-playlist-sidebar-renderer">
<div class="style-scope ytd-menu-service-item-renderer" role="option" tabindex="0" aria-disabled="false">
<p>Remove all videos who has been watched at more or equal X percent</p>
<input id="removeVideosEnhancerValue" type="number" min="0" max="100" value="99">
<button id="removeVideosEnhancerButton" disabled>Remove !</button>
</div>
</div>`
);
}
createEventsListeners(DOMFragment) {
const input = DOMFragment.getElementById("removeVideosEnhancerValue");
const button = DOMFragment.getElementById("removeVideosEnhancerButton");
button.addEventListener('click', () => this.handleRemoveVideosClickedEvent(input.value));
}
appendDOM(DOMFragment) {
const container = document.evaluate('//ytd-playlist-sidebar-renderer/div[@id="items"]', document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
container.appendChild(DOMFragment);
}
run() {
const domFragment = this.constructDOM();
this.createEventsListeners(domFragment);
this.appendDOM(domFragment);
this.disableRemoveButton();
this.getAllPlaylistVideos()
.then((playlistContent) => {
this.playlistVideos = playlistContent;
this.enableRemoveButton();
})
.catch(console.error);
}
}
function cleanupDOM() {
// Destroy every DOM elements created by the script
const extendedDOM = document.getElementById("yt-remove-video-enhancer-container");
if (extendedDOM) {
extendedDOM.parentNode.removeChild(extendedDOM);
}
}
// The following conditions and check are here to mitigate the "virtual" navigation of youtube
// Without this fix, Tampermonkey fail to load our script on youtube without a full page reload.
if (window.location.pathname === '/playlist' && window.location.search === '?list=WL') {
const script = new GMScript(window.ytcfg.data_, window.getPageData().data.response.contents.twoColumnBrowseResultsRenderer);
script.run();
}
history.pushState = ( f => function pushState(){
let ret = f.apply(this, arguments);
window.dispatchEvent(new Event('pushstate'));
window.dispatchEvent(new Event('locationchange'));
return ret;
})(history.pushState);
history.replaceState = ( f => function replaceState(){
let ret = f.apply(this, arguments);
window.dispatchEvent(new Event('replacestate'));
window.dispatchEvent(new Event('locationchange'));
return ret;
})(history.replaceState);
window.addEventListener('popstate',()=>{
window.dispatchEvent(new Event('locationchange'))
});
window.addEventListener('yt-navigate-finish',()=>{
window.dispatchEvent(new Event('locationchange'))
});
window.addEventListener('locationchange', function(){
if (window.location.pathname === '/playlist' && window.location.search === '?list=WL') {
const pagedata = window.getPageData(); // Prefetched initial datas present in the page
const ytcfgdata = window.ytcfg.data_; // configuration of youtube app containing auth tokens
const twoColumnBrowseResultsRenderer = pagedata.data.response.contents.twoColumnBrowseResultsRenderer; // 100 videos data from playlist loaded by youtube
const script = new GMScript(ytcfgdata, twoColumnBrowseResultsRenderer);
script.run();
} else {
cleanupDOM();
}
});