Youtube direct downloader

Video/short download button hidden in three dots combo menu below video. Downloads MP4, WEBM or MP3 from youtube + option to redirect shorts to normal videos. Choose your preferred quality from 8k to audio only, codec (h264, vp9 or av1) or service provider (cobalt, y2mate, yt1s) in settings.

// ==UserScript==
// @name                Youtube direct downloader
// @version             2.1.2
// @description         Video/short download button hidden in three dots combo menu below video. Downloads MP4, WEBM or MP3 from youtube + option to redirect shorts to normal videos. Choose your preferred quality from 8k to audio only, codec (h264, vp9 or av1) or service provider (cobalt, y2mate, yt1s) in settings.
// @author              FawayTT
// @namespace           FawayTT
// @supportURL          https://github.com/FawayTT/userscripts/issues
// @icon                https://github.com/FawayTT/userscripts/blob/main/youtube-downloader-icon.png?raw=true
// @match               https://www.youtube.com/*
// @connect             api.cobalt.tools
// @require             https://openuserjs.org/src/libs/sizzle/GM_config.js
// @grant               GM_getValue
// @grant               GM_setValue
// @grant               GM_registerMenuCommand
// @grant               GM_openInTab
// @grant               GM_xmlhttpRequest
// @license             MIT
// ==/UserScript==

GM_registerMenuCommand('Settings', opencfg);

const defaults = {
  downloadService: 'cobalt',
  quality: 'max',
  vCodec: 'vp9',
  aFormat: 'mp3',
  filenamePattern: 'pretty',
  isAudioMuted: false,
  disableMetadata: false,
  audioOnly: false,
  redirectShorts: false,
};

let gmc = new GM_config({
  id: 'config',
  title: 'Youtube direct downloader settings',
  fields: {
    downloadService: {
      section: ['Download method (use cobalt for best quality):'],
      label: 'Service',
      labelPos: 'left',
      type: 'select',
      default: defaults.downloadService,
      options: ['cobalt', 'y2mate', 'yt1s'],
    },
    quality: {
      section: ['Cobalt-only settings'],
      label: 'Quality:',
      labelPos: 'left',
      type: 'select',
      default: defaults.quality,
      options: ['max', '2160', '1440', '1080', '720', '480', '360', '240', '144'],
    },
    videoCodec: {
      label: 'Video codec (h264 [MP4] for best compatibility, vp9 [WEBM] for better quality. AV1 = best quality but is used only by few videos):',
      labelPos: 'left',
      type: 'select',
      default: defaults.vCodec,
      options: ['h264', 'vp9', 'av1'],
    },
    audioFormat: {
      label: 'Audio format:',
      type: 'select',
      default: defaults.aFormat,
      options: ['best', 'mp3', 'ogg', 'wav', 'opus'],
    },
    audioOnly: {
      label: 'Always download only audio:',
      type: 'checkbox',
      default: defaults.audioOnly,
    },
    isAudioMuted: {
      label: 'Download videos without audio:',
      type: 'checkbox',
      default: defaults.isAudioMuted,
    },
    disableMetadata: {
      label: 'Download videos without metadata:',
      type: 'checkbox',
      default: defaults.disableMetadata,
    },
    filenamePattern: {
      label: 'Filename pattern:',
      type: 'select',
      default: defaults.filenamePattern,
      options: ['classic', 'pretty', 'basic', 'nerdy', 'opus'],
    },
    redirectShorts: {
      section: ['Extra features'],
      label: 'Redirect shorts:',
      labelPos: 'left',
      type: 'checkbox',
      default: defaults.redirectShorts,
    },
    url: {
      section: ['Links'],
      label: 'My other userscripts',
      type: 'button',
      click: () => {
        GM_openInTab('https://github.com/FawayTT/userscripts');
      },
    },
    cobaltUrl: {
      label: 'Cobalt',
      type: 'button',
      click: () => {
        GM_openInTab('https://github.com/imputnet/cobalt');
      },
    },
  },
  events: {
    save: function () {
      gmc.close();
    },
    init: onInit,
  },
});

function opencfg() {
  gmc.open();
  config.style = `
  width: 100%;
  height: 100%;
  max-height: 40rem;
  max-width: 80rem;
  border-radius: 10px;
  z-index: 9999999;
  position: fixed;
  `;
}

let timeout;
let oldHref = document.location.href;
let menuIndex = 1;
let menuMaxTries = 10;

function getYouTubeVideoID(url) {
  const urlParams = new URLSearchParams(new URL(url).search);
  return urlParams.get('v');
}

function download(audioOnly) {
  switch (gmc.get('downloadService')) {
    case 'y2mate':
      if (audioOnly) window.open(`https://www.y2mate.com/youtube-mp3/${getYouTubeVideoID(document.location.href)}`);
      else window.open(`https://www.y2mate.com/download-youtube/${getYouTubeVideoID(document.location.href)}`);
      break;
    case 'yt1s':
      if (audioOnly) window.open(`https://www.yt1s.com/en/youtube-to-mp3?q=${getYouTubeVideoID(document.location.href)}`);
      else window.open(`https://www.yt1s.com/en/youtube-to-mp4?q=${getYouTubeVideoID(document.location.href)}`);
      break;
    default:
      GM_xmlhttpRequest({
        method: 'POST',
        url: 'https://api.cobalt.tools/api/json',
        headers: {
          Accept: 'application/json',
          'Content-Type': 'application/json',
        },
        data: JSON.stringify({
          url: encodeURI(document.location.href),
          vQuality: gmc.get('quality'),
          vCodec: gmc.get('videoCodec'),
          aFormat: gmc.get('audioFormat'),
          filenamePattern: gmc.get('filenamePattern'),
          isAudioMuted: gmc.get('isAudioMuted'),
          disableMetadata: gmc.get('disableMetadata'),
          isAudioOnly: audioOnly || gmc.get('audioOnly'),
        }),
        onload: (response) => {
          const data = JSON.parse(response.responseText);
          if (data.url) window.open(data.url);
        },
      });
      break;
  }
}

