Greasy Fork is available in English.

Local YouTube Downloader

Download YouTube videos without external service. Better Version

  1. // ==UserScript==
  2. // @name Local YouTube Downloader
  3. // @namespace https://twitter.com/notshoelaze
  4. // @version 0.1
  5. // @description Download YouTube videos without external service. Better Version
  6. // @author notshoelaze
  7. // @match https://*.youtube.com/*
  8. // @require https://unpkg.com/vue@2.6.10/dist/vue.js
  9. // @require https://unpkg.com/xfetch-js@0.3.4/xfetch.min.js
  10. // @require https://unpkg.com/@ffmpeg/ffmpeg@0.6.1/dist/ffmpeg.min.js
  11. // @require https://bundle.run/p-queue@6.3.0
  12. // @grant GM_xmlhttpRequest
  13. // @grant GM_info
  14. // @grant GM_setValue
  15. // @grant GM_getValue
  16. // @grant unsafeWindow
  17. // @run-at document-end
  18. // @connect googlevideo.com
  19. // @compatible firefox >=52
  20. // @compatible chrome >=55
  21. // @license MIT
  22. // ==/UserScript==
  23.  
  24. ;(function () {
  25. 'use strict'
  26. if (
  27. window.top === window.self &&
  28. GM_info.scriptHandler === 'Tampermonkey' &&
  29. GM_info.version === '4.18.0' &&
  30. GM_getValue('tampermonkey_breaks_should_alert', true)
  31. ) {
  32. alert(
  33. `Tampermonkey recently release a breaking change / bug in version 4.18.0 that breaks this script, which is fixed in newer version of Tampermonkey right now. You should update it or switch to Violentmonkey instead.`
  34. )
  35. GM_setValue('tampermonkey_breaks_should_alert', false)
  36. }
  37. const DEBUG = true
  38. const createLogger = (console, tag) =>
  39. Object.keys(console)
  40. .map(k => [k, (...args) => (DEBUG ? console[k](tag + ': ' + args[0], ...args.slice(1)) : void 0)])
  41. .reduce((acc, [k, fn]) => ((acc[k] = fn), acc), {})
  42. const logger = createLogger(console, 'YTDL')
  43. const sleep = ms => new Promise(res => setTimeout(res, ms))
  44.  
  45. const LANG_FALLBACK = 'en'
  46. const LOCALE = {
  47. en: {
  48. togglelinks: 'Show/Hide Links',
  49. stream: 'Stream',
  50. adaptive: 'Adaptive (No Sound)',
  51. videoid: 'Video ID: ',
  52. inbrowser_adaptive_merger: 'Online Adaptive Video & Audio Merger (FFmpeg)',
  53. dlmp4: 'Download high-resolution mp4 in one click',
  54. get_video_failed: 'Failed to get video infomation for unknown reason, refresh the page may work.',
  55. live_stream_disabled_message: 'Local YouTube Downloader is not available for live stream'
  56. },
  57.  
  58. }
  59. for (const [lang, data] of Object.entries(LOCALE)) {
  60. if (lang === LANG_FALLBACK) continue
  61. for (const key of Object.keys(LOCALE[LANG_FALLBACK])) {
  62. if (!(key in data)) {
  63. data[key] = LOCALE[LANG_FALLBACK][key]
  64. }
  65. }
  66. }
  67. const findLang = l => {
  68. l = l.replace('-Hant', '') // special case for zh-Hant-TW
  69. // language resolution logic: zh-tw --(if not exists)--> zh --(if not exists)--> LANG_FALLBACK(en)
  70. l = l.toLowerCase().replace('_', '-')
  71. if (l in LOCALE) return l
  72. else if (l.length > 2) return findLang(l.split('-')[0])
  73. else return LANG_FALLBACK
  74. }
  75. const getLangCode = () => {
  76. const html = document.querySelector('html')
  77. if (html) {
  78. return html.lang
  79. } else {
  80. return navigator.language
  81. }
  82. }
  83. const $ = (s, x = document) => x.querySelector(s)
  84. const $el = (tag, opts) => {
  85. const el = document.createElement(tag)
  86. Object.assign(el, opts)
  87. return el
  88. }
  89. const escapeRegExp = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  90. const parseDecsig = data => {
  91. try {
  92. if (data.startsWith('var script')) {
  93. // they inject the script via script tag
  94. const obj = {}
  95. const document = {
  96. createElement: () => obj,
  97. head: { appendChild: () => {} }
  98. }
  99. eval(data)
  100. data = obj.innerHTML
  101. }
  102. const fnnameresult = /=([a-zA-Z0-9\$_]+?)\(decodeURIComponent/.exec(data)
  103. const fnname = fnnameresult[1]
  104. const _argnamefnbodyresult = new RegExp(escapeRegExp(fnname) + '=function\\((.+?)\\){((.+)=\\2.+?)}').exec(
  105. data
  106. )
  107. const [_, argname, fnbody] = _argnamefnbodyresult
  108. const helpernameresult = /;([a-zA-Z0-9$_]+?)\..+?\(/.exec(fnbody)
  109. const helpername = helpernameresult[1]
  110. const helperresult = new RegExp('var ' + escapeRegExp(helpername) + '={[\\s\\S]+?};').exec(data)
  111. const helper = helperresult[0]
  112. logger.log(`parsedecsig result: %s=>{%s\n%s}`, argname, helper, fnbody)
  113. return new Function([argname], helper + '\n' + fnbody)
  114. } catch (e) {
  115. logger.error('parsedecsig error: %o', e)
  116. logger.info('script content: %s', data)
  117. logger.info(
  118. 'If you encounter this error, please copy the full "script content" to https://pastebin.com/ for me.'
  119. )
  120. }
  121. }
  122. const parseQuery = s => [...new URLSearchParams(s).entries()].reduce((acc, [k, v]) => ((acc[k] = v), acc), {})
  123. const parseResponse = (id, playerResponse, decsig) => {
  124. logger.log(`video %s playerResponse: %o`, id, playerResponse)
  125. let stream = []
  126. if (playerResponse.streamingData.formats) {
  127. stream = playerResponse.streamingData.formats.map(x =>
  128. Object.assign({}, x, parseQuery(x.cipher || x.signatureCipher))
  129. )
  130. logger.log(`video %s stream: %o`, id, stream)
  131. for (const obj of stream) {
  132. if (obj.s) {
  133. obj.s = decsig(obj.s)
  134. obj.url += `&${obj.sp}=${encodeURIComponent(obj.s)}`
  135. }
  136. }
  137. }
  138.  
  139. let adaptive = []
  140. if (playerResponse.streamingData.adaptiveFormats) {
  141. adaptive = playerResponse.streamingData.adaptiveFormats.map(x =>
  142. Object.assign({}, x, parseQuery(x.cipher || x.signatureCipher))
  143. )
  144. logger.log(`video %s adaptive: %o`, id, adaptive)
  145. for (const obj of adaptive) {
  146. if (obj.s) {
  147. obj.s = decsig(obj.s)
  148. obj.url += `&${obj.sp}=${encodeURIComponent(obj.s)}`
  149. }
  150. }
  151. }
  152. logger.log(`video %s result: %o`, id, { stream, adaptive })
  153. return { stream, adaptive, details: playerResponse.videoDetails, playerResponse }
  154. }
  155.  
  156. // video downloader
  157. const xhrDownloadUint8Array = async ({ url, contentLength }, progressCb) => {
  158. if (typeof contentLength === 'string') contentLength = parseInt(contentLength)
  159. progressCb({
  160. loaded: 0,
  161. total: contentLength,
  162. speed: 0
  163. })
  164. const chunkSize = 65536
  165. const getBuffer = (start, end) =>
  166. fetch(url + `&range=${start}-${end ? end - 1 : ''}`).then(r => r.arrayBuffer())
  167. const data = new Uint8Array(contentLength)
  168. let downloaded = 0
  169. const queue = new pQueue.default({ concurrency: 6 })
  170. const startTime = Date.now()
  171. const ps = []
  172. for (let start = 0; start < contentLength; start += chunkSize) {
  173. const exceeded = start + chunkSize > contentLength
  174. const curChunkSize = exceeded ? contentLength - start : chunkSize
  175. const end = exceeded ? null : start + chunkSize
  176. const p = queue.add(() => {
  177. console.log('dl start', url, start, end)
  178. return getBuffer(start, end)
  179. .then(buf => {
  180. console.log('dl done', url, start, end)
  181. downloaded += curChunkSize
  182. data.set(new Uint8Array(buf), start)
  183. const ds = (Date.now() - startTime + 1) / 1000
  184. progressCb({
  185. loaded: downloaded,
  186. total: contentLength,
  187. speed: downloaded / ds
  188. })
  189. })
  190. .catch(err => {
  191. queue.clear()
  192. alert('Download error')
  193. })
  194. })
  195. ps.push(p)
  196. }
  197. await Promise.all(ps)
  198. return data
  199. }
  200.  
  201. const ffWorker = FFmpeg.createWorker({
  202. logger: DEBUG ? m => logger.log(m.message) : () => {}
  203. })
  204. let ffWorkerLoaded = false
  205. const mergeVideo = async (video, audio) => {
  206. if (!ffWorkerLoaded) await ffWorker.load()
  207. await ffWorker.write('video.mp4', video)
  208. await ffWorker.write('audio.mp4', audio)
  209. await ffWorker.run('-i video.mp4 -i audio.mp4 -c copy output.mp4', {
  210. input: ['video.mp4', 'audio.mp4'],
  211. output: 'output.mp4'
  212. })
  213. const { data } = await ffWorker.read('output.mp4')
  214. await ffWorker.remove('output.mp4')
  215. return data
  216. }
  217. const triggerDownload = (url, filename) => {
  218. const a = document.createElement('a')
  219. a.href = url
  220. a.download = filename
  221. document.body.appendChild(a)
  222. a.click()
  223. a.remove()
  224. }
  225. const dlModalTemplate = `
  226. <div style="width: 100%; height: 100%;">
  227. <div v-if="merging" style="height: 100%; width: 100%; display: flex; justify-content: center; align-items: center; font-size: 24px;">Merging video, please wait...</div>
  228. <div v-else style="height: 100%; width: 100%; display: flex; flex-direction: column;">
  229. <div style="flex: 1; margin: 10px;">
  230. <p style="font-size: 24px;">Video</p>
  231. <progress style="width: 100%;" :value="video.progress" min="0" max="100"></progress>
  232. <div style="display: flex; justify-content: space-between;">
  233. <span>{{video.speed}} kB/s</span>
  234. <span>{{video.loaded}}/{{video.total}} MB</span>
  235. </div>
  236. </div>
  237. <div style="flex: 1; margin: 10px;">
  238. <p style="font-size: 24px;">Audio</p>
  239. <progress style="width: 100%;" :value="audio.progress" min="0" max="100"></progress>
  240. <div style="display: flex; justify-content: space-between;">
  241. <span>{{audio.speed}} kB/s</span>
  242. <span>{{audio.loaded}}/{{audio.total}} MB</span>
  243. </div>
  244. </div>
  245. </div>
  246. </div>
  247. `
  248. function openDownloadModel(adaptive, title) {
  249. const win = open(
  250. '',
  251. 'Video Download',
  252. `toolbar=no,height=${screen.height / 2},width=${screen.width / 2},left=${screenLeft},top=${screenTop}`
  253. )
  254. const div = win.document.createElement('div')
  255. win.document.body.appendChild(div)
  256. win.document.title = `Downloading "${title}"`
  257. const dlModalApp = new Vue({
  258. template: dlModalTemplate,
  259. data() {
  260. return {
  261. video: {
  262. progress: 0,
  263. total: 0,
  264. loaded: 0,
  265. speed: 0
  266. },
  267. audio: {
  268. progress: 0,
  269. total: 0,
  270. loaded: 0,
  271. speed: 0
  272. },
  273. merging: false
  274. }
  275. },
  276. methods: {
  277. async start(adaptive, title) {
  278. win.onbeforeunload = () => true
  279. // YouTube's default order is descending by video quality
  280. const videoObj = adaptive
  281. .filter(x => x.mimeType.includes('video/mp4') || x.mimeType.includes('video/webm'))
  282. .map(v => {
  283. const [_, quality, fps] = /(\d+)p(\d*)/.exec(v.qualityLabel)
  284. v.qualityNum = parseInt(quality)
  285. v.fps = fps ? parseInt(fps) : 30
  286. return v
  287. })
  288. .sort((a, b) => {
  289. if (a.qualityNum === b.qualityNum) return b.fps - a.fps // ex: 30-60=-30, then a will be put before b
  290. return b.qualityNum - a.qualityNum
  291. })[0]
  292. const audioObj = adaptive.find(x => x.mimeType.includes('audio/mp4'))
  293. const vPromise = xhrDownloadUint8Array(videoObj, e => {
  294. this.video.progress = (e.loaded / e.total) * 100
  295. this.video.loaded = (e.loaded / 1024 / 1024).toFixed(2)
  296. this.video.total = (e.total / 1024 / 1024).toFixed(2)
  297. this.video.speed = (e.speed / 1024).toFixed(2)
  298. })
  299. const aPromise = xhrDownloadUint8Array(audioObj, e => {
  300. this.audio.progress = (e.loaded / e.total) * 100
  301. this.audio.loaded = (e.loaded / 1024 / 1024).toFixed(2)
  302. this.audio.total = (e.total / 1024 / 1024).toFixed(2)
  303. this.audio.speed = (e.speed / 1024).toFixed(2)
  304. })
  305. const [varr, aarr] = await Promise.all([vPromise, aPromise])
  306. this.merging = true
  307. win.onunload = () => {
  308. // trigger download when user close it
  309. const bvurl = URL.createObjectURL(new Blob([varr]))
  310. const baurl = URL.createObjectURL(new Blob([aarr]))
  311. triggerDownload(bvurl, title + '-videoonly.mp4')
  312. triggerDownload(baurl, title + '-audioonly.mp4')
  313. }
  314. const result = await Promise.race([mergeVideo(varr, aarr), sleep(1000 * 25).then(() => null)])
  315. if (!result) {
  316. alert('An error has occurred when merging video')
  317. const bvurl = URL.createObjectURL(new Blob([varr]))
  318. const baurl = URL.createObjectURL(new Blob([aarr]))
  319. triggerDownload(bvurl, title + '-videoonly.mp4')
  320. triggerDownload(baurl, title + '-audioonly.mp4')
  321. return this.close()
  322. }
  323. this.merging = false
  324. const url = URL.createObjectURL(new Blob([result]))
  325. triggerDownload(url, title + '.mp4')
  326. win.onbeforeunload = null
  327. win.onunload = null
  328. win.close()
  329. }
  330. }
  331. }).$mount(div)
  332. dlModalApp.start(adaptive, title)
  333. }
  334.  
  335. const template = `
  336. <div class="box" :class="{'dark':dark}">
  337. <template v-if="!isLiveStream">
  338. <div v-if="adaptive.length" class="of-h t-center c-pointer lh-20">
  339. <a class="fs-14px" @click="dlmp4" v-text="strings.dlmp4"></a>
  340. </div>
  341. <div @click="hide=!hide" class="box-toggle div-a t-center fs-14px c-pointer lh-20" v-text="strings.togglelinks"></div>
  342. <div :class="{'hide':hide}">
  343. <div class="t-center fs-14px" v-text="strings.videoid+id"></div>
  344. <div class="d-flex">
  345. <div class="f-1 of-h">
  346. <div class="t-center fs-14px" v-text="strings.stream"></div>
  347. <a class="ytdl-link-btn fs-14px" target="_blank" v-for="vid in stream" :href="vid.url" :title="vid.type" v-text="formatStreamText(vid)"></a>
  348. </div>
  349. <div class="f-1 of-h">
  350. <div class="t-center fs-14px" v-text="strings.adaptive"></div>
  351. <a class="ytdl-link-btn fs-14px" target="_blank" v-for="vid in adaptive" :href="vid.url" :title="vid.type" v-text="formatAdaptiveText(vid)"></a>
  352. </div>
  353. </div>
  354. <div class="of-h t-center">
  355. <a class="fs-14px" href="https://maple3142.github.io/mergemp4/" target="_blank" v-text="strings.inbrowser_adaptive_merger"></a>
  356. </div>
  357. </div>
  358. </template>
  359. <template v-else>
  360. <div class="t-center fs-14px lh-20" v-text="strings.live_stream_disabled_message"></div>
  361. </template>
  362. </div>
  363. `.slice(1)
  364. const app = new Vue({
  365. data() {
  366. return {
  367. hide: true,
  368. id: '',
  369. isLiveStream: false,
  370. stream: [],
  371. adaptive: [],
  372. details: null,
  373. dark: false,
  374. lang: findLang(getLangCode())
  375. }
  376. },
  377. computed: {
  378. strings() {
  379. return LOCALE[this.lang.toLowerCase()]
  380. }
  381. },
  382. methods: {
  383. dlmp4() {
  384. openDownloadModel(this.adaptive, this.details.title)
  385. },
  386. formatStreamText(vid) {
  387. return [vid.qualityLabel, vid.quality].filter(x => x).join(': ')
  388. },
  389. formatAdaptiveText(vid) {
  390. let str = [vid.qualityLabel, vid.mimeType].filter(x => x).join(': ')
  391. if (vid.mimeType.includes('audio')) {
  392. str += ` ${Math.round(vid.bitrate / 1000)}kbps`
  393. }
  394. return str
  395. }
  396. },
  397. template
  398. })
  399. logger.log(`default language: %s`, app.lang)
  400.  
  401. // attach element
  402. const shadowHost = $el('div')
  403. const shadow = shadowHost.attachShadow ? shadowHost.attachShadow({ mode: 'closed' }) : shadowHost // no shadow dom
  404. logger.log('shadowHost: %o', shadowHost)
  405. const container = $el('div')
  406. shadow.appendChild(container)
  407. app.$mount(container)
  408.  
  409. if (DEBUG && typeof unsafeWindow !== 'undefined') {
  410. // expose some functions for debugging
  411. unsafeWindow.$app = app
  412. unsafeWindow.parseQuery = parseQuery
  413. unsafeWindow.parseDecsig = parseDecsig
  414. unsafeWindow.parseResponse = parseResponse
  415. }
  416. const load = async playerResponse => {
  417. try {
  418. const basejs =
  419. (typeof ytplayer !== 'undefined' && 'config' in ytplayer && ytplayer.config.assets
  420. ? 'https://' + location.host + ytplayer.config.assets.js
  421. : 'web_player_context_config' in ytplayer
  422. ? 'https://' + location.host + ytplayer.web_player_context_config.jsUrl
  423. : null) || $('script[src$="base.js"]').src
  424. const decsig = await xf.get(basejs).text(parseDecsig)
  425. const id = parseQuery(location.search).v
  426. const data = parseResponse(id, playerResponse, decsig)
  427. logger.log('video loaded: %s', id)
  428. app.isLiveStream = data.playerResponse.playabilityStatus.liveStreamability != null
  429. app.id = id
  430. app.stream = data.stream
  431. app.adaptive = data.adaptive
  432. app.details = data.details
  433.  
  434. const actLang = getLangCode()
  435. if (actLang != null) {
  436. const lang = findLang(actLang)
  437. logger.log('youtube ui lang: %s', actLang)
  438. logger.log('ytdl lang:', lang)
  439. app.lang = lang
  440. }
  441. } catch (err) {
  442. alert(app.strings.get_video_failed)
  443. logger.error('load', err)
  444. }
  445. }
  446.  
  447. // hook fetch response
  448. const ff = fetch
  449. unsafeWindow.fetch = (...args) => {
  450. if (args[0] instanceof Request) {
  451. return ff(...args).then(resp => {
  452. if (resp.url.includes('player')) {
  453. resp.clone().json().then(load)
  454. }
  455. return resp
  456. })
  457. }
  458. return ff(...args)
  459. }
  460.  
  461. // attach element
  462. const it = setInterval(() => {
  463. const el =
  464. $('ytd-watch-metadata') ||
  465. $('#info-contents') ||
  466. $('#watch-header') ||
  467. $('.page-container:not([hidden]) ytm-item-section-renderer>lazy-list')
  468. if (el && !el.contains(shadowHost)) {
  469. el.appendChild(shadowHost)
  470. clearInterval(it)
  471. }
  472. }, 100)
  473.  
  474. // init
  475. unsafeWindow.addEventListener('load', () => {
  476. const firstResp = unsafeWindow?.ytplayer?.config?.args?.raw_player_response
  477. if (firstResp) {
  478. load(firstResp)
  479. }
  480. })
  481.  
  482. // listen to dark mode toggle
  483. const $html = $('html')
  484. new MutationObserver(() => {
  485. app.dark = $html.getAttribute('dark') !== null
  486. }).observe($html, { attributes: true })
  487. app.dark = $html.getAttribute('dark') !== null
  488.  
  489. const css = `
  490. .hide{
  491. display: none;
  492. }
  493. .t-center{
  494. text-align: center;
  495. }
  496. .d-flex{
  497. display: flex;
  498. }
  499. .f-1{
  500. flex: 1;
  501. }
  502. .fs-14px{
  503. font-size: 14px;
  504. }
  505. .of-h{
  506. overflow: hidden;
  507. }
  508. .box{
  509. padding-top: .5em;
  510. padding-bottom: .5em;
  511. border-bottom: 1px solid var(--yt-border-color);
  512. font-family: Arial;
  513. }
  514. .box-toggle{
  515. margin: 3px;
  516. user-select: none;
  517. -moz-user-select: -moz-none;
  518. }
  519. .ytdl-link-btn{
  520. display: block;
  521. border: 1px solid !important;
  522. border-radius: 3px;
  523. text-decoration: none !important;
  524. outline: 0;
  525. text-align: center;
  526. padding: 2px;
  527. margin: 5px;
  528. color: black;
  529. }
  530. a, .div-a{
  531. text-decoration: none;
  532. color: var(--yt-button-color, inherit);
  533. }
  534. a:hover, .div-a:hover{
  535. color: var(--yt-spec-call-to-action, blue);
  536. }
  537. .box.dark{
  538. color: var(--yt-endpoint-color, var(--yt-spec-text-primary));
  539. }
  540. .box.dark .ytdl-link-btn{
  541. color: var(--yt-endpoint-color, var(--yt-spec-text-primary));
  542. }
  543. .box.dark .ytdl-link-btn:hover{
  544. color: rgba(200, 200, 255, 0.8);
  545. }
  546. .box.dark .box-toggle:hover{
  547. color: rgba(200, 200, 255, 0.8);
  548. }
  549. .c-pointer{
  550. cursor: pointer;
  551. }
  552. .lh-20{
  553. line-height: 20px;
  554. }
  555. `
  556. shadow.appendChild($el('style', { textContent: css }))
  557. })()