// ==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 3.0.3 // @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_info // @grant GM_addStyle // @grant GM_xmlHttpRequest // @grant GM_xmlhttpRequest // @run-at document-start // @downloadURL none // ==/UserScript== (async () => { 'use strict'; // abort if not on youtube or youtube music if (!detectYoutubeService()) { console.log('\x1b[31m[YTDL]\x1b[0m Invalid YouTube service, aborting...'); return; } // ===== VARIABLES ===== let DEV_MODE = String(localStorage.getItem('ytdl-dev-mode')).toLowerCase() === 'true'; let SHOW_NOTIFICATIONS = localStorage.getItem('ytdl-notif-enabled') === null ? true : String(localStorage.getItem('ytdl-notif-enabled')).toLowerCase() === 'true'; let oldILog = console.log; let oldWLog = console.warn; let oldELog = console.error; let VIDEO_DATA = { video_duration: null, video_url: null, video_author: null, video_title: null, video_id: null, }; let videoDataReady = false; // ===== END VARIABLES ===== // ===== METHODS ===== function logger(level, ...args) { if (DEV_MODE && level.toLowerCase() === 'info') oldILog.apply(console, ['%c[YTDL]', 'color: #f00;', ...args]); else if (DEV_MODE && level.toLowerCase() === 'warn') oldWLog.apply(console, ['%c[YTDL]', 'color: #f00;', ...args]); else if (level.toLowerCase() === 'error') oldELog.apply(console, ['%c[YTDL]', 'color: #f00;', ...args]); } function Cobalt(videoUrl, audioOnly = false) { // Use Promise because GM.xmlHttpRequest 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 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 }); }); } function fetchNotifications() { // Use Promise because GM.xmlHttpRequest behaves differently with different userscript managers return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: 'https://raw.githubusercontent.com/madkarmaa/youtube-downloader/main/notifications.json', headers: { 'Cache-Control': 'no-cache', Accept: 'application/json', 'Content-Type': 'application/json', }, onload: (response) => { const data = JSON.parse(response.responseText); if (data?.length) resolve(data); else reject(data); }, onerror: (err) => reject(err), }); }); } class Notification { constructor(title, body, uuid, storeUUID = true) { const notification = document.createElement('div'); notification.classList.add('ytdl-notification', 'opened', uuid); hideOnAnimationEnd(notification, 'closeNotif', true); const nTitle = document.createElement('h2'); nTitle.textContent = title; notification.appendChild(nTitle); const nBody = document.createElement('div'); body.split('\n').forEach((text) => { const paragraph = document.createElement('p'); paragraph.textContent = text; nBody.appendChild(paragraph); }); notification.appendChild(nBody); const nDismissButton = document.createElement('button'); nDismissButton.textContent = 'Dismiss'; nDismissButton.addEventListener('click', () => { if (storeUUID) { const localNotificationsHashes = JSON.parse(localStorage.getItem('ytdl-notifications') ?? '[]'); localNotificationsHashes.push(uuid); localStorage.setItem('ytdl-notifications', JSON.stringify(localNotificationsHashes)); logger('info', `Notification ${uuid} set as read`); } notification.classList.remove('opened'); notification.classList.add('closed'); }); notification.appendChild(nDismissButton); document.body.appendChild(notification); logger('info', 'New notification displayed', notification); } } async function manageNotifications() { if (!SHOW_NOTIFICATIONS) { logger('info', 'Notifications disabled by the user'); return; } const localNotificationsHashes = JSON.parse(localStorage.getItem('ytdl-notifications')) ?? []; logger('info', 'Local read notifications hashes\n\n', localNotificationsHashes); const onlineNotifications = await fetchNotifications(); logger( 'info', 'Online notifications hashes\n\n', onlineNotifications.map((n) => n.uuid) ); const unreadNotifications = onlineNotifications.filter((n) => !localNotificationsHashes.includes(n.uuid)); logger( 'info', 'Unread notifications hashes\n\n', unreadNotifications.map((n) => n.uuid) ); unreadNotifications.reverse().forEach((n) => { new Notification(n.title, n.body, n.uuid); }); } async function updateVideoData(e) { videoDataReady = false; const temp_video_data = e.detail?.getVideoData(); VIDEO_DATA.video_duration = e.detail?.getDuration(); VIDEO_DATA.video_url = e.detail?.getVideoUrl(); VIDEO_DATA.video_author = temp_video_data?.author; VIDEO_DATA.video_title = temp_video_data?.title; VIDEO_DATA.video_id = temp_video_data?.video_id; videoDataReady = true; logger('info', 'Video data updated\n\n', VIDEO_DATA); } async function hookPlayerEvent(...fns) { document.addEventListener('yt-player-updated', (e) => { for (let i = 0; i < fns.length; i++) fns[i](e); }); logger( 'info', 'Video player event hooked. Callbacks:\n\n', fns.map((f) => f.name) ); } async function hookNavigationEvents(...fns) { ['yt-navigate', 'yt-navigate-finish', 'yt-navigate-finish', 'yt-page-data-updated'].forEach((evName) => { document.addEventListener(evName, (e) => { for (let i = 0; i < fns.length; i++) fns[i](e); }); }); logger( 'info', 'Navigation events hooked. Callbacks:\n\n', fns.map((f) => f.name) ); } function hideOnAnimationEnd(target, animationName, alsoRemove = false) { target.addEventListener('animationend', (e) => { if (e.animationName === animationName) { if (alsoRemove) e.target.remove(); else e.target.style.display = 'none'; } }); } // https://stackoverflow.com/a/10344293 function isTyping() { const el = document.activeElement; return ( el && ((el.tagName.toLowerCase() === 'input' && el.type === 'text') || el.tagName.toLowerCase() === 'textarea' || String(el.getAttribute('contenteditable')).toLowerCase() === 'true') ); } async function appendSideMenu() { const sideMenu = document.createElement('div'); sideMenu.id = 'ytdl-sideMenu'; sideMenu.classList.add('closed'); sideMenu.style.display = 'none'; hideOnAnimationEnd(sideMenu, 'closeMenu'); const sideMenuHeader = document.createElement('h2'); sideMenuHeader.textContent = 'Youtube downloader settings'; sideMenuHeader.classList.add('header'); sideMenu.appendChild(sideMenuHeader); // ===== templates, don't use, just clone the node ===== const sideMenuSettingContainer = document.createElement('div'); sideMenuSettingContainer.classList.add('setting-row'); const sideMenuSettingLabel = document.createElement('h3'); sideMenuSettingLabel.classList.add('setting-label'); const sideMenuSettingDescription = document.createElement('p'); sideMenuSettingDescription.classList.add('setting-description'); sideMenuSettingContainer.append(sideMenuSettingLabel, sideMenuSettingDescription); const switchContainer = document.createElement('span'); switchContainer.classList.add('ytdl-switch'); const switchCheckbox = document.createElement('input'); switchCheckbox.type = 'checkbox'; const switchLabel = document.createElement('label'); switchContainer.append(switchCheckbox, switchLabel); // ===== end templates ===== const notifContainer = sideMenuSettingContainer.cloneNode(true); notifContainer.querySelector('.setting-label').textContent = 'Notifications'; notifContainer.querySelector('.setting-description').textContent = "Disable if you don't want to receive notifications from the developer."; const notifSwitch = switchContainer.cloneNode(true); notifSwitch.querySelector('input').checked = SHOW_NOTIFICATIONS; notifSwitch.querySelector('input').id = 'ytdl-notif-switch'; notifSwitch.querySelector('label').setAttribute('for', 'ytdl-notif-switch'); notifSwitch.querySelector('input').addEventListener('change', (e) => { SHOW_NOTIFICATIONS = e.target.checked; localStorage.setItem('ytdl-notif-enabled', SHOW_NOTIFICATIONS); logger('info', `Notifications ${SHOW_NOTIFICATIONS ? 'enabled' : 'disabled'}`); }); notifContainer.appendChild(notifSwitch); sideMenu.appendChild(notifContainer); const devModeContainer = sideMenuSettingContainer.cloneNode(true); devModeContainer.querySelector('.setting-label').textContent = 'Developer mode'; devModeContainer.querySelector('.setting-description').textContent = "Show a detailed output of what's happening under the hood in the console."; const devModeSwitch = switchContainer.cloneNode(true); devModeSwitch.querySelector('input').checked = DEV_MODE; devModeSwitch.querySelector('input').id = 'ytdl-dev-mode-switch'; devModeSwitch.querySelector('label').setAttribute('for', 'ytdl-dev-mode-switch'); devModeSwitch.querySelector('input').addEventListener('change', (e) => { DEV_MODE = e.target.checked; localStorage.setItem('ytdl-dev-mode', DEV_MODE); // always use console.log here to show output console.log(`\x1b[31m[YTDL]\x1b[0m Developer mode ${DEV_MODE ? 'enabled' : 'disabled'}`); }); devModeContainer.appendChild(devModeSwitch); sideMenu.appendChild(devModeContainer); document.addEventListener('mousedown', (e) => { if (sideMenu.style.display !== 'none' && !sideMenu.contains(e.target)) { sideMenu.classList.remove('opened'); sideMenu.classList.add('closed'); logger('info', 'Side menu closed'); } }); document.addEventListener('keydown', (e) => { if (e.key !== 'p') return; if (isTyping()) return; if (sideMenu.style.display === 'none') { sideMenu.style.top = window.scrollY + 'px'; sideMenu.style.display = 'flex'; sideMenu.classList.remove('closed'); sideMenu.classList.add('opened'); logger('info', 'Side menu opened'); } else { sideMenu.classList.remove('opened'); sideMenu.classList.add('closed'); logger('info', 'Side menu closed'); } }); window.addEventListener('scroll', () => { if (sideMenu.classList.contains('closed')) return; sideMenu.classList.remove('opened'); sideMenu.classList.add('closed'); logger('info', 'Side menu closed'); }); document.body.appendChild(sideMenu); logger('info', 'Side menu created\n\n', sideMenu); } function detectYoutubeService() { if (window.location.hostname === 'www.youtube.com' && window.location.pathname.startsWith('/shorts')) return 'SHORTS'; if (window.location.hostname === 'www.youtube.com' && window.location.pathname.startsWith('/watch')) return 'WATCH'; else if (window.location.hostname === 'music.youtube.com') return 'MUSIC'; else if (window.location.hostname === 'www.youtube.com') return 'YOUTUBE'; else return null; } function elementInContainer(container, element) { return container.contains(element); } async function leftClick() { const isYtMusic = detectYoutubeService() === 'MUSIC'; if (!isYtMusic && !videoDataReady) { logger('warn', 'Video data not ready'); new Notification('Wait!', 'The video data is not ready yet, try again in a few seconds.', 'popup', false); return; } else if (isYtMusic && !window.location.pathname.startsWith('/watch')) { logger('warn', 'Video URL not avaiable'); new Notification( 'Wait!', 'Open the music player so the song link is visible, then try again.', 'popup', false ); return; } try { logger('info', 'Download started'); window.open( await Cobalt( isYtMusic ? window.location.href.replace('music.youtube.com', 'www.youtube.com') : VIDEO_DATA.video_url ), '_blank' ); logger('info', 'Download completed'); } catch (err) { logger('error', JSON.parse(JSON.stringify(err))); new Notification('Error', JSON.stringify(err), 'error', false); } } async function rightClick(e) { const isYtMusic = detectYoutubeService() === 'MUSIC'; e.preventDefault(); if (!isYtMusic && !videoDataReady) { logger('warn', 'Video data not ready'); new Notification('Wait!', 'The video data is not ready yet, try again in a few seconds.', 'popup', false); return false; } else if (isYtMusic && !window.location.pathname.startsWith('/watch')) { logger('warn', 'Video URL not avaiable'); new Notification( 'Wait!', 'Open the music player so the song link is visible, then try again.', 'popup', false ); return; } try { logger('info', 'Download started'); window.open( await Cobalt( isYtMusic ? window.location.href.replace('music.youtube.com', 'www.youtube.com') : VIDEO_DATA.video_url, true ), '_blank' ); logger('info', 'Download completed'); } catch (err) { logger('error', JSON.parse(JSON.stringify(err))); new Notification('Error', JSON.stringify(err), 'error', false); } return false; } // https://www.30secondsofcode.org/js/s/element-is-visible-in-viewport/ function elementIsVisibleInViewport(el, partiallyVisible = false) { const { top, left, bottom, right } = el.getBoundingClientRect(); const { innerHeight, innerWidth } = window; return partiallyVisible ? ((top > 0 && top < innerHeight) || (bottom > 0 && bottom < innerHeight)) && ((left > 0 && left < innerWidth) || (right > 0 && right < innerWidth)) : top >= 0 && left >= 0 && bottom <= innerHeight && right <= innerWidth; } async function appendDownloadButton(e) { const ytContainerSelector = '#movie_player > div.ytp-chrome-bottom > div.ytp-chrome-controls > div.ytp-right-controls'; const ytmContainerSelector = '#layout > ytmusic-player-bar > div.middle-controls.style-scope.ytmusic-player-bar > div.middle-controls-buttons.style-scope.ytmusic-player-bar'; const ytsContainerSelector = '#actions.style-scope.ytd-reel-player-overlay-renderer'; // ===== templates, don't use, just clone the node ===== const downloadIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); downloadIcon.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); downloadIcon.setAttribute('fill', 'currentColor'); downloadIcon.setAttribute('height', '24'); downloadIcon.setAttribute('viewBox', '0 0 24 24'); downloadIcon.setAttribute('width', '24'); downloadIcon.setAttribute('focusable', 'false'); downloadIcon.style.pointerEvents = 'none'; downloadIcon.style.display = 'block'; downloadIcon.style.width = '100%'; downloadIcon.style.height = '100%'; const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('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'); downloadIcon.appendChild(path); const downloadButton = document.createElement('button'); downloadButton.id = 'ytdl-download-button'; downloadButton.classList.add('ytp-button'); downloadButton.title = 'Left click to download as video, right click as audio only'; downloadButton.appendChild(downloadIcon); // ===== end templates ===== switch (detectYoutubeService()) { case 'WATCH': const ytCont = await waitForElement(ytContainerSelector); logger('info', 'Download button container found\n\n', ytCont); if (elementInContainer(ytCont, ytCont.querySelector('#ytdl-download-button'))) { logger('warn', 'Download button already in container'); break; } const ytDlBtnClone = downloadButton.cloneNode(true); ytDlBtnClone.classList.add('YT'); ytDlBtnClone.addEventListener('click', leftClick); ytDlBtnClone.addEventListener('contextmenu', rightClick); logger('info', 'Download button created\n\n', ytDlBtnClone); ytCont.insertBefore(ytDlBtnClone, ytCont.firstChild); logger('info', 'Download button inserted in container'); break; case 'MUSIC': const ytmCont = await waitForElement(ytmContainerSelector); logger('info', 'Download button container found\n\n', ytmCont); if (elementInContainer(ytmCont, ytmCont.querySelector('#ytdl-download-button'))) { logger('warn', 'Download button already in container'); break; } const ytmDlBtnClone = downloadButton.cloneNode(true); ytmDlBtnClone.classList.add('YTM'); ytmDlBtnClone.addEventListener('click', leftClick); ytmDlBtnClone.addEventListener('contextmenu', rightClick); logger('info', 'Download button created\n\n', ytmDlBtnClone); ytmCont.insertBefore(ytmDlBtnClone, ytmCont.firstChild); logger('info', 'Download button inserted in container'); break; case 'SHORTS': if (e.type !== 'yt-navigate-finish') return; await waitForElement(ytsContainerSelector); // wait for the UI to finish loading const visibleYtsConts = Array.from(document.querySelectorAll(ytsContainerSelector)).filter((el) => elementIsVisibleInViewport(el) ); logger('info', 'Download button containers found\n\n', visibleYtsConts); visibleYtsConts.forEach((ytsCont) => { if (elementInContainer(ytsCont, ytsCont.querySelector('#ytdl-download-button'))) { logger('warn', 'Download button already in container'); return; } const ytsDlBtnClone = downloadButton.cloneNode(true); ytsDlBtnClone.classList.add( 'YTS', 'yt-spec-button-shape-next', 'yt-spec-button-shape-next--tonal', 'yt-spec-button-shape-next--mono', 'yt-spec-button-shape-next--size-l', 'yt-spec-button-shape-next--icon-button' ); ytsDlBtnClone.addEventListener('click', leftClick); ytsDlBtnClone.addEventListener('contextmenu', rightClick); logger('info', 'Download button created\n\n', ytsDlBtnClone); ytsCont.insertBefore(ytsDlBtnClone, ytsCont.firstChild); logger('info', 'Download button inserted in container'); }); break; default: return; } } async function devStuff() { if (!DEV_MODE) return; logger('info', 'Current service is: ' + detectYoutubeService()); } // ===== END METHODS ===== GM_addStyle(` #ytdl-sideMenu { min-height: 100vh; z-index: 9998; position: absolute; top: 0; left: -100vw; width: 50vw; background-color: var(--yt-spec-base-background); border-right: 2px solid var(--yt-spec-static-grey); display: flex; flex-direction: column; gap: 2rem; padding: 2rem 2.5rem; font-family: "Roboto", "Arial", sans-serif; } #ytdl-sideMenu.opened { animation: openMenu .3s linear forwards; } #ytdl-sideMenu.closed { animation: closeMenu .3s linear forwards; } #ytdl-sideMenu .header { text-align: center; font-size: 2.5rem; color: var(--yt-brand-youtube-red); } #ytdl-sideMenu .setting-row { display: flex; flex-direction: column; gap: 1rem; } #ytdl-sideMenu .setting-label { font-size: 1.8rem; color: var(--yt-brand-youtube-red); } #ytdl-sideMenu .setting-description { font-size: 1.4rem; color: var(--yt-spec-text-primary); } .ytdl-switch { display: inline-block; } .ytdl-switch input { display: none; } .ytdl-switch label { display: block; width: 50px; height: 19.5px; padding: 3px; border-radius: 15px; border: 2px solid var(--yt-spec-inverted-background); cursor: pointer; transition: 0.3s; } .ytdl-switch label::after { content: ""; display: inherit; width: 20px; height: 20px; border-radius: 12px; background: var(--yt-spec-inverted-background); transition: 0.3s; } .ytdl-switch input:checked ~ label { border-color: var(--yt-spec-themed-green); } .ytdl-switch input:checked ~ label::after { translate: 30px 0; background: var(--yt-spec-themed-green); } .ytdl-switch input:disabled ~ label { opacity: 0.5; cursor: not-allowed; } .ytdl-notification { display: flex; flex-direction: column; gap: 2rem; position: fixed; top: 50vh; left: 50vw; transform: translate(-50%, -50%); background-color: var(--yt-spec-base-background); border: 2px solid var(--yt-spec-static-grey); border-radius: 8px; color: var(--yt-spec-text-primary); z-index: 9999; padding: 1.5rem 1.6rem; font-family: "Roboto", "Arial", sans-serif; font-size: 1.4rem; width: fit-content; height: fit-content; max-width: 40vw; max-height: 50vh; word-wrap: break-word; line-height: var(--yt-caption-line-height); } .ytdl-notification.opened { animation: openNotif .3s linear forwards; } .ytdl-notification.closed { animation: closeNotif .3s linear forwards; } .ytdl-notification h2 { color: var(--yt-brand-youtube-red); } .ytdl-notification > div { display: flex; flex-direction: column; gap: 1rem; } .ytdl-notification > button { transition: all 0.2s ease-in-out; cursor: pointer; border: 2px solid var(--yt-spec-static-grey); border-radius: 8px; background-color: var(--yt-brand-medium-red); padding: 0.7rem 0.8rem; color: #fff; font-weight: 600; } .ytdl-notification button:hover { background-color: var(--yt-spec-red-70); } #ytdl-download-button { background: none; border: none; outline: none; color: var(--yt-spec-text-primary); cursor: pointer; transition: color 0.2s ease-in-out; display: inline-flex; justify-content: center; align-items: center; } #ytdl-download-button:hover { color: var(--yt-brand-youtube-red); } #ytdl-download-button.YTM { transform: scale(1.5); margin: 0 1rem; } #ytdl-download-button > svg { transform: translateX(5%); } @keyframes openMenu { 0% { left: -100vw; } 100% { left: 0; } } @keyframes closeMenu { 0% { left: 0; } 100% { left: -100vw; } } @keyframes openNotif { 0% { opacity: 0; } 100% { opacity: 1; } } @keyframes closeNotif { 0% { opacity: 1; } 100% { opacity: 0; } } `); logger('info', 'Custom styles added'); hookPlayerEvent(updateVideoData); hookNavigationEvents(appendDownloadButton, devStuff); // functions that require the DOM to exist window.addEventListener('DOMContentLoaded', () => { appendSideMenu(); appendDownloadButton(); manageNotifications(); }); })();