function capitalize(s){
    return s[0].toUpperCase() + s.slice(1);
}

function createButton() {
  if (document.getElementsByTagName('custom-dwn-button').length !== 0) return;
  const menu = document.getElementsByTagName('ytd-menu-popup-renderer')[0];
  const downButtonOuter = document.createElement('custom-dwn-button');
  const icon = document.createElement('div');
  const text = document.createElement('div');
  const downButton = document.createElement('button');
  const extra = document.createElement('div');
  const settings = document.createElement('div');
  const downAudioOnly = document.createElement('div');
  downAudioOnly.title = 'Download audio only';
  settings.title = 'Settings';
  menu.style.minHeight = '100px';
  menu.style.minWidth = '150px';
  downButtonOuter.style.cssText = `
          cursor: pointer;
          margin-top: 8px;
          font-size: 1.4rem;
          line-height: 2rem;
          font-weight: 400;
          position: relative;
          color: var(--yt-spec-text-primary);
          font-family: "Roboto","Arial",sans-serif;
          white-space: nowrap;
          display: flex;
          margin-bottom: -10px;
          padding: 10px 0 10px 21px;
          gap: 23px;
          align-items: center;`;
  downButton.style.cssText = `
            position: absolute;
            left: 0;
            top: 0;
            width: 90%;
            height: 100%;
            opacity: 0;
            cursor: pointer;
            z-index: 9999;`;
  extra.style.cssText = `
            position: absolute;
            display: flex;
            flex-direction: column;
            justify-content: center;
            align-items: center;
            background: rgba(255, 255, 255, 0.2);
            border-radius: 3px;
            padding: 1px;
            color: var(--yt-spec-text-primary);
            z-index: 9999;
            right: 0;
            top: 0;
            width: 10%;
            height: 90%;`;
  icon.innerText = '⇩';
  const serviceName = gmc.get('downloadService') || 'Cobalt';
  text.innerText = capitalize(serviceName);
  settings.innerText = '☰';
  downAudioOnly.innerText = '▶';
  icon.style.cssText = `
            font-size: 2.1rem;`;
  downButtonOuter.appendChild(icon);
  downButtonOuter.appendChild(text);
  downButtonOuter.appendChild(extra);
  downButtonOuter.appendChild(downButton);
  extra.appendChild(settings);
  extra.appendChild(downAudioOnly);
  downButton.addEventListener('click', () => {
    download();
  });
  downAudioOnly.addEventListener('click', () => {
    download(true);
  });
  settings.addEventListener('click', opencfg);
  downButtonOuter.addEventListener('mouseenter', () => {
    downButtonOuter.style.backgroundColor = 'var(--yt-spec-10-percent-layer)';
  });
  downButtonOuter.addEventListener('mouseleave', () => {
    downButtonOuter.style.backgroundColor = '';
  });
  menu.insertBefore(downButtonOuter, menu.firstChild);
}

function watchMenu() {
  menuIndex += 1;
  if (menuMaxTries < menuIndex) {
    menuIndex = 1;
    clearTimeout(timeout);
    return;
  }
  if (document.location.href.indexOf('youtube.com/shorts') > -1) {
    const menu = document.getElementById('menu-button');
    if (!menu) {
      timeout = setTimeout(watchMenu, 500 * menuIndex);
      return;
    }
    menu.addEventListener('click', createButton);
    menuIndex = 1;
    clearTimeout(timeout);
    return;
  }
  const topRow = document.getElementById('top-row');
  const menu = topRow.querySelector('#button-shape');
  if (!topRow || !menu) {
    timeout = setTimeout(() => {
      watchMenu(false);
    }, 500 * menuIndex);
    return;
  }
  menu.addEventListener('click', createButton);
  menuIndex = 1;
  clearTimeout(timeout);
}

function modifyMenu() {
  if (document.location.href.indexOf('youtube.com/watch') === -1 && document.location.href.indexOf('youtube.com/shorts') === -1) return;
  if (document.hidden) {
    window.addEventListener('visibilitychange', () => {
      if (document.hidden) return;
      timeout = setTimeout(watchMenu, 500 * menuIndex);
    });
  } else timeout = setTimeout(watchMenu, 500 * menuIndex);
}

function checkShort() {
  if (document.location.href.indexOf('youtube.com/shorts') > -1 && gmc.get('redirectShorts')) window.location.replace(window.location.toString().replace('/shorts/', '/watch?v='));
}

function onInit() {
  const bodyList = document.querySelector('body');
  checkShort();
  modifyMenu();
  const observer = new MutationObserver(function (mutations) {
    mutations.forEach(function (mutation) {
      if (oldHref != document.location.href) {
        checkShort();
        oldHref = document.location.href;
        modifyMenu();
      }
    });
  });
  observer.observe(bodyList, {
    childList: true,
    subtree: true,
  });
}