maple3142 / Local YouTube Downloader

// ==UserScript==
// @name         Local YouTube Downloader
// @name:zh-TW   本地 YouTube 下載器
// @name:zh-CN   本地 YouTube 下载器
// @namespace    https://blog.maple3142.net/
// @version      0.6.0
// @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
// @grant        GM_xmlhttpRequest
// @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 = (fn => o => new Promise((res, rej) => fn({ ...o, onload: res, onerror: rej })))(
		typeof GM_xmlhttpRequest === 'undefined' ? GM.xmlHttpRequest : GM_xmlhttpRequest
	)
	const xhrget = url =>
		// not sure why `fetch` doesn't work here
		new Promise((res, rej) => {
			const xhr = new XMLHttpRequest()
			xhr.open('GET', 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]
		$p.log(`parsedecsig result: ${argname} => { ${helper}\n${fnbody}}`)
		return new Function([argname], helper + '\n' + fnbody)
	}
	const getdecsig = path => xhrget('https://www.youtube.com' + path).then(parsedecsig)
	const parseQuery = s => [...new URLSearchParams(s).entries()].reduce((acc, [k, v]) => ((acc[k] = v), acc), {})
	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 workerMessageHandler = async e => {
		const decsig = await getdecsig(e.data.path)
		const result = await getVideo(e.data.id, decsig)
		self.postMessage(result)
	}
	const ytdlWorkerCode = `
const DEBUG=${DEBUG}
const $p=(${create$p.toString()})(console)
const parseQuery=${parseQuery.toString()}
const xhrget=${xhrget.toString()}
const parsedecsig=${parsedecsig.toString()}
const getdecsig=${getdecsig.toString()}
const getVideo=${getVideo.toString()}
self.onmessage=${workerMessageHandler.toString()}`
	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', { className: 'box' }, [
			h(
				'div',
				{ onclick: () => actions.toggleHide(), className: 'box-toggle t-center fs-14px' },
				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 fs-14px'
									},
									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 fs-14px'
									},
									(x.quality_label ? x.quality_label + ':' : '') + x.type
								)
							)
						)
					)
				])
			])
		])
	const shadowHost = $el('div')
	const shadow = shadowHost.attachShadow ? shadowHost.attachShadow({ mode: 'open' }) : shadowHost // no shadow dom
	const container = $el('div')
	shadow.appendChild(container)
	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(shadowHost)) el.appendChild(shadowHost)
		if (location.href !== prevurl && location.pathname === '/watch') {
			prevurl = location.href
			$app.setState({
				hide: true
			})
			const id = parseQuery(location.search).v
			$p.log(`start loading new video: ${id}`)
			load(id)
		}
	}, 1000)
	const css = `
.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;
}
.box{
	border-bottom: 1px solid var(--yt-border-color);
	font-family: Arial;
}
.box-toggle{
	margin: 3px;
	user-select: none;
	-moz-user-select: -moz-none;
}
.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;
}
`
	shadow.appendChild($el('style', { textContent: css }))
})()