Local YouTube Downloader

Get youtube raw link without external service.

As of 2018-07-25. See the latest version.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Local YouTube Downloader
// @name:zh-TW   本地 YouTube 下載器
// @name:zh-CN   本地 YouTube 下载器
// @namespace    https://blog.maple3142.net/
// @version      0.5.7
// @description  Get youtube raw link without external service.
// @description:zh-TW  不需要透過第三方的服務就能下載 YouTube 影片。
// @description:zh-CN  不需要透过第三方的服务就能下载 YouTube 影片。
// @author       maple3142
// @match        https://*.youtube.com/*
// @connect      youtube.com
// @require      https://cdnjs.cloudflare.com/ajax/libs/hyperapp/1.2.6/hyperapp.js
// @run-at       document-start
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @license      MIT
// ==/UserScript==

;(function() {
	'use strict'
	const DEBUG = false
	const create$p = console =>
		Object.keys(console)
			.map(k => [k, (...args) => (DEBUG ? console[k]('YTDL: ' + args[0], ...args.slice(1)) : void 0)])
			.reduce((acc, [k, fn]) => ((acc[k] = fn), acc), {})
	const $p = create$p(console)

	const LANG_FALLBACK = 'en'
	const LOCALE = {
		en: {
			togglelinks: 'Show/Hide Links',
			stream: 'Stream',
			adaptive: 'Adaptive',
			videoid: 'Video Id: {{id}}'
		},
		'zh-tw': {
			togglelinks: '顯示/隱藏連結',
			stream: '串流 Stream',
			adaptive: '自適應 Adaptive',
			videoid: '影片 Id: {{id}}'
		},
		zh: {
			togglelinks: '显示/隐藏连结',
			stream: '串流 Stream',
			adaptive: '自适应 Adaptive',
			videoid: '影片 Id: {{id}}'
		}
	}
	const findLang = l => {
		// language resolution logic: zh-tw --(if not exists)--> zh --(if not exists)--> LANG_FALLBACK(en)
		l = l.toLowerCase()
		if (l in LOCALE) return l
		else if (l.length > 2) return findLang(l.split('-')[0])
		else return LANG_FALLBACK
	}

	const format = s => d => s.replace(/{{(\w+?)}}/g, (m, g1) => d[g1])
	const $ = (s, x = document) => x.querySelector(s)
	const $el = (tag, opts) => {
		const el = document.createElement(tag)
		Object.assign(el, opts)
		return el
	}
	const gmxhr = o => new Promise((res, rej) => GM_xmlhttpRequest({ ...o, onload: res, onerror: rej }))
	const xhrhead = url =>
		new Promise((res, rej) => {
			const xhr = new XMLHttpRequest()
			xhr.open('HEAD', url)
			xhr.onreadystatechange = () => {
				if (xhr.readyState === xhr.DONE) {
					res(xhr.responseText)
				}
			}
			xhr.onerror = rej
			xhr.send()
		})
	const getytplayer = async () => {
		if (typeof ytplayer !== 'undefined' && ytplayer.config) return ytplayer
		$p.log('No ytplayer is founded')
		const html = await gmxhr({
			method: 'GET',
			url: 'https://www.youtube.com' + location.pathname + location.search
		}).then(r => r.responseText)
		const d = /<script >(var ytplayer[\s\S]*?)ytplayer\.load/.exec(html)
		let config = eval(d[1])
		unsafeWindow.ytplayer = {
			config
		}
		$p.log('ytplayer fetched: %o', unsafeWindow.ytplayer)
		return ytplayer
	}
	const parsedecsig = data => {
		const fnname = /\"signature\"\),.+?\.set\(.+?,(.+?)\(/.exec(data)[1]
		const [_, argname, fnbody] = new RegExp(fnname + '=function\\((.+?)\\){(.+?)}').exec(data)
		const helpername = /;(.+?)\..+?\(/.exec(fnbody)[1]
		const helper = new RegExp('var ' + helpername + '={[\\s\\S]+?};').exec(data)[0]
		return new Function([argname], helper + ';' + fnbody)
	}
	const getdecsig = path => xhrhead('https://www.youtube.com' + path).then(parsedecsig)
	const parseQuery = s =>
		Object.assign(
			...s
				.split('&')
				.map(x => x.split('='))
				.map(p => ({ [p[0]]: decodeURIComponent(p[1]) }))
		)
	const getVideo = async (id, decsig) => {
		return fetch(`https://www.youtube.com/get_video_info?video_id=${id}&el=detailpage`)
			.then(r => r.text())
			.then(async data => {
				const obj = parseQuery(data)
				$p.log(`video ${id} data: %o`, obj)
				if (obj.status === 'fail') {
					throw obj
				}
				let stream = []
				if (obj.url_encoded_fmt_stream_map) {
					stream = obj.url_encoded_fmt_stream_map.split(',').map(parseQuery)
					if (stream[0].sp && stream[0].sp.includes('signature')) {
						stream = stream
							.map(x => ({ ...x, s: decsig(x.s) }))
							.map(x => ({ ...x, url: x.url + `&signature=${x.s}` }))
					}
				}

				let adaptive = []
				if (obj.adaptive_fmts) {
					adaptive = obj.adaptive_fmts.split(',').map(parseQuery)
					if (adaptive[0].sp && adaptive[0].sp.includes('signature')) {
						adaptive = adaptive
							.map(x => ({ ...x, s: decsig(x.s) }))
							.map(x => ({ ...x, url: x.url + `&signature=${x.s}` }))
					}
				}
				$p.log(`video ${id} result: %o`, { stream, adaptive })
				return { stream, adaptive }
			})
	}
	const ytdlWorkerCode = `
const DEBUG=${DEBUG}
const $p=(${create$p.toString()})(console)
const parseQuery=${parseQuery.toString()}
const xhrhead=${xhrhead.toString()}
const parsedecsig=${parsedecsig.toString()}
const getdecsig=${getdecsig.toString()}
const getVideo=${getVideo.toString()}
self.onmessage=async e=>{
const decsig=await getdecsig(e.data.path)
const result=await getVideo(e.data.id,decsig)
postMessage(result)
}`
	const ytdlWorker = new Worker(URL.createObjectURL(new Blob([ytdlWorkerCode])))
	const workerGetVideo = (id, path) => {
		$p.log(`workerGetVideo start: ${id} ${path}`)
		return new Promise((res, rej) => {
			const callback = e => {
				ytdlWorker.removeEventListener('message', callback)
				$p.log('workerGetVideo end: %o', e.data)
				res(e.data)
			}
			ytdlWorker.addEventListener('message', callback)
			ytdlWorker.postMessage({ id, path })
		})
	}

	const { app, h } = hyperapp
	const state = {
		hide: true,
		id: '',
		stream: [],
		adaptive: [],
		lang: findLang(navigator.language),
		strings: LOCALE[findLang(navigator.language)]
	}
	$p.log(`default language: ${state.lang}`)
	const actions = {
		toggleHide: () => state => ({ hide: !state.hide }),
		setState: newstate => state => newstate,
		setLang: lang => state => {
			const target = findLang(lang)
			$p.log(`language change to: ${target}`)
			return {
				lang: target,
				strings: LOCALE[target]
			}
		},
		getState: () => state => state
	}
	const view = (state, actions) =>
		h('div', { id: 'ytdl-box' }, [
			h(
				'div',
				{ onclick: () => actions.toggleHide(), id: 'ytdl-box-toggle', className: 't-center' },
				state.strings.togglelinks
			),
			h('div', { className: state.hide ? 'hide' : '' }, [
				h('div', { className: 't-center fs-14px' }, format(state.strings.videoid, state)),
				h('div', { className: 'd-flex' }, [
					h(
						'div',
						{ className: 'f-1 of-h' },
						[h('div', { className: 't-center fs-14px' }, state.strings.stream)].concat(
							state.stream.map(x =>
								h(
									'a',
									{ href: x.url, title: x.type, target: '_blank', className: 'ytdl-link-btn' },
									x.quality || x.type
								)
							)
						)
					),
					h(
						'div',
						{ className: 'f-1 of-h' },
						[h('div', { className: 't-center fs-14px' }, state.strings.adaptive)].concat(
							state.adaptive.map(x =>
								h(
									'a',
									{ href: x.url, title: x.type, target: '_blank', className: 'ytdl-link-btn' },
									(x.quality_label ? x.quality_label + ':' : '') + x.type
								)
							)
						)
					)
				])
			])
		])
	const container = $el('div')
	const $app = app(state, actions, view, container)
	if (DEBUG) unsafeWindow.$app = $app
	const load = async id => {
		const ytplayer = await getytplayer()
		return workerGetVideo(id, ytplayer.config.assets.js)
			.then(data => {
				$p.log('video loaded: %s', id)
				$app.setState({
					id,
					stream: data.stream,
					adaptive: data.adaptive
				})
				if (ytplayer.config.args.host_language) $app.setLang(ytplayer.config.args.host_language)
			})
			.catch(err => $p.error('load', err))
	}
	let prevurl = null
	setInterval(() => {
		const el = $('#info-contents') || $('#watch-header') || $('ytm-item-section-renderer>lazy-list')
		if (el && !el.contains(container)) el.appendChild(container)
		if (location.href !== prevurl && location.pathname === '/watch') {
			prevurl = location.href
			$app.setState({
				hide: true
			})
			const id = new URLSearchParams(location.search).get('v')
			$p.log(`start loading new video: ${id}`)
			load(id)
		}
	}, 1000)
	GM_addStyle(`
.hide{
display: none;
}
.t-center{
text-align: center;
}
.d-flex{
display: flex;
}
.f-1{
flex: 1;
}
.fs-14px{
font-size: 14px;
}
.of-h{
overflow: hidden;
}
#ytdl-box{
border-bottom: 1px solid var(--yt-border-color);
}
#ytdl-box-toggle{
margin: 3px;
user-select: none;
-moz-user-select: -moz-none;
}
#ytdl-box-toggle:hover{
color: blue;
}
.ytdl-link-btn{
display: block;
border: 1px solid !important;
border-radius: 3px;
text-decoration: none !important;
outline: 0;
text-align: center;
padding: 2px;
margin: 5px;
color: black;
}
a.ytdl-link-btn{
text-decoration: none;
}
a.ytdl-link-btn:hover{
color: blue;
}
`)
})()