YouTube downloader

A simple userscript to download YouTube videos in MAX QUALITY

当前为 2024-02-02 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name YouTube downloader
  3. // @icon https://raw.githubusercontent.com/madkarmaa/youtube-downloader/main/images/icon.png
  4. // @namespace aGkgdGhlcmUgOik=
  5. // @source https://github.com/madkarmaa/youtube-downloader
  6. // @supportURL https://github.com/madkarmaa/youtube-downloader
  7. // @version 2.0.2
  8. // @description A simple userscript to download YouTube videos in MAX QUALITY
  9. // @author mk_
  10. // @match *://*.youtube.com/*
  11. // @connect co.wuk.sh
  12. // @connect raw.githubusercontent.com
  13. // @grant GM_addStyle
  14. // @grant GM_getValue
  15. // @grant GM_setValue
  16. // @grant GM_xmlHttpRequest
  17. // @grant GM_xmlhttpRequest
  18. // @run-at document-end
  19. // ==/UserScript==
  20.  
  21. (async () => {
  22. 'use strict';
  23.  
  24. const randomNumber = Math.floor(Math.random() * Date.now());
  25. const buttonId = `yt-downloader-btn-${randomNumber}`;
  26.  
  27. let oldLog = console.log;
  28. /**
  29. * Custom logging function copied from `console.log`
  30. * @param {...any} args `console.log` arguments
  31. * @returns {void}
  32. */
  33. const logger = (...args) => oldLog.apply(console, ['\x1b[31m[YT Downloader >> INFO]\x1b[0m', ...args]);
  34.  
  35. GM_addStyle(`
  36. @import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&display=swap')
  37.  
  38. #${buttonId}.YOUTUBE > svg {
  39. margin-top: 3px;
  40. margin-bottom: -3px;
  41. }
  42.  
  43. #${buttonId}.SHORTS > svg {
  44. margin-left: 3px;
  45. }
  46.  
  47. #${buttonId}:hover > svg {
  48. fill: #f00;
  49. }
  50.  
  51. #yt-downloader-notification-${randomNumber} {
  52. background-color: #282828;
  53. color: #fff;
  54. border: 2px solid #fff;
  55. border-radius: 8px;
  56. position: fixed;
  57. top: 0;
  58. right: 0;
  59. margin-top: 10px;
  60. margin-right: 10px;
  61. padding: 15px;
  62. z-index: 99999;
  63. max-width: 17.5%;
  64. }
  65.  
  66. #yt-downloader-notification-${randomNumber} > h3 {
  67. color: #f00;
  68. font-size: 2.5rem;
  69. }
  70.  
  71. #yt-downloader-notification-${randomNumber} > span {
  72. font-style: italic;
  73. font-size: 1.5rem;
  74. }
  75.  
  76. #yt-downloader-notification-${randomNumber} a {
  77. color: #f00;
  78. }
  79.  
  80. #yt-downloader-notification-${randomNumber} > button {
  81. position: absolute;
  82. top: 0;
  83. right: 0;
  84. background: none;
  85. border: none;
  86. outline: none;
  87. width: fit-content;
  88. height: fit-content;
  89. margin: 5px;
  90. padding: 0;
  91. }
  92.  
  93. #yt-downloader-notification-${randomNumber} > button > svg {
  94. fill: #fff;
  95. }
  96.  
  97. #yt-downloader-menu-${randomNumber} {
  98. width: 40vw;
  99. height: 60vh;
  100. background-color: rgba(0, 0, 0, 0.9);
  101. position: absolute;
  102. left: 50%;
  103. top: 50%;
  104. transform: translate(-50%, -50%);
  105. z-index: 999;
  106. border-radius: 8px;
  107. border: 2px solid rgba(255, 0, 0, 0.9);
  108. opacity: 0;
  109. display: flex;
  110. flex-direction: column;
  111. gap: 1.3rem;
  112. color: #fff;
  113. font-size: 1.5rem !important;
  114. padding: 15px;
  115. }
  116.  
  117. #yt-downloader-menu-${randomNumber} > textarea {
  118. resize: none;
  119. width: 100%;
  120. background: transparent !important;
  121. border: none !important;
  122. color: #fff !important;
  123. height: 100%;
  124. outline: none !important;
  125. margin: 0 !important;
  126. padding: 0 !important;
  127. font-family: "Fira Code", monospace;
  128. font-size: 1.5rem;
  129. }
  130.  
  131. #yt-downloader-menu-${randomNumber} > textarea::-webkit-scrollbar {
  132. display: none;
  133. }
  134.  
  135. #yt-downloader-menu-${randomNumber} > button {
  136. opacity: 0.25;
  137. position: absolute;
  138. top: 0;
  139. right: 0;
  140. border-top-right-radius: 8px;
  141. background-color: rgba(255, 0, 0, 0.5);
  142. color: #fff;
  143. outline: none;
  144. border: none;
  145. border-bottom: 2px solid #f00;
  146. border-left: 2px solid #f00;
  147. cursor: pointer;
  148. font-family: "Fira Code", monospace;
  149. font-size: 1.2rem;
  150. transition: all .3s ease-in-out;
  151. margin: 0;
  152. padding: 3px 5px;
  153. }
  154.  
  155. #yt-downloader-menu-${randomNumber} > button:hover {
  156. opacity: 1;
  157. }
  158.  
  159. #yt-downloader-menu-${randomNumber}.opened {
  160. animation: openMenu .3s linear forwards;
  161. }
  162.  
  163. #yt-downloader-menu-${randomNumber}.closed {
  164. animation: closeMenu .3s linear forwards;
  165. }
  166.  
  167. input {
  168. accent-color: #f00;
  169. }
  170.  
  171. @keyframes openMenu {
  172. 0% {
  173. opacity: 0;
  174. }
  175.  
  176. 100% {
  177. opacity: 1;
  178. }
  179. }
  180.  
  181. @keyframes closeMenu {
  182. 0% {
  183. opacity: 1;
  184. }
  185.  
  186. 100% {
  187. opacity: 0;
  188. }
  189. }
  190. `);
  191.  
  192. function Cobalt(videoUrl, audioOnly = false) {
  193. // Use Promise because GM.xmlHttpRequest is async and behaves differently with different userscript managers
  194. return new Promise((resolve, reject) => {
  195. // https://github.com/wukko/cobalt/blob/current/docs/api.md
  196. GM_xmlhttpRequest({
  197. method: 'POST',
  198. url: 'https://co.wuk.sh/api/json',
  199. headers: {
  200. 'Cache-Control': 'no-cache',
  201. Accept: 'application/json',
  202. 'Content-Type': 'application/json',
  203. },
  204. data: JSON.stringify({
  205. url: encodeURI(videoUrl), // video url
  206. vQuality: 'max', // always max quality
  207. filenamePattern: 'basic', // file name = video title
  208. isAudioOnly: audioOnly,
  209. disableMetadata: true, // privacy
  210. }),
  211. onload: (response) => {
  212. const data = JSON.parse(response.responseText);
  213. if (data?.url) resolve(data.url);
  214. else reject(data);
  215. },
  216. onerror: (err) => reject(err),
  217. });
  218. });
  219. }
  220.  
  221. /**
  222. * https://stackoverflow.com/a/61511955
  223. * @param {String} selector The CSS selector used to select the element
  224. * @returns {Promise<Element>} The selected element
  225. */
  226. function waitForElement(selector) {
  227. return new Promise((resolve) => {
  228. if (document.querySelector(selector)) return resolve(document.querySelector(selector));
  229.  
  230. const observer = new MutationObserver(() => {
  231. if (document.querySelector(selector)) {
  232. observer.disconnect();
  233. resolve(document.querySelector(selector));
  234. }
  235. });
  236.  
  237. observer.observe(document.body, { childList: true, subtree: true });
  238. });
  239. }
  240.  
  241. /**
  242. * Append a notification element to the document
  243. * @param {String} title The title of the message
  244. * @param {String} message The message to display
  245. * @returns {void}
  246. */
  247. function notify(title, message) {
  248. const notificationContainer = document.createElement('div');
  249. notificationContainer.id = `yt-downloader-notification-${randomNumber}`;
  250.  
  251. const titleElement = document.createElement('h3');
  252. titleElement.textContent = title;
  253.  
  254. const messageElement = document.createElement('span');
  255. messageElement.innerHTML = message;
  256.  
  257. const closeButton = document.createElement('button');
  258. closeButton.innerHTML =
  259. '<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>';
  260. closeButton.addEventListener('click', () => {
  261. notificationContainer.remove();
  262. });
  263.  
  264. notificationContainer.append(titleElement, messageElement, closeButton);
  265. document.body.appendChild(notificationContainer);
  266. }
  267.  
  268. /**
  269. * Throw an error after `sec` seconds
  270. * @param {number} sec How long to wait before throwing an error (seconds)
  271. * @returns {Promise<void>}
  272. */
  273. function timeout(sec) {
  274. return new Promise((resolve, reject) => {
  275. setTimeout(() => {
  276. reject('Request timed out after ' + sec + ' seconds');
  277. }, sec * 1000);
  278. });
  279. }
  280.  
  281. /**
  282. * Detect which YouTube service is being used
  283. * @returns {"SHORTS" | "MUSIC" | "YOUTUBE" | null}
  284. */
  285. function updateService() {
  286. if (window.location.hostname === 'www.youtube.com' && window.location.pathname.startsWith('/shorts'))
  287. return 'SHORTS';
  288. else if (window.location.hostname === 'music.youtube.com') return 'MUSIC';
  289. else if (window.location.hostname === 'www.youtube.com' && window.location.pathname.startsWith('/watch'))
  290. return 'YOUTUBE';
  291. else return null;
  292. }
  293.  
  294. /**
  295. * Left click => download video
  296. * @returns {void}
  297. */
  298. async function leftClick() {
  299. if (!window.location.pathname.slice(1))
  300. return notify('Hey!', 'The video/song player is not open, I cannot see the link to download!'); // do nothing if video is not focused
  301.  
  302. if (!VIDEO_DATA) return notify("The video data hasn't been loaded yet", 'Try again in a few seconds...');
  303.  
  304. try {
  305. // window.open(await Cobalt(window.location.href), '_blank');
  306. eval(replacePlaceholders(codeTextArea.value));
  307. } catch (err) {
  308. notify('An error occurred!', JSON.stringify(err));
  309. }
  310. }
  311.  
  312. /**
  313. * Right click => download audio
  314. * @param {Event} e The right click event
  315. * @returns {void}
  316. */
  317. async function rightClick(e) {
  318. e.preventDefault();
  319.  
  320. if (!window.location.pathname.slice(1))
  321. return notify('Hey!', 'The video/song player is not open, I cannot see the link to download!'); // do nothing if video is not focused
  322.  
  323. try {
  324. window.open(await Cobalt(window.location.href, true), '_blank');
  325. } catch (err) {
  326. notify('An error occurred!', JSON.stringify(err));
  327. }
  328.  
  329. return false;
  330. }
  331.  
  332. /**
  333. * Middle mouse button click => open menu
  334. * @param {MouseEvent} e The mouse event
  335. * @returns {false}
  336. */
  337. function middleClick(e) {
  338. if (e.buttons !== 4) return;
  339. e.preventDefault();
  340. menuPopup.style.display = 'block';
  341. menuPopup.classList.add('opened');
  342. menuPopup.classList.remove('closed');
  343.  
  344. notify(
  345. 'Wait! Read this first!',
  346. `Here you can set up the code you want to be executed when LEFT CLICKING the download button.
  347. <br><br>It requires JavaScript coding knowledge, so proceed only if you know what you are doing.
  348. <br><br> You have access to <b>some</b> <a target="_blank" href="https://violentmonkey.github.io/api/gm/">GM API functions</a>, described in the userscript header.
  349. <br><br><a target="_blank" href="https://github.com/madkarmaa/youtube-downloader/docs/PLACEHOLDERS.md">Read more</a>`
  350. );
  351.  
  352. return false;
  353. }
  354.  
  355. /**
  356. * Renderer process
  357. * @param {CustomEvent} event The YouTube custom navigation event
  358. * @returns {Promise<void>}
  359. */
  360. async function RENDERER(event) {
  361. logger('Checking if user is watching');
  362. // do nothing if the user isn't watching any media
  363. if (!event?.detail?.endpoint?.watchEndpoint?.videoId && !event?.detail?.endpoint?.reelWatchEndpoint?.videoId) {
  364. logger('User is not watching');
  365. return;
  366. }
  367. logger('User is watching');
  368.  
  369. // wait for the button to copy to appear before continuing
  370. logger('Waiting for the button to copy to appear');
  371. let buttonToCopy;
  372. switch (YOUTUBE_SERVICE) {
  373. case 'YOUTUBE':
  374. buttonToCopy = waitForElement(
  375. 'div#player div.ytp-chrome-controls div.ytp-right-controls button[aria-label="Settings"]'
  376. );
  377. break;
  378. case 'MUSIC':
  379. buttonToCopy = waitForElement(
  380. '[slot="player-bar"] div.middle-controls div.middle-controls-buttons #like-button-renderer #button-shape-dislike button[aria-label="Dislike"]'
  381. );
  382. break;
  383. case 'SHORTS':
  384. buttonToCopy = waitForElement(
  385. 'div#actions.ytd-reel-player-overlay-renderer div#comments-button button'
  386. );
  387. break;
  388.  
  389. default:
  390. break;
  391. }
  392.  
  393. // cancel rendering after 5 seconds of the button not appearing in the document
  394. buttonToCopy = await Promise.race([timeout(5), buttonToCopy]);
  395. logger('Button to copy is:', buttonToCopy);
  396.  
  397. // create the download button
  398. const downloadButton = document.createElement('button');
  399. downloadButton.id = buttonId;
  400. downloadButton.title =
  401. 'Click to download as video\nRight click to download as audio\nMMB to open advanced settings menu';
  402. downloadButton.innerHTML =
  403. '<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>';
  404. downloadButton.classList = buttonToCopy.classList;
  405.  
  406. if (YOUTUBE_SERVICE === 'YOUTUBE') downloadButton.classList.add('ytp-hd-quality-badge');
  407. downloadButton.classList.add(YOUTUBE_SERVICE);
  408. logger('Download button created:', downloadButton);
  409.  
  410. downloadButton.addEventListener('click', leftClick);
  411. downloadButton.addEventListener('contextmenu', rightClick);
  412. downloadButton.addEventListener('mousedown', middleClick);
  413. logger('Event listeners added to the download button');
  414.  
  415. switch (YOUTUBE_SERVICE) {
  416. case 'YOUTUBE':
  417. logger('Waiting for the player buttons row to appear');
  418. const YTButtonsRow = await waitForElement('div#player div.ytp-chrome-controls div.ytp-right-controls');
  419. logger('Buttons row is now available');
  420.  
  421. if (!YTButtonsRow.querySelector('#' + buttonId))
  422. YTButtonsRow.insertBefore(downloadButton, YTButtonsRow.firstChild);
  423. logger('Download button added to the buttons row');
  424.  
  425. break;
  426. case 'MUSIC':
  427. logger('Waiting for the player buttons row to appear');
  428. const YTMButtonsRow = await waitForElement(
  429. '[slot="player-bar"] div.middle-controls div.middle-controls-buttons'
  430. );
  431. logger('Buttons row is now available');
  432.  
  433. if (!YTMButtonsRow.querySelector('#' + buttonId))
  434. YTMButtonsRow.insertBefore(downloadButton, YTMButtonsRow.firstChild);
  435. logger('Download button added to the buttons row');
  436.  
  437. break;
  438. case 'SHORTS':
  439. // wait for the first reel to load
  440. logger('Waiting for the reels to load');
  441. await waitForElement('div#actions.ytd-reel-player-overlay-renderer div#like-button');
  442. logger('Reels loaded');
  443.  
  444. document.querySelectorAll('div#actions.ytd-reel-player-overlay-renderer').forEach((buttonsCol) => {
  445. if (!buttonsCol.getAttribute('data-button-added') && !buttonsCol.querySelector(buttonId)) {
  446. const dlButtonCopy = downloadButton.cloneNode(true);
  447. dlButtonCopy.addEventListener('click', leftClick);
  448. dlButtonCopy.addEventListener('contextmenu', rightClick);
  449. dlButtonCopy.addEventListener('mousedown', middleClick);
  450.  
  451. buttonsCol.insertBefore(dlButtonCopy, buttonsCol.querySelector('div#like-button'));
  452. buttonsCol.setAttribute('data-button-added', true);
  453. }
  454. });
  455. logger('Download buttons added to reels');
  456.  
  457. break;
  458.  
  459. default:
  460. break;
  461. }
  462. }
  463.  
  464. function replacePlaceholders(inputString) {
  465. return inputString.replace(/{{\s*([^}\s]+)\s*}}/g, (match, placeholder) => VIDEO_DATA[placeholder] || match);
  466. }
  467.  
  468. let VIDEO_DATA;
  469. document.addEventListener('yt-player-updated', (e) => {
  470. const temp_video_data = e.detail.getVideoData();
  471. VIDEO_DATA = {
  472. current_time: e.detail.getCurrentTime(),
  473. video_duration: e.detail.getDuration(),
  474. video_url: e.detail.getVideoUrl(),
  475. video_author: temp_video_data?.author,
  476. video_title: temp_video_data?.title,
  477. video_id: temp_video_data?.video_id,
  478. };
  479. });
  480.  
  481. let YOUTUBE_SERVICE = updateService();
  482.  
  483. const menuPopup = document.createElement('div');
  484. menuPopup.id = `yt-downloader-menu-${randomNumber}`;
  485. menuPopup.style.display = 'none';
  486. menuPopup.classList.add('closed');
  487.  
  488. const codeTextArea = document.createElement('textarea');
  489.  
  490. const resetButton = document.createElement('button');
  491. resetButton.textContent = 'Reset to default';
  492. resetButton.addEventListener('click', () => {
  493. codeTextArea.value = `(async () => {\n\n${Cobalt.toString()}\n\nwindow.open(await Cobalt('{{ video_url }}'), '_blank');\n\n})();`;
  494. });
  495.  
  496. menuPopup.append(codeTextArea, resetButton);
  497.  
  498. codeTextArea.value =
  499. localStorage.getItem('yt-dl-code') ||
  500. `(async () => {\n\n${Cobalt.toString()}\n\nwindow.open(await Cobalt('{{ video_url }}'), '_blank');\n\n})();`;
  501. localStorage.setItem('yt-dl-code', codeTextArea.value);
  502.  
  503. menuPopup.addEventListener('animationend', (e) => {
  504. if (e.animationName === 'closeMenu') e.target.style.display = 'none';
  505. });
  506.  
  507. document.addEventListener('click', (e) => {
  508. if (menuPopup.style.display !== 'none' && e.target !== menuPopup && !menuPopup.contains(e.target)) {
  509. e.preventDefault();
  510. menuPopup.classList.add('closed');
  511. menuPopup.classList.remove('opened');
  512. localStorage.setItem('yt-dl-code', codeTextArea.value);
  513. return false;
  514. }
  515. });
  516. document.body.appendChild(menuPopup);
  517.  
  518. ['yt-navigate', 'yt-navigate-finish'].forEach((evName) =>
  519. document.addEventListener(evName, (e) => {
  520. YOUTUBE_SERVICE = updateService();
  521. logger('Service is:', YOUTUBE_SERVICE);
  522. if (!YOUTUBE_SERVICE) return;
  523. RENDERER(e);
  524. })
  525. );
  526. })();