// ==UserScript== // @name Local YouTube Downloader // @namespace https://twitter.com/notshoelaze // @version 0.1 // @description Download YouTube videos without external service. Better Version // @author notshoelaze // @match https://*.youtube.com/* // @require https://unpkg.com/vue@2.6.10/dist/vue.js // @require https://unpkg.com/xfetch-js@0.3.4/xfetch.min.js // @require https://unpkg.com/@ffmpeg/ffmpeg@0.6.1/dist/ffmpeg.min.js // @require https://bundle.run/p-queue@6.3.0 // @grant GM_xmlhttpRequest // @grant GM_info // @grant GM_setValue // @grant GM_getValue // @grant unsafeWindow // @run-at document-end // @connect googlevideo.com // @compatible firefox >=52 // @compatible chrome >=55 // @license MIT // @downloadURL https://update.greasyfork.icu/scripts/484735/Local%20YouTube%20Downloader.user.js // @updateURL https://update.greasyfork.icu/scripts/484735/Local%20YouTube%20Downloader.meta.js // ==/UserScript== ;(function () { 'use strict' if ( window.top === window.self && GM_info.scriptHandler === 'Tampermonkey' && GM_info.version === '4.18.0' && GM_getValue('tampermonkey_breaks_should_alert', true) ) { alert( `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.` ) GM_setValue('tampermonkey_breaks_should_alert', false) } const DEBUG = true const createLogger = (console, tag) => Object.keys(console) .map(k => [k, (...args) => (DEBUG ? console[k](tag + ': ' + args[0], ...args.slice(1)) : void 0)]) .reduce((acc, [k, fn]) => ((acc[k] = fn), acc), {}) const logger = createLogger(console, 'YTDL') const sleep = ms => new Promise(res => setTimeout(res, ms)) const LANG_FALLBACK = 'en' const LOCALE = { en: { togglelinks: 'Show/Hide Links', stream: 'Stream', adaptive: 'Adaptive (No Sound)', videoid: 'Video ID: ', inbrowser_adaptive_merger: 'Online Adaptive Video & Audio Merger (FFmpeg)', dlmp4: 'Download high-resolution mp4 in one click', get_video_failed: 'Failed to get video infomation for unknown reason, refresh the page may work.', live_stream_disabled_message: 'Local YouTube Downloader is not available for live stream' }, } for (const [lang, data] of Object.entries(LOCALE)) { if (lang === LANG_FALLBACK) continue for (const key of Object.keys(LOCALE[LANG_FALLBACK])) { if (!(key in data)) { data[key] = LOCALE[LANG_FALLBACK][key] } } } const findLang = l => { l = l.replace('-Hant', '') // special case for zh-Hant-TW // language resolution logic: zh-tw --(if not exists)--> zh --(if not exists)--> LANG_FALLBACK(en) l = l.toLowerCase().replace('_', '-') if (l in LOCALE) return l else if (l.length > 2) return findLang(l.split('-')[0]) else return LANG_FALLBACK } const getLangCode = () => { const html = document.querySelector('html') if (html) { return html.lang } else { return navigator.language } } const $ = (s, x = document) => x.querySelector(s) const $el = (tag, opts) => { const el = document.createElement(tag) Object.assign(el, opts) return el } const escapeRegExp = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const parseDecsig = data => { try { if (data.startsWith('var script')) { // they inject the script via script tag const obj = {} const document = { createElement: () => obj, head: { appendChild: () => {} } } eval(data) data = obj.innerHTML } const fnnameresult = /=([a-zA-Z0-9\$_]+?)\(decodeURIComponent/.exec(data) const fnname = fnnameresult[1] const _argnamefnbodyresult = new RegExp(escapeRegExp(fnname) + '=function\\((.+?)\\){((.+)=\\2.+?)}').exec( data ) const [_, argname, fnbody] = _argnamefnbodyresult const helpernameresult = /;([a-zA-Z0-9$_]+?)\..+?\(/.exec(fnbody) const helpername = helpernameresult[1] const helperresult = new RegExp('var ' + escapeRegExp(helpername) + '={[\\s\\S]+?};').exec(data) const helper = helperresult[0] logger.log(`parsedecsig result: %s=>{%s\n%s}`, argname, helper, fnbody) return new Function([argname], helper + '\n' + fnbody) } catch (e) { logger.error('parsedecsig error: %o', e) logger.info('script content: %s', data) logger.info( 'If you encounter this error, please copy the full "script content" to https://pastebin.com/ for me.' ) } } const parseQuery = s => [...new URLSearchParams(s).entries()].reduce((acc, [k, v]) => ((acc[k] = v), acc), {}) const parseResponse = (id, playerResponse, decsig) => { logger.log(`video %s playerResponse: %o`, id, playerResponse) let stream = [] if (playerResponse.streamingData.formats) { stream = playerResponse.streamingData.formats.map(x => Object.assign({}, x, parseQuery(x.cipher || x.signatureCipher)) ) logger.log(`video %s stream: %o`, id, stream) for (const obj of stream) { if (obj.s) { obj.s = decsig(obj.s) obj.url += `&${obj.sp}=${encodeURIComponent(obj.s)}` } } } let adaptive = [] if (playerResponse.streamingData.adaptiveFormats) { adaptive = playerResponse.streamingData.adaptiveFormats.map(x => Object.assign({}, x, parseQuery(x.cipher || x.signatureCipher)) ) logger.log(`video %s adaptive: %o`, id, adaptive) for (const obj of adaptive) { if (obj.s) { obj.s = decsig(obj.s) obj.url += `&${obj.sp}=${encodeURIComponent(obj.s)}` } } } logger.log(`video %s result: %o`, id, { stream, adaptive }) return { stream, adaptive, details: playerResponse.videoDetails, playerResponse } } // video downloader const xhrDownloadUint8Array = async ({ url, contentLength }, progressCb) => { if (typeof contentLength === 'string') contentLength = parseInt(contentLength) progressCb({ loaded: 0, total: contentLength, speed: 0 }) const chunkSize = 65536 const getBuffer = (start, end) => fetch(url + `&range=${start}-${end ? end - 1 : ''}`).then(r => r.arrayBuffer()) const data = new Uint8Array(contentLength) let downloaded = 0 const queue = new pQueue.default({ concurrency: 6 }) const startTime = Date.now() const ps = [] for (let start = 0; start < contentLength; start += chunkSize) { const exceeded = start + chunkSize > contentLength const curChunkSize = exceeded ? contentLength - start : chunkSize const end = exceeded ? null : start + chunkSize const p = queue.add(() => { console.log('dl start', url, start, end) return getBuffer(start, end) .then(buf => { console.log('dl done', url, start, end) downloaded += curChunkSize data.set(new Uint8Array(buf), start) const ds = (Date.now() - startTime + 1) / 1000 progressCb({ loaded: downloaded, total: contentLength, speed: downloaded / ds }) }) .catch(err => { queue.clear() alert('Download error') }) }) ps.push(p) } await Promise.all(ps) return data } const ffWorker = FFmpeg.createWorker({ logger: DEBUG ? m => logger.log(m.message) : () => {} }) let ffWorkerLoaded = false const mergeVideo = async (video, audio) => { if (!ffWorkerLoaded) await ffWorker.load() await ffWorker.write('video.mp4', video) await ffWorker.write('audio.mp4', audio) await ffWorker.run('-i video.mp4 -i audio.mp4 -c copy output.mp4', { input: ['video.mp4', 'audio.mp4'], output: 'output.mp4' }) const { data } = await ffWorker.read('output.mp4') await ffWorker.remove('output.mp4') return data } const triggerDownload = (url, filename) => { const a = document.createElement('a') a.href = url a.download = filename document.body.appendChild(a) a.click() a.remove() } const dlModalTemplate = `
Video
Audio