YouTube downloader

A simple userscript to download YouTube videos in MAX QUALITY

目前为 2024-02-03 提交的版本。查看 最新版本

  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.4
  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. /**
  193. * Download a video using the Cobalt API
  194. * @param {String} videoUrl The url of the video to download
  195. * @param {*} audioOnly Wether to download the video as audio only or not
  196. * @returns
  197. */
  198. function Cobalt(videoUrl, audioOnly = false) {
  199. // Use Promise because GM.xmlHttpRequest is async and behaves differently with different userscript managers
  200. return new Promise((resolve, reject) => {
  201. // https://github.com/wukko/cobalt/blob/current/docs/api.md
  202. GM_xmlhttpRequest({
  203. method: 'POST',
  204. url: 'https://co.wuk.sh/api/json',
  205. headers: {
  206. 'Cache-Control': 'no-cache',
  207. Accept: 'application/json',
  208. 'Content-Type': 'application/json',
  209. },
  210. data: JSON.stringify({
  211. url: encodeURI(videoUrl), // video url
  212. vQuality: 'max', // always max quality
  213. filenamePattern: 'basic', // file name = video title
  214. isAudioOnly: audioOnly,
  215. disableMetadata: true, // privacy
  216. }),
  217. onload: (response) => {
  218. const data = JSON.parse(response.responseText);
  219. if (data?.url) resolve(data.url);
  220. else reject(data);
  221. },
  222. onerror: (err) => reject(err),
  223. });
  224. });
  225. }
  226.  
  227. /**
  228. * https://stackoverflow.com/a/61511955
  229. * @param {String} selector The CSS selector used to select the element
  230. * @returns {Promise<Element>} The selected element
  231. */
  232. function waitForElement(selector) {
  233. return new Promise((resolve) => {
  234. if (document.querySelector(selector)) return resolve(document.querySelector(selector));
  235.  
  236. const observer = new MutationObserver(() => {
  237. if (document.querySelector(selector)) {
  238. observer.disconnect();
  239. resolve(document.querySelector(selector));
  240. }
  241. });
  242.  
  243. observer.observe(document.body, { childList: true, subtree: true });
  244. });
  245. }
  246.  
  247. /**
  248. * Append a notification element to the document
  249. * @param {String} title The title of the message
  250. * @param {String} message The message to display
  251. * @returns {void}
  252. */
  253. function notify(title, message) {
  254. const notificationContainer = document.createElement('div');
  255. notificationContainer.id = `yt-downloader-notification-${randomNumber}`;
  256.  
  257. const titleElement = document.createElement('h3');
  258. titleElement.textContent = title;
  259.  
  260. const messageElement = document.createElement('span');
  261. messageElement.innerHTML = message;
  262.  
  263. const closeButton = document.createElement('button');
  264. closeButton.innerHTML =
  265. '<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>';
  266. closeButton.addEventListener('click', () => {
  267. notificationContainer.remove();
  268. });
  269.  
  270. notificationContainer.append(titleElement, messageElement, closeButton);
  271. document.body.appendChild(notificationContainer);
  272. }
  273.  
  274. /**
  275. * Throw an error after `sec` seconds
  276. * @param {number} sec How long to wait before throwing an error (seconds)
  277. * @returns {Promise<void>}
  278. */
  279. function timeout(sec) {
  280. return new Promise((resolve, reject) => {
  281. setTimeout(() => {
  282. reject('Request timed out after ' + sec + ' seconds');
  283. }, sec * 1000);
  284. });
  285. }
  286.  
  287. /**
  288. * Detect which YouTube service is being used
  289. * @returns {"SHORTS" | "MUSIC" | "YOUTUBE" | null}
  290. */
  291. function updateService() {
  292. if (window.location.hostname === 'www.youtube.com' && window.location.pathname.startsWith('/shorts'))
  293. return 'SHORTS';
  294. else if (window.location.hostname === 'music.youtube.com') return 'MUSIC';
  295. else if (window.location.hostname === 'www.youtube.com' && window.location.pathname.startsWith('/watch'))
  296. return 'YOUTUBE';
  297. else return null;
  298. }
  299.  
  300. /**
  301. * Left click => download video
  302. * @returns {void}
  303. */
  304. async function leftClick() {
  305. if (!window.location.pathname.slice(1))
  306. return notify('Hey!', 'The video/song player is not open, I cannot see the link to download!'); // do nothing if video is not focused
  307.  
  308. if (!VIDEO_DATA) return notify("The video data hasn't been loaded yet", 'Try again in a few seconds...');
  309.  
  310. try {
  311. // window.open(await Cobalt(window.location.href), '_blank');
  312. eval(replacePlaceholders(codeTextArea.value));
  313. } catch (err) {
  314. notify('An error occurred!', JSON.stringify(err));
  315. }
  316. }
  317.  
  318. /**
  319. * Right click => download audio
  320. * @param {Event} e The right click event
  321. * @returns {void}
  322. */
  323. async function rightClick(e) {
  324. e.preventDefault();
  325.  
  326. if (!window.location.pathname.slice(1))
  327. return notify('Hey!', 'The video/song player is not open, I cannot see the link to download!'); // do nothing if video is not focused
  328.  
  329. try {
  330. window.open(await Cobalt(window.location.href, true), '_blank');
  331. } catch (err) {
  332. notify('An error occurred!', JSON.stringify(err));
  333. }
  334.  
  335. return false;
  336. }
  337.  
  338. /**
  339. * Middle mouse button click => open menu
  340. * @param {MouseEvent} e The mouse event
  341. * @returns {false}
  342. */
  343. function middleClick(e) {
  344. if (e.buttons !== 4) return;
  345. e.preventDefault();
  346. menuPopup.style.display = 'block';
  347. menuPopup.classList.add('opened');
  348. menuPopup.classList.remove('closed');
  349.  
  350. notify(
  351. 'Wait! Read this first!',
  352. `Here you can set up the code you want to be executed when LEFT CLICKING the download button.
  353. <br><br>It requires JavaScript coding knowledge, so proceed only if you know what you are doing.
  354. <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.
  355. <br><br><a target="_blank" href="https://github.com/madkarmaa/youtube-downloader/docs/PLACEHOLDERS.md">Read more</a>`
  356. );
  357.  
  358. return false;
  359. }
  360.  
  361. /**
  362. * Renderer process
  363. * @param {CustomEvent} event The YouTube custom navigation event
  364. * @returns {Promise<void>}
  365. */
  366. async function RENDERER(event) {
  367. logger('Checking if user is watching');
  368. // do nothing if the user isn't watching any media
  369. if (!event?.detail?.endpoint?.watchEndpoint?.videoId && !event?.detail?.endpoint?.reelWatchEndpoint?.videoId) {
  370. logger('User is not watching');
  371. return;
  372. }
  373. logger('User is watching');
  374.  
  375. // wait for the button to copy to appear before continuing
  376. logger('Waiting for the button to copy to appear');
  377. let buttonToCopy;
  378. switch (YOUTUBE_SERVICE) {
  379. case 'YOUTUBE':
  380. buttonToCopy = waitForElement(
  381. 'div#player div.ytp-chrome-controls div.ytp-right-controls button[aria-label="Settings"]'
  382. );
  383. break;
  384. case 'MUSIC':
  385. buttonToCopy = waitForElement(
  386. '[slot="player-bar"] div.middle-controls div.middle-controls-buttons #like-button-renderer #button-shape-dislike button[aria-label="Dislike"]'
  387. );
  388. break;
  389. case 'SHORTS':
  390. buttonToCopy = waitForElement(
  391. 'div#actions.ytd-reel-player-overlay-renderer div#comments-button button'
  392. );
  393. break;
  394.  
  395. default:
  396. break;
  397. }
  398.  
  399. // cancel rendering after 5 seconds of the button not appearing in the document
  400. buttonToCopy = await Promise.race([timeout(5), buttonToCopy]);
  401. logger('Button to copy is:', buttonToCopy);
  402.  
  403. // create the download button
  404. const downloadButton = document.createElement('button');
  405. downloadButton.id = buttonId;
  406. downloadButton.title =
  407. 'Click to download as video\nRight click to download as audio\nMMB to open advanced settings menu';
  408. downloadButton.innerHTML =
  409. '<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>';
  410. downloadButton.classList = buttonToCopy.classList;
  411.  
  412. if (YOUTUBE_SERVICE === 'YOUTUBE') downloadButton.classList.add('ytp-hd-quality-badge');
  413. downloadButton.classList.add(YOUTUBE_SERVICE);
  414. logger('Download button created:', downloadButton);
  415.  
  416. downloadButton.addEventListener('click', leftClick);
  417. downloadButton.addEventListener('contextmenu', rightClick);
  418. downloadButton.addEventListener('mousedown', middleClick);
  419. logger('Event listeners added to the download button');
  420.  
  421. switch (YOUTUBE_SERVICE) {
  422. case 'YOUTUBE':
  423. logger('Waiting for the player buttons row to appear');
  424. const YTButtonsRow = await waitForElement('div#player div.ytp-chrome-controls div.ytp-right-controls');
  425. logger('Buttons row is now available');
  426.  
  427. if (!YTButtonsRow.querySelector('#' + buttonId))
  428. YTButtonsRow.insertBefore(downloadButton, YTButtonsRow.firstChild);
  429. logger('Download button added to the buttons row');
  430.  
  431. break;
  432. case 'MUSIC':
  433. logger('Waiting for the player buttons row to appear');
  434. const YTMButtonsRow = await waitForElement(
  435. '[slot="player-bar"] div.middle-controls div.middle-controls-buttons'
  436. );
  437. logger('Buttons row is now available');
  438.  
  439. if (!YTMButtonsRow.querySelector('#' + buttonId))
  440. YTMButtonsRow.insertBefore(downloadButton, YTMButtonsRow.firstChild);
  441. logger('Download button added to the buttons row');
  442.  
  443. break;
  444. case 'SHORTS':
  445. // wait for the first reel to load
  446. logger('Waiting for the reels to load');
  447. await waitForElement('div#actions.ytd-reel-player-overlay-renderer div#like-button');
  448. logger('Reels loaded');
  449.  
  450. document.querySelectorAll('div#actions.ytd-reel-player-overlay-renderer').forEach((buttonsCol) => {
  451. if (!buttonsCol.getAttribute('data-button-added') && !buttonsCol.querySelector(buttonId)) {
  452. const dlButtonCopy = downloadButton.cloneNode(true);
  453. dlButtonCopy.addEventListener('click', leftClick);
  454. dlButtonCopy.addEventListener('contextmenu', rightClick);
  455. dlButtonCopy.addEventListener('mousedown', middleClick);
  456.  
  457. buttonsCol.insertBefore(dlButtonCopy, buttonsCol.querySelector('div#like-button'));
  458. buttonsCol.setAttribute('data-button-added', true);
  459. }
  460. });
  461. logger('Download buttons added to reels');
  462.  
  463. break;
  464.  
  465. default:
  466. break;
  467. }
  468. }
  469.  
  470. /**
  471. * Replace the placeholders in a string with their values
  472. * @param {*} inputString The input string
  473. * @returns {String} The string with the parsed placeholders
  474. */
  475. function replacePlaceholders(inputString) {
  476. return inputString.replace(/{{\s*([^}\s]+)\s*}}/g, (match, placeholder) => VIDEO_DATA[placeholder] || match);
  477. }
  478.  
  479. let VIDEO_DATA;
  480. document.addEventListener('yt-player-updated', (e) => {
  481. const temp_video_data = e.detail.getVideoData();
  482. VIDEO_DATA = {
  483. current_time: e.detail.getCurrentTime(),
  484. video_duration: e.detail.getDuration(),
  485. video_url: e.detail.getVideoUrl(),
  486. video_author: temp_video_data?.author,
  487. video_title: temp_video_data?.title,
  488. video_id: temp_video_data?.video_id,
  489. };
  490. logger('Video data updated', VIDEO_DATA);
  491. });
  492.  
  493. let YOUTUBE_SERVICE = updateService();
  494.  
  495. const menuPopup = document.createElement('div');
  496. menuPopup.id = `yt-downloader-menu-${randomNumber}`;
  497. menuPopup.style.display = 'none';
  498. menuPopup.classList.add('closed');
  499.  
  500. const codeTextArea = document.createElement('textarea');
  501.  
  502. const resetButton = document.createElement('button');
  503. resetButton.textContent = 'Reset to default';
  504. resetButton.addEventListener('click', () => {
  505. codeTextArea.value = `(async () => {\n\n${Cobalt.toString()}\n\nwindow.open(await Cobalt('{{ video_url }}'), '_blank');\n\n})();`;
  506. logger('Code reset');
  507. });
  508.  
  509. menuPopup.append(codeTextArea, resetButton);
  510.  
  511. codeTextArea.value =
  512. localStorage.getItem('yt-dl-code') ||
  513. `(async () => {\n\n${Cobalt.toString()}\n\nwindow.open(await Cobalt('{{ video_url }}'), '_blank');\n\n})();`;
  514. localStorage.setItem('yt-dl-code', codeTextArea.value);
  515. logger('Code retrieved and set to textarea');
  516.  
  517. menuPopup.addEventListener('animationend', (e) => {
  518. if (e.animationName === 'closeMenu') e.target.style.display = 'none';
  519. });
  520.  
  521. document.addEventListener('click', (e) => {
  522. if (menuPopup.style.display !== 'none' && e.target !== menuPopup && !menuPopup.contains(e.target)) {
  523. e.preventDefault();
  524. menuPopup.classList.add('closed');
  525. menuPopup.classList.remove('opened');
  526. logger('Menu closed');
  527. localStorage.setItem('yt-dl-code', codeTextArea.value);
  528. logger('Code saved to localStorage');
  529. return false;
  530. }
  531. });
  532. document.body.appendChild(menuPopup);
  533. logger('Menu created', menuPopup);
  534.  
  535. ['yt-navigate', 'yt-navigate-finish'].forEach((evName) =>
  536. document.addEventListener(evName, (e) => {
  537. YOUTUBE_SERVICE = updateService();
  538. logger('Service is:', YOUTUBE_SERVICE);
  539. if (!YOUTUBE_SERVICE) return;
  540. RENDERER(e);
  541. })
  542. );
  543. })();