// ==UserScript==
// @name YouTube downloader
// @icon https://raw.githubusercontent.com/madkarmaa/youtube-downloader/main/images/icon.png
// @namespace aGkgdGhlcmUgOik=
// @source https://github.com/madkarmaa/youtube-downloader
// @supportURL https://github.com/madkarmaa/youtube-downloader
// @version 1.5.0
// @description A simple userscript to download YouTube videos in MAX QUALITY
// @author mk_
// @match *://*.youtube.com/*
// @connect co.wuk.sh
// @connect raw.githubusercontent.com
// @grant GM_addStyle
// @grant GM.xmlHttpRequest
// @grant GM.xmlhttpRequest
// @run-at document-end
// ==/UserScript==
(async () => {
'use strict';
const randomNumber = Math.floor(Math.random() * Date.now());
const buttonId = `yt-downloader-btn-${randomNumber}`;
let oldLog = console.log;
/**
* Custom logging function copied from `console.log`
* @param {...any} args `console.log` arguments
* @returns {void}
*/
const logger = (...args) => oldLog.apply(console, ['\x1b[31m[YT Downloader >> INFO]\x1b[0m', ...args]);
GM_addStyle(`
#${buttonId}.YOUTUBE > svg {
margin-top: 3px;
margin-bottom: -3px;
}
#${buttonId}.SHORTS > svg {
margin-left: 3px;
}
#${buttonId}:hover > svg {
fill: #f00;
}
#yt-downloader-notification-${randomNumber} {
background-color: #282828;
color: #fff;
border: 2px solid #fff;
border-radius: 8px;
position: fixed;
top: 0;
right: 0;
margin-top: 10px;
margin-right: 10px;
padding: 15px;
z-index: 999;
}
#yt-downloader-notification-${randomNumber} > h3 {
color: #f00;
font-size: 2.5rem;
}
#yt-downloader-notification-${randomNumber} > span {
font-style: italic;
font-size: 1.5rem;
}
#yt-downloader-notification-${randomNumber} > button {
position: absolute;
top: 0;
right: 0;
background: none;
border: none;
outline: none;
width: fit-content;
height: fit-content;
margin: 5px;
padding: 0;
}
#yt-downloader-notification-${randomNumber} > button > svg {
fill: #fff;
}
`);
function Cobalt(videoUrl, audioOnly = false) {
// Use Promise because GM.xmlHttpRequest is async and behaves differently with different userscript managers
return new Promise((resolve, reject) => {
// https://github.com/wukko/cobalt/blob/current/docs/api.md
GM.xmlHttpRequest({
method: 'POST',
url: 'https://co.wuk.sh/api/json',
headers: {
'Cache-Control': 'no-cache',
Accept: 'application/json',
'Content-Type': 'application/json',
},
data: JSON.stringify({
url: encodeURI(videoUrl), // video url
vQuality: 'max', // always max quality
filenamePattern: 'basic', // file name = video title
isAudioOnly: audioOnly,
disableMetadata: true, // privacy
}),
onload: (response) => {
const data = JSON.parse(response.responseText);
if (data?.url) resolve(data.url);
else reject(data);
},
onerror: (err) => reject(err),
});
});
}
/**
* https://stackoverflow.com/a/61511955
* @param {String} selector The CSS selector used to select the element
* @returns {Promise<Element>} The selected element
*/
function waitForElement(selector) {
return new Promise((resolve) => {
if (document.querySelector(selector)) return resolve(document.querySelector(selector));
const observer = new MutationObserver(() => {
if (document.querySelector(selector)) {
observer.disconnect();
resolve(document.querySelector(selector));
}
});
observer.observe(document.body, { childList: true, subtree: true });
});
}
/**
* Append a notification element to the document
* @param {String} title The title of the message
* @param {String} message The message to display
* @returns {void}
*/
function notify(title, message) {
const notificationContainer = document.createElement('div');
notificationContainer.id = `yt-downloader-notification-${randomNumber}`;
const titleElement = document.createElement('h3');
titleElement.textContent = title;
const messageElement = document.createElement('span');
messageElement.textContent = message;
const closeButton = document.createElement('button');
closeButton.innerHTML =
'<svg xmlns="http://www.w3.org/2000/svg" height="1.5rem" viewBox="0 0 384 512"><path d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"/></svg>';
closeButton.addEventListener('click', () => {
notificationContainer.remove();
});
notificationContainer.append(titleElement, messageElement, closeButton);
document.body.appendChild(notificationContainer);
}
/**
* Throw an error after `sec` seconds
* @param {number} sec How long to wait before throwing an error (seconds)
* @returns {Promise<void>}
*/
function timeout(sec) {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('Request timed out after ' + sec + ' seconds');
}, sec * 1000);
});
}
/**
* Detect which YouTube service is being used
* @returns {"SHORTS" | "MUSIC" | "YOUTUBE" | null}
*/
function updateService() {
if (window.location.hostname === 'www.youtube.com' && window.location.pathname.startsWith('/shorts'))
return 'SHORTS';
else if (window.location.hostname === 'music.youtube.com') return 'MUSIC';
else if (window.location.hostname === 'www.youtube.com' && window.location.pathname.startsWith('/watch'))
return 'YOUTUBE';
else return null;
}
let YOUTUBE_SERVICE = updateService();
/**
* Renderer process
* @param {CustomEvent} event The YouTube custom navigation event
* @returns {Promise<void>}
*/
async function RENDERER(event) {
logger('Checking if user is watching');
// do nothing if the user isn't watching any media
if (!event?.detail?.endpoint?.watchEndpoint?.videoId && !event?.detail?.endpoint?.reelWatchEndpoint?.videoId) {
logger('User is not watching');
return;
}
logger('User is watching');
// wait for the button to copy to appear before continuing
logger('Waiting for the button to copy to appear');
let buttonToCopy;
switch (YOUTUBE_SERVICE) {
case 'YOUTUBE':
buttonToCopy = waitForElement(
'div#player div.ytp-chrome-controls div.ytp-right-controls button[aria-label="Settings"]'
);
break;
case 'MUSIC':
buttonToCopy = waitForElement(
'[slot="player-bar"] div.middle-controls div.middle-controls-buttons #like-button-renderer #button-shape-dislike button[aria-label="Dislike"]'
);
break;
case 'SHORTS':
buttonToCopy = waitForElement(
'div#actions.ytd-reel-player-overlay-renderer div#comments-button button'
);
break;
default:
break;
}
// cancel rendering after 5 seconds of the button not appearing in the document
buttonToCopy = await Promise.race([timeout(5), buttonToCopy]);
logger('Button to copy is:', buttonToCopy);
// create the download button
const downloadButton = document.createElement('button');
downloadButton.id = buttonId;
downloadButton.title = 'Click to download as video\nRight click to download as audio';
downloadButton.innerHTML =
'<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" height="24" viewBox="0 0 24 24" width="24" focusable="false" style="pointer-events: none; display: block; width: 100%; height: 100%;"><path d="M17 18v1H6v-1h11zm-.5-6.6-.7-.7-3.8 3.7V4h-1v10.4l-3.8-3.8-.7.7 5 5 5-4.9z"></path></svg>';
downloadButton.classList = buttonToCopy.classList;
if (YOUTUBE_SERVICE === 'YOUTUBE') downloadButton.classList.add('ytp-hd-quality-badge');
downloadButton.classList.add(YOUTUBE_SERVICE);
logger('Download button created:', downloadButton);
/**
* Left click => download video
* @returns {void}
*/
async function leftClick() {
if (!window.location.pathname.slice(1))
return notify('Hey!', 'The video/song player is not open, I cannot see the link to download!'); // do nothing if video is not focused
try {
window.open(await Cobalt(window.location.href), '_blank');
} catch (err) {
notify('An error occurred!', JSON.stringify(err));
}
}
/**
* Right click => download audio
* @param {Event} e The right click event
* @returns {void}
*/
async function rightClick(e) {
e.preventDefault();
if (!window.location.pathname.slice(1))
return notify('Hey!', 'The video/song player is not open, I cannot see the link to download!'); // do nothing if video is not focused
try {
window.open(await Cobalt(window.location.href, true), '_blank');
} catch (err) {
notify('An error occurred!', JSON.stringify(err));
}
return false;
}
downloadButton.addEventListener('click', leftClick);
downloadButton.addEventListener('contextmenu', rightClick);
logger('Event listeners added to the download button');
switch (YOUTUBE_SERVICE) {
case 'YOUTUBE':
logger('Waiting for the player buttons row to appear');
const YTButtonsRow = await waitForElement('div#player div.ytp-chrome-controls div.ytp-right-controls');
logger('Buttons row is now available');
if (!YTButtonsRow.querySelector('#' + buttonId))
YTButtonsRow.insertBefore(downloadButton, YTButtonsRow.firstChild);
logger('Download button added to the buttons row');
break;
case 'MUSIC':
logger('Waiting for the player buttons row to appear');
const YTMButtonsRow = await waitForElement(
'[slot="player-bar"] div.middle-controls div.middle-controls-buttons'
);
logger('Buttons row is now available');
if (!YTMButtonsRow.querySelector('#' + buttonId))
YTMButtonsRow.insertBefore(downloadButton, YTMButtonsRow.firstChild);
logger('Download button added to the buttons row');
break;
case 'SHORTS':
// wait for the first reel to load
logger('Waiting for the reels to load');
await waitForElement('div#actions.ytd-reel-player-overlay-renderer div#like-button');
logger('Reels loaded');
document.querySelectorAll('div#actions.ytd-reel-player-overlay-renderer').forEach((buttonsCol) => {
if (!buttonsCol.getAttribute('data-button-added') && !buttonsCol.querySelector(buttonId)) {
const dlButtonCopy = downloadButton.cloneNode(true);
dlButtonCopy.addEventListener('click', leftClick);
dlButtonCopy.addEventListener('contextmenu', rightClick);
buttonsCol.insertBefore(dlButtonCopy, buttonsCol.querySelector('div#like-button'));
buttonsCol.setAttribute('data-button-added', true);
}
});
logger('Download buttons added to reels');
break;
default:
break;
}
}
['yt-navigate', 'yt-navigate-finish'].forEach((evName) =>
document.addEventListener(evName, (e) => {
YOUTUBE_SERVICE = updateService();
logger('Service is:', YOUTUBE_SERVICE);
if (!YOUTUBE_SERVICE) return;
RENDERER(e);
})
);
})();