Last active
June 10, 2023 08:01
-
-
Save vogler/aeeb2078d2e30ea5aa0240c0320fc35b to your computer and use it in GitHub Desktop.
Tampermonkey: YouTube: button for Watch Later (shows status, allows to toggle)
This file contains hidden or 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: 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