Skip to content

Instantly share code, notes, and snippets.

@vogler
Last active June 10, 2023 08:01
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 vogler/aeeb2078d2e30ea5aa0240c0320fc35b to your computer and use it in GitHub Desktop.
Save vogler/aeeb2078d2e30ea5aa0240c0320fc35b to your computer and use it in GitHub Desktop.
Tampermonkey: YouTube: button for Watch Later (shows status, allows to toggle)
// ==UserScript==
// @name YouTube: button for Watch Later (shows status, allows to toggle)
// @namespace https://gist.github.com/vogler
// @downloadURL https://gist.github.com/vogler/aeeb2078d2e30ea5aa0240c0320fc35b/raw/youtube-watch-later-status.tamper.js
// @version 0.3
// @description YouTube: button for Watch Later (shows status, allows to toggle)
// @author Ralf Vogler
// @ match https://www.youtube.com/watch?v=* // this will not work if you open youtube.com and then click on a video since it is a SPA
// @match https://www.youtube.com/*
// @grant window.onurlchange
// ==/UserScript==
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// wait until `selector` is found on `node` and return the result
const waitFor = (selector, node = document) => new Promise(resolve => {
const r = node.querySelector(selector);
if (r) return resolve(r);
const observer = new MutationObserver(mutations => {
const r = node.querySelector(selector);
if (r) {
resolve(r);
observer.disconnect();
}
});
observer.observe(node, {
childList: true,
subtree: true
});
});
// calculate SAPISIDHASH = SHA1(timestamp cookie.SAPISID origin)
// https://stackoverflow.com/questions/16907352/reverse-engineering-javascript-behind-google-button
// https://gist.github.com/eyecatchup/2d700122e24154fdc985b7071ec7764a
async function getSApiSidHash(SAPISID, origin) {
function sha1(str) {
return window.crypto.subtle.digest("SHA-1", new TextEncoder("utf-8").encode(str)).then(buf => {
return Array.prototype.map.call(new Uint8Array(buf), x=>(('00'+x.toString(16)).slice(-2))).join('');
});
}
const TIMESTAMP_MS = Date.now();
const digest = await sha1(`${TIMESTAMP_MS} ${SAPISID} ${origin}`);
return `${TIMESTAMP_MS}_${digest}`;
}
// context field needed for requests, removed most fields from original request body:
// .clickTracking
// .adSignalsInfo
// .user
// .request
// .context.request.consistencyTokenJars
// .context.client.deviceExperimentId
// .context.client.mainAppWebInfo and many others
const context = {
"client": {
"clientName": "WEB",
"clientVersion": "2.20230607.06.00",
},
};
const in_watch_later = async (id) => {
const body = JSON.stringify({
context,
"videoIds": [id],
"excludeWatchLater": false
});
// did not work: https://www.tampermonkey.net/documentation.php#api:GM_cookie.list
const SAPISID = document.cookie.split('SAPISID=')[1].split('; ')[0];
const SAPISIDHASH = await getSApiSidHash(SAPISID, 'https://www.youtube.com');
// console.log(SAPISID, SAPISIDHASH);
const response = await fetch("https://www.youtube.com/youtubei/v1/playlist/get_add_to_playlist", {
"headers": {
"accept": "*/*",
"authorization": "SAPISIDHASH " + SAPISIDHASH,
"cache-control": "no-cache",
"content-type": "application/json",
"pragma": "no-cache",
},
body,
"method": "POST",
"mode": "cors",
"credentials": "include"
});
const json = await response.json();
const playlists = json.contents[0].addToPlaylistRenderer.playlists;
const watch_later = playlists.filter(p => p.playlistAddToOptionRenderer.playlistId == 'WL')[0].playlistAddToOptionRenderer;
console.log('watch-later-status:', id, watch_later.containsSelectedVideos);
return watch_later.containsSelectedVideos == 'ALL';
};
const getId = () => (new URLSearchParams(document.location.search)).get('v');
(async function() {
'use strict';
// id of current video
let id = getId();
window.addEventListener('urlchange', _ => { const nid = getId(); console.log('urlchange:', id, '->', nid); id = nid; update(); });
// attach button that shows status and toggles it
const e = document.createElement('button');
const update = async () => {
const inwl = await in_watch_later(id);
// e.innerHTML = inwl ? '✅' : '🚫';
e.innerHTML = inwl ? '✔' : '⏱️'; // X
e.title = (inwl ? 'Remove from' : 'Add to') + ' Watch Later';
};
update(); // set initial state
e.style.width = '36px';
e.style.marginLeft = '10px';
e.style.border = 'none';
e.style.borderRadius = '20px';
e.style.cursor = 'pointer';
// toggle 'Watch Later' by clicking around the menus
e.onclick = async _ => {
const save_wl = document.querySelector('yt-formatted-string[title="Watch later"]');
if (save_wl) save_wl.click(); // only found after '... > Save' was clicked and ytd-add-to-playlist-renderer is loaded
else {
document.querySelector('.ytd-watch-metadata button.yt-spec-button-shape-next[aria-label="More actions"]').click(); // ... menu
await waitFor('ytd-menu-popup-renderer');
[...document.querySelectorAll('yt-formatted-string.ytd-menu-service-item-renderer')].filter(e => e.innerText == 'Save')[0].click(); // Save
await waitFor('ytd-add-to-playlist-renderer');
document.querySelector('yt-formatted-string[title="Watch later"]').click(); // Watch Later
document.querySelector('#close-button').click();
}
await delay(3000);
update();
};
(await waitFor('#segmented-buttons-wrapper')).appendChild(e);
})();
// Could also use requests to add/remove from Watch Later playlist (instead of clicking), but then we wouldn't get the little confirmation message at the bottom
// Ideally we'd just call the function that's attached to the button, but it's hard to find out how to call it.
// https://www.youtube.com/youtubei/v1/browse/edit_playlist
const req_add = {
"context": {
"client": { /*...*/ }
},
"actions": [
{
"addedVideoId": "Q06gylxS3-I",
"action": "ACTION_ADD_VIDEO"
}
],
"playlistId": "WL"
};
const req_remove = {
"context": {
"client": { /*...*/ }
},
"actions": [
{
"action": "ACTION_REMOVE_VIDEO_BY_VIDEO_ID",
"removedVideoId": "Q06gylxS3-I"
}
],
"playlistId": "WL"
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment