YouTube downloader

A simple userscript to download YouTube videos in MAX QUALITY

目前為 2023-12-18 提交的版本,檢視 最新版本

  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 1.4.0
  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.xmlHttpRequest
  15. // @grant GM.xmlhttpRequest
  16. // @run-at document-end
  17. // ==/UserScript==
  18.  
  19. (async () => {
  20. 'use strict';
  21.  
  22. const randomNumber = Math.floor(Math.random() * Date.now());
  23.  
  24. function Cobalt(videoUrl, audioOnly = false) {
  25. // Use Promise because GM.xmlHttpRequest is async and behaves differently with different userscript managers
  26. return new Promise((resolve, reject) => {
  27. // https://github.com/wukko/cobalt/blob/current/docs/api.md
  28. GM.xmlHttpRequest({
  29. method: 'POST',
  30. url: 'https://co.wuk.sh/api/json',
  31. headers: {
  32. 'Cache-Control': 'no-cache',
  33. Accept: 'application/json',
  34. 'Content-Type': 'application/json',
  35. },
  36. data: JSON.stringify({
  37. url: encodeURI(videoUrl), // video url
  38. vQuality: 'max', // always max quality
  39. filenamePattern: 'basic', // file name = video title
  40. isAudioOnly: audioOnly,
  41. disableMetadata: true, // privacy
  42. }),
  43. onload: (response) => {
  44. const data = JSON.parse(response.responseText);
  45. if (data?.url) resolve(data.url);
  46. else reject(data);
  47. },
  48. onerror: (err) => reject(err),
  49. });
  50. });
  51. }
  52.  
  53. // https://stackoverflow.com/a/61511955
  54. function waitForElement(selector) {
  55. return new Promise((resolve) => {
  56. if (document.querySelector(selector)) return resolve(document.querySelector(selector));
  57.  
  58. const observer = new MutationObserver(() => {
  59. if (document.querySelector(selector)) {
  60. observer.disconnect();
  61. resolve(document.querySelector(selector));
  62. }
  63. });
  64.  
  65. observer.observe(document.body, { childList: true, subtree: true });
  66. });
  67. }
  68.  
  69. function notify(title, message) {
  70. const notificationContainer = document.createElement('div');
  71. notificationContainer.id = `yt-downloader-notification-${randomNumber}`;
  72.  
  73. const titleElement = document.createElement('h3');
  74. titleElement.textContent = title;
  75.  
  76. const messageElement = document.createElement('span');
  77. messageElement.textContent = message;
  78.  
  79. const closeButton = document.createElement('button');
  80. closeButton.innerHTML =
  81. '<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>';
  82. closeButton.addEventListener('click', () => {
  83. notificationContainer.remove();
  84. });
  85.  
  86. notificationContainer.append(titleElement, messageElement, closeButton);
  87. document.body.appendChild(notificationContainer);
  88. }
  89.  
  90. // detect which youtube service is being used
  91. const SERVICES = {
  92. YOUTUBE: 'www.youtube.com',
  93. SHORTS: '/shorts',
  94. MUSIC: 'music.youtube.com',
  95. };
  96. const YOUTUBE_SERVICE =
  97. window.location.hostname === SERVICES.YOUTUBE && window.location.pathname.startsWith(SERVICES.SHORTS)
  98. ? 'SHORTS'
  99. : window.location.hostname === SERVICES.MUSIC
  100. ? 'MUSIC'
  101. : 'YOUTUBE';
  102.  
  103. // wait for the button to copy to appear before continuing
  104. const buttonToCopy = await waitForElement(
  105. YOUTUBE_SERVICE === 'YOUTUBE'
  106. ? 'div#player div.ytp-chrome-controls div.ytp-right-controls button[aria-label="Settings"]'
  107. : YOUTUBE_SERVICE === 'MUSIC'
  108. ? '[slot="player-bar"] div.middle-controls div.middle-controls-buttons #like-button-renderer #button-shape-dislike button[aria-label="Dislike"]'
  109. : 'div#actions.ytd-reel-player-overlay-renderer div#comments-button button'
  110. );
  111.  
  112. const downloadButton = document.createElement('button');
  113.  
  114. const buttonId = `yt-downloader-btn-${randomNumber}`;
  115. downloadButton.id = buttonId;
  116. downloadButton.title = 'Click to download as video\nRight click to download as audio';
  117. downloadButton.innerHTML =
  118. '<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>';
  119. downloadButton.classList = buttonToCopy.classList;
  120.  
  121. if (YOUTUBE_SERVICE === 'YOUTUBE') downloadButton.classList.add('ytp-hd-quality-badge');
  122. downloadButton.classList.add(YOUTUBE_SERVICE);
  123.  
  124. // normal click => download video
  125. async function leftClick() {
  126. if (!window.location.pathname.slice(1))
  127. return notify('Hey!', 'The video/song player is not open, I cannot see the link to download!'); // do nothing if video is not focused
  128.  
  129. try {
  130. window.open(await Cobalt(window.location.href), '_blank');
  131. } catch (err) {
  132. notify('An error occurred!', JSON.stringify(err));
  133. }
  134. }
  135. downloadButton.addEventListener('click', leftClick);
  136. // right click => download audio
  137. async function rightClick() {
  138. if (!window.location.pathname.slice(1))
  139. return notify('Hey!', 'The video/song player is not open, I cannot see the link to download!'); // do nothing if video is not focused
  140.  
  141. e.preventDefault();
  142. try {
  143. window.open(await Cobalt(window.location.href, true), '_blank');
  144. } catch (err) {
  145. notify('An error occurred!', JSON.stringify(err));
  146. }
  147. return false;
  148. }
  149. downloadButton.addEventListener('contextmenu', rightClick);
  150.  
  151. GM_addStyle(`
  152. #${buttonId}.YOUTUBE > svg {
  153. margin-top: 3px;
  154. margin-bottom: -3px;
  155. }
  156.  
  157. #${buttonId}.SHORTS > svg {
  158. margin-left: 3px;
  159. }
  160.  
  161. #${buttonId}:hover > svg {
  162. fill: #f00;
  163. }
  164.  
  165. #yt-downloader-notification-${randomNumber} {
  166. background-color: #282828;
  167. color: #fff;
  168. border: 2px solid #fff;
  169. border-radius: 8px;
  170. position: fixed;
  171. top: 0;
  172. right: 0;
  173. margin-top: 10px;
  174. margin-right: 10px;
  175. padding: 15px;
  176. z-index: 999;
  177. }
  178.  
  179. #yt-downloader-notification-${randomNumber} > h3 {
  180. color: #f00;
  181. font-size: 2.5rem;
  182. }
  183.  
  184. #yt-downloader-notification-${randomNumber} > span {
  185. font-style: italic;
  186. font-size: 1.5rem;
  187. }
  188.  
  189. #yt-downloader-notification-${randomNumber} > button {
  190. position: absolute;
  191. top: 0;
  192. right: 0;
  193. background: none;
  194. border: none;
  195. outline: none;
  196. width: fit-content;
  197. height: fit-content;
  198. margin: 5px;
  199. padding: 0;
  200. }
  201.  
  202. #yt-downloader-notification-${randomNumber} > button > svg {
  203. fill: #fff;
  204. }
  205. `);
  206.  
  207. if (YOUTUBE_SERVICE !== 'SHORTS') {
  208. const buttonsRow = await waitForElement(
  209. YOUTUBE_SERVICE === 'YOUTUBE'
  210. ? 'div#player div.ytp-chrome-controls div.ytp-right-controls'
  211. : '[slot="player-bar"] div.middle-controls div.middle-controls-buttons'
  212. );
  213. if (!buttonsRow.contains(downloadButton)) buttonsRow.insertBefore(downloadButton, buttonsRow.firstChild);
  214. } else {
  215. function addButtonToShorts() {
  216. document.querySelectorAll('div#actions.ytd-reel-player-overlay-renderer').forEach((buttonsRow) => {
  217. const dlButtonCopy = downloadButton.cloneNode(true);
  218. dlButtonCopy.addEventListener('click', leftClick);
  219. dlButtonCopy.addEventListener('contextmenu', rightClick);
  220.  
  221. if (!buttonsRow.getAttribute('data-button-added') && !buttonsRow.contains(downloadButton)) {
  222. buttonsRow.insertBefore(dlButtonCopy, buttonsRow.querySelector('div#like-button'));
  223. buttonsRow.setAttribute('data-button-added', true);
  224. }
  225. });
  226. }
  227.  
  228. addButtonToShorts();
  229. document.addEventListener('yt-navigate-finish', addButtonToShorts);
  230. }
  231. })();