【自用】-本地 YouTube 下载器【mod】

不需要透过第三方的服务就能下载 YouTube 视频。【mod自https://gf.qytechs.cn/zh-CN/scripts/369400-local-youtube-downloader】

目前为 2021-08-18 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @icon https://www.youtube.com/favicon.ico
  3. // @homepage https://gf.qytechs.cn/zh-CN/scripts/431000-%E8%87%AA%E7%94%A8-%E6%9C%AC%E5%9C%B0-youtube-%E4%B8%8B%E8%BD%BD%E5%99%A8-mod
  4. // @name 【自用】-本地 YouTube 下载器【mod】
  5. // @namespace https://blog.maple3142.net/
  6. // @version 0.9.45【自用mod】
  7. // @description 不需要透过第三方的服务就能下载 YouTube 视频。【mod自https://gf.qytechs.cn/zh-CN/scripts/369400-local-youtube-downloader】
  8. // @author maple3142
  9. // @match https://*.youtube.com/*
  10. // @require https://unpkg.com/vue@2.6.10/dist/vue.js
  11. // @require https://unpkg.com/xfetch-js@0.3.4/xfetch.min.js
  12. // @require https://unpkg.com/@ffmpeg/ffmpeg@0.6.1/dist/ffmpeg.min.js
  13. // @require https://bundle.run/p-queue@6.3.0
  14. // @grant GM_xmlhttpRequest
  15. // @grant unsafeWindow
  16. // @run-at document-start
  17. // @connect googlevideo.com
  18. // @compatible firefox >=52
  19. // @compatible chrome >=55
  20. // @license MIT
  21. // ==/UserScript==
  22. ;(function () {
  23. 'use strict'
  24. const DEBUG = true
  25. const createLogger = (console, tag) =>
  26. Object.keys(console)
  27. .map(k => [k, (...args) => (DEBUG ? console[k](tag + ': ' + args[0], ...args.slice(1)) : void 0)])
  28. .reduce((acc, [k, fn]) => ((acc[k] = fn), acc), {})
  29. const logger = createLogger(console, 'YTDL')
  30. const sleep = ms => new Promise(res => setTimeout(res, ms))
  31. const LANG_FALLBACK = 'en'
  32. const LOCALE = {
  33. en: {
  34. togglelinks: 'Show/Hide Links',
  35. stream: 'Stream',
  36. adaptive: 'Adaptive',
  37. videoid: 'Video ID: ',
  38. inbrowser_adaptive_merger: 'Online Adaptive Video & Audio Merger (FFmpeg)',
  39. dlmp4: 'Download high-resolution mp4 in one click',
  40. get_video_failed: 'Failed to get video infomation for unknown reason, refresh the page may work.',
  41. live_stream_disabled_message: 'Local YouTube Downloader is not available for live stream'
  42. },
  43. 'zh-tw': {
  44. togglelinks: '顯示 / 隱藏連結',
  45. stream: '串流 Stream',
  46. adaptive: '自適應 Adaptive',
  47. videoid: '影片 ID: ',
  48. inbrowser_adaptive_merger: '線上自適應影片及音訊合成工具 (FFmpeg)',
  49. dlmp4: '一鍵下載高畫質 mp4',
  50. get_video_failed: '無法取得影片資訊,重新整理頁面可能會有效果。',
  51. live_stream_disabled_message: '因為是直播的緣故,本地 YouTube 下載器的功能是停用的。'
  52. },
  53. 'zh-hk': {
  54. togglelinks: '顯示/隱藏連結',
  55. stream: '串流 Stream',
  56. adaptive: '自動適應 Adaptive',
  57. videoid: '影片 ID: ',
  58. inbrowser_adaptive_merger: '網上自動適應影片及音訊合成工具 (FFmpeg)',
  59. dlmp4: '一 click 下載高畫質 mp4',
  60. get_video_failed: '無法取得影片資訊,重新整理頁面可能會有效果。',
  61. live_stream_disabled_message: '本地 YouTube 下載器無法用於直播。'
  62. },
  63. zh: {
  64. togglelinks: '显示/隐藏链接',
  65. stream: '串流 Stream',
  66. adaptive: '自适应 Adaptive',
  67. videoid: '视频 ID: ',
  68. inbrowser_adaptive_merger: '线上自适应视频及音频合成工具 (FFmpeg)',
  69. dlmp4: '一键下载高画质 mp4',
  70. get_video_failed: '无法取得影片资讯,重新整理页面可能会有效果。',
  71. live_stream_disabled_message: '因为是直播,本地 YouTube 下载器的功能已被禁用。'
  72. },
  73. ja: {
  74. togglelinks: 'リンク表示・非表示',
  75. stream: 'ストリーミング',
  76. adaptive: 'アダプティブ',
  77. videoid: 'ビデオ ID: ',
  78. inbrowser_adaptive_merger: 'ビデオとオーディオを合併するオンラインツール (FFmpeg)',
  79. dlmp4: 'ワンクリックで高解像度の mp4 をダウンロード',
  80. live_stream_disabled_message: 'ライブ配信のため、ローカル YouTube ダウンローダーは無効になっています。'
  81. },
  82. kr: {
  83. togglelinks: '링크 보이기 · 숨기기',
  84. stream: '스트리밍',
  85. adaptive: '적응 (어댑티브)',
  86. videoid: '비디오 ID: ',
  87. inbrowser_adaptive_merger: '비디오와 오디오를 합병하는 온라인 도구 (FFmpeg)',
  88. dlmp4: '한 번의 클릭으로 고해상도 mp4 다운로드'
  89. },
  90. es: {
  91. togglelinks: 'Mostrar/Ocultar Links',
  92. stream: 'Stream',
  93. adaptive: 'Adaptable',
  94. videoid: 'Id del Video: ',
  95. inbrowser_adaptive_merger: 'Acoplar Audio a Video (FFmpeg)'
  96. },
  97. he: {
  98. togglelinks: 'הצג/הסתר קישורים',
  99. stream: 'סטרים',
  100. adaptive: 'אדפטיבי',
  101. videoid: 'מזהה סרטון: '
  102. },
  103. fr: {
  104. togglelinks: 'Afficher/Masquer les liens',
  105. stream: 'Stream',
  106. adaptive: 'Adaptative',
  107. videoid: 'ID vidéo: ',
  108. inbrowser_adaptive_merger: 'Fusionner vidéos et audios adaptatifs dans le navigateur (FFmpeg)',
  109. dlmp4: 'Téléchargez la plus haute résolution mp4 en un clic'
  110. },
  111. pl: {
  112. togglelinks: 'Pokaż/Ukryj Linki',
  113. stream: 'Stream',
  114. adaptive: 'Adaptywne',
  115. videoid: 'ID filmu: ',
  116. inbrowser_adaptive_merger: 'Połącz audio i wideo adaptywne w przeglądarce (FFmpeg)',
  117. dlmp4: 'Pobierz .mp4 w najwyższej jakości'
  118. },
  119. hi: {
  120. togglelinks: 'लिंक टॉगल करें',
  121. stream: 'स्ट्रीमिंग (Stream)',
  122. adaptive: 'अनुकूली (Adaptive)',
  123. videoid: 'वीडियो आईडी: {{id}}'
  124. },
  125. ru: {
  126. togglelinks: 'Показать/Cкрыть ссылки',
  127. stream: 'Поток',
  128. adaptive: 'Адаптивный',
  129. videoid: 'Идентификатор видео: ',
  130. inbrowser_adaptive_merger: 'Адаптивное слияние видео и аудио онлайн (FFmpeg)',
  131. dlmp4: 'Скачать mp4 в высоком разрешении в один клик',
  132. get_video_failed:
  133. 'Не удалось получить информацию о видео по неизвестной причине, попробуйте обновить страницу.',
  134. live_stream_disabled_message: 'Локальный загрузчик YouTube недоступен для прямой трансляции'
  135. },
  136. ua: {
  137. togglelinks: 'Показати/Приховати посилання',
  138. stream: 'Потік',
  139. adaptive: 'Адаптивний',
  140. videoid: 'Ідентифікатор відео: ',
  141. inbrowser_adaptive_merger: 'Адаптивне злиття відео і аудіо онлайн (FFmpeg)',
  142. dlmp4: 'Завантажити mp4 у високій роздільній здатності в один клік',
  143. get_video_failed:
  144. 'Не вдалося отримати інформацію про відео з невідомої причини, спробуйте оновити сторінку.',
  145. live_stream_disabled_message: 'Локальний завантажувач YouTube недоступний для прямої трансляції'
  146. },
  147. cs: {
  148. togglelinks: 'Zobrazit/Skrýt odkazy',
  149. stream: 'Stream',
  150. adaptive: 'Adaptivní',
  151. videoid: 'ID videa: ',
  152. inbrowser_adaptive_merger: 'Online nástroj pro sloučení videa a audia (FFmpeg)',
  153. dlmp4: 'Stáhnout video mp4 jedním kliknutím ve vysokém rozlišení',
  154. get_video_failed: 'Nepodařilo se nahrát informace o videu. Zkuste obnovit stránku (F5).',
  155. live_stream_disabled_message: 'Local YouTube Downloader není dostupný pro živé vysílání'
  156. }
  157. }
  158. for (const [lang, data] of Object.entries(LOCALE)) {
  159. if (lang === LANG_FALLBACK) continue
  160. for (const key of Object.keys(LOCALE[LANG_FALLBACK])) {
  161. if (!(key in data)) {
  162. data[key] = LOCALE[LANG_FALLBACK][key]
  163. }
  164. }
  165. }
  166. const findLang = l => {
  167. l = l.replace('-Hant', '') // special case for zh-Hant-TW
  168. // language resolution logic: zh-tw --(if not exists)--> zh --(if not exists)--> LANG_FALLBACK(en)
  169. l = l.toLowerCase().replace('_', '-')
  170. if (l in LOCALE) return l
  171. else if (l.length > 2) return findLang(l.split('-')[0])
  172. else return LANG_FALLBACK
  173. }
  174. const getLangCode = () => {
  175. const html = document.querySelector('html')
  176. if (html) {
  177. return html.lang
  178. } else {
  179. return navigator.language
  180. }
  181. }
  182. const $ = (s, x = document) => x.querySelector(s)
  183. const $el = (tag, opts) => {
  184. const el = document.createElement(tag)
  185. Object.assign(el, opts)
  186. return el
  187. }
  188. const escapeRegExp = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  189. const parseDecsig = data => {
  190. try {
  191. if (data.startsWith('var script')) {
  192. // they inject the script via script tag
  193. const obj = {}
  194. const document = {
  195. createElement: () => obj,
  196. head: { appendChild: () => {} }
  197. }
  198. eval(data)
  199. data = obj.innerHTML
  200. }
  201. const fnnameresult = /=([a-zA-Z0-9\$]+?)\(decodeURIComponent/.exec(data)
  202. const fnname = fnnameresult[1]
  203. const _argnamefnbodyresult = new RegExp(escapeRegExp(fnname) + '=function\\((.+?)\\){((.+)=\\2.+?)}').exec(
  204. data
  205. )
  206. const [_, argname, fnbody] = _argnamefnbodyresult
  207. const helpernameresult = /;(.+?)\..+?\(/.exec(fnbody)
  208. const helpername = helpernameresult[1]
  209. const helperresult = new RegExp('var ' + escapeRegExp(helpername) + '={[\\s\\S]+?};').exec(data)
  210. const helper = helperresult[0]
  211. logger.log(`parsedecsig result: %s=>{%s\n%s}`, argname, helper, fnbody)
  212. return new Function([argname], helper + '\n' + fnbody)
  213. } catch (e) {
  214. logger.error('parsedecsig error: %o', e)
  215. logger.info('script content: %s', data)
  216. logger.info(
  217. 'If you encounter this error, please copy the full "script content" to https://pastebin.com/ for me.'
  218. )
  219. }
  220. }
  221. const parseQuery = s => [...new URLSearchParams(s).entries()].reduce((acc, [k, v]) => ((acc[k] = v), acc), {})
  222. const parseResponse = (id, playerResponse, decsig) => {
  223. logger.log(`video %s playerResponse: %o`, id, playerResponse)
  224. let stream = []
  225. if (playerResponse.streamingData.formats) {
  226. stream = playerResponse.streamingData.formats.map(x =>
  227. Object.assign({}, x, parseQuery(x.cipher || x.signatureCipher))
  228. )
  229. logger.log(`video %s stream: %o`, id, stream)
  230. for (const obj of stream) {
  231. if (obj.s) {
  232. obj.s = decsig(obj.s)
  233. obj.url += `&${obj.sp}=${encodeURIComponent(obj.s)}`
  234. }
  235. }
  236. }
  237. let adaptive = []
  238. if (playerResponse.streamingData.adaptiveFormats) {
  239. adaptive = playerResponse.streamingData.adaptiveFormats.map(x =>
  240. Object.assign({}, x, parseQuery(x.cipher || x.signatureCipher))
  241. )
  242. logger.log(`video %s adaptive: %o`, id, adaptive)
  243. for (const obj of adaptive) {
  244. if (obj.s) {
  245. obj.s = decsig(obj.s)
  246. obj.url += `&${obj.sp}=${encodeURIComponent(obj.s)}`
  247. }
  248. }
  249. }
  250. logger.log(`video %s result: %o`, id, { stream, adaptive })
  251. return { stream, adaptive, details: playerResponse.videoDetails, playerResponse }
  252. }
  253. const determineChunksNum = size => {
  254. const n = Math.ceil(size / (1024 * 1024 * 3)) // 3 MB
  255. return n
  256. }
  257. // video downloader
  258. const xhrDownloadUint8Array = async ({ url, contentLength }, progressCb) => {
  259. if (typeof contentLength === 'string') contentLength = parseInt(contentLength)
  260. progressCb({
  261. loaded: 0,
  262. total: contentLength,
  263. speed: 0
  264. })
  265. const chunkSize = Math.floor(contentLength / determineChunksNum(contentLength))
  266. const getBuffer = (start, end) =>
  267. new Promise((res, rej) => {
  268. const xhr = {}
  269. xhr.responseType = 'arraybuffer'
  270. xhr.method = 'GET'
  271. xhr.url = url
  272. xhr.headers = {
  273. 'User-Agent':
  274. 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.124 Safari/537.36',
  275. Range: `bytes=${start}-${end ? end - 1 : ''}`,
  276. 'Accept-Encoding': 'identity',
  277. 'Accept-Language': 'en-us,en;q=0.5',
  278. 'Accept-Charset': 'ISO-8859-1,utf-8;q=0.7,*;q=0.7'
  279. }
  280. xhr.onload = obj => {
  281. if (obj.status >= 200 && obj.status < 300) {
  282. res(obj.response)
  283. } else {
  284. rej(obj)
  285. }
  286. }
  287. GM_xmlhttpRequest(xhr)
  288. })
  289. const data = new Uint8Array(contentLength)
  290. let downloaded = 0
  291. const queue = new pQueue.default({ concurrency: 5 })
  292. const startTime = Date.now()
  293. const ps = []
  294. for (let start = 0; start < contentLength; start += chunkSize) {
  295. const exceeded = start + chunkSize > contentLength
  296. const curChunkSize = exceeded ? contentLength - start : chunkSize
  297. const end = exceeded ? null : start + chunkSize
  298. const p = queue.add(() =>
  299. getBuffer(start, end)
  300. .then(buf => {
  301. downloaded += curChunkSize
  302. data.set(new Uint8Array(buf), start)
  303. const ds = (Date.now() - startTime + 1) / 1000
  304. progressCb({
  305. loaded: downloaded,
  306. total: contentLength,
  307. speed: downloaded / ds
  308. })
  309. })
  310. .catch(err => {
  311. queue.clear()
  312. alert('Download error')
  313. })
  314. )
  315. ps.push(p)
  316. }
  317. await Promise.all(ps)
  318. return data
  319. }
  320. const ffWorker = FFmpeg.createWorker({
  321. logger: DEBUG ? m => logger.log(m.message) : () => {}
  322. })
  323. let ffWorkerLoaded = false
  324. const mergeVideo = async (video, audio) => {
  325. if (!ffWorkerLoaded) await ffWorker.load()
  326. await ffWorker.write('video.mp4', video)
  327. await ffWorker.write('audio.mp4', audio)
  328. await ffWorker.run('-i video.mp4 -i audio.mp4 -c copy output.mp4', {
  329. input: ['video.mp4', 'audio.mp4'],
  330. output: 'output.mp4'
  331. })
  332. const { data } = await ffWorker.read('output.mp4')
  333. await ffWorker.remove('output.mp4')
  334. return data
  335. }
  336. const triggerDownload = (url, filename) => {
  337. const a = document.createElement('a')
  338. a.href = url
  339. a.download = filename
  340. document.body.appendChild(a)
  341. a.click()
  342. a.remove()
  343. }
  344. const dlModalTemplate = `
  345. <div style="width: 100%; height: 100%;">
  346. <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>
  347. <div v-else style="height: 100%; width: 100%; display: flex; flex-direction: column;">
  348. <div style="flex: 1; margin: 10px;">
  349. <p style="font-size: 24px;">Video</p>
  350. <progress style="width: 100%;" :value="video.progress" min="0" max="100"></progress>
  351. <div style="display: flex; justify-content: space-between;">
  352. <span>{{video.speed}} kB/s</span>
  353. <span>{{video.loaded}}/{{video.total}} MB</span>
  354. </div>
  355. </div>
  356. <div style="flex: 1; margin: 10px;">
  357. <p style="font-size: 24px;">Audio</p>
  358. <progress style="width: 100%;" :value="audio.progress" min="0" max="100"></progress>
  359. <div style="display: flex; justify-content: space-between;">
  360. <span>{{audio.speed}} kB/s</span>
  361. <span>{{audio.loaded}}/{{audio.total}} MB</span>
  362. </div>
  363. </div>
  364. </div>
  365. </div>
  366. `
  367. function openDownloadModel(adaptive, title) {
  368. const win = open(
  369. '',
  370. 'Video Download',
  371. `toolbar=no,height=${screen.height / 2},width=${screen.width / 2},left=${screenLeft},top=${screenTop}`
  372. )
  373. const div = win.document.createElement('div')
  374. win.document.body.appendChild(div)
  375. win.document.title = `Downloading "${title}"`
  376. const dlModalApp = new Vue({
  377. template: dlModalTemplate,
  378. data() {
  379. return {
  380. video: {
  381. progress: 0,
  382. total: 0,
  383. loaded: 0,
  384. speed: 0
  385. },
  386. audio: {
  387. progress: 0,
  388. total: 0,
  389. loaded: 0,
  390. speed: 0
  391. },
  392. merging: false
  393. }
  394. },
  395. methods: {
  396. async start(adaptive, title) {
  397. win.onbeforeunload = () => true
  398. // YouTube's default order is descending by video quality
  399. const videoObj = adaptive
  400. .filter(x => x.mimeType.includes('video/mp4') || x.mimeType.includes('video/webm'))
  401. .map(v => {
  402. const [_, quality, fps] = /(\d+)p(\d*)/.exec(v.qualityLabel)
  403. v.qualityNum = parseInt(quality)
  404. v.fps = fps ? parseInt(fps) : 30
  405. return v
  406. })
  407. .sort((a, b) => {
  408. if (a.qualityNum === b.qualityNum) return b.fps - a.fps // ex: 30-60=-30, then a will be put before b
  409. return b.qualityNum - a.qualityNum
  410. })[0]
  411. const audioObj = adaptive.find(x => x.mimeType.includes('audio/mp4'))
  412. const vPromise = xhrDownloadUint8Array(videoObj, e => {
  413. this.video.progress = (e.loaded / e.total) * 100
  414. this.video.loaded = (e.loaded / 1024 / 1024).toFixed(2)
  415. this.video.total = (e.total / 1024 / 1024).toFixed(2)
  416. this.video.speed = (e.speed / 1024).toFixed(2)
  417. })
  418. const aPromise = xhrDownloadUint8Array(audioObj, e => {
  419. this.audio.progress = (e.loaded / e.total) * 100
  420. this.audio.loaded = (e.loaded / 1024 / 1024).toFixed(2)
  421. this.audio.total = (e.total / 1024 / 1024).toFixed(2)
  422. this.audio.speed = (e.speed / 1024).toFixed(2)
  423. })
  424. const [varr, aarr] = await Promise.all([vPromise, aPromise])
  425. this.merging = true
  426. win.onunload = () => {
  427. // trigger download when user close it
  428. const bvurl = URL.createObjectURL(new Blob([varr]))
  429. const baurl = URL.createObjectURL(new Blob([aarr]))
  430. triggerDownload(bvurl, title + '-videoonly.mp4')
  431. triggerDownload(baurl, title + '-audioonly.mp4')
  432. }
  433. const result = await Promise.race([mergeVideo(varr, aarr), sleep(1000 * 25).then(() => null)])
  434. if (!result) {
  435. alert('An error has occurred when merging video')
  436. const bvurl = URL.createObjectURL(new Blob([varr]))
  437. const baurl = URL.createObjectURL(new Blob([aarr]))
  438. triggerDownload(bvurl, title + '-videoonly.mp4')
  439. triggerDownload(baurl, title + '-audioonly.mp4')
  440. return this.close()
  441. }
  442. this.merging = false
  443. const url = URL.createObjectURL(new Blob([result]))
  444. triggerDownload(url, title + '.mp4')
  445. win.onbeforeunload = null
  446. win.onunload = null
  447. win.close()
  448. }
  449. }
  450. }).$mount(div)
  451. dlModalApp.start(adaptive, title)
  452. }
  453. const template = `
  454. <div class="box" :class="{'dark':dark}">
  455. <template v-if="!isLiveStream">
  456. <div v-if="adaptive.length" class="of-h t-center c-pointer lh-20">
  457. <a class="fs-14px" @click="dlmp4" v-text="strings.dlmp4"></a>
  458. </div>
  459. <div @click="hide=!hide" class="box-toggle div-a t-center fs-14px c-pointer lh-20" v-text="strings.togglelinks"></div>
  460. <div :class="{'hide':hide}">
  461. <div class="t-center fs-14px" v-text="strings.videoid+id"></div>
  462. <div class="d-flex">
  463. <div class="f-1 of-h">
  464. <div class="t-center fs-14px" v-text="strings.stream"></div>
  465. <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>
  466. </div>
  467. <div class="f-1 of-h">
  468. <div class="t-center fs-14px" v-text="strings.adaptive"></div>
  469. <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>
  470. </div>
  471. </div>
  472. <div class="of-h t-center">
  473. <a class="fs-14px" href="https://maple3142.github.io/mergemp4/" target="_blank" v-text="strings.inbrowser_adaptive_merger"></a>
  474. </div>
  475. </div>
  476. </template>
  477. <template v-else>
  478. <div class="t-center fs-14px lh-20" v-text="strings.live_stream_disabled_message"></div>
  479. </template>
  480. </div>
  481. `.slice(1)
  482. const app = new Vue({
  483. data() {
  484. return {
  485. hide: true,
  486. id: '',
  487. isLiveStream: false,
  488. stream: [],
  489. adaptive: [],
  490. details: null,
  491. dark: false,
  492. lang: findLang(getLangCode())
  493. }
  494. },
  495. computed: {
  496. strings() {
  497. return LOCALE[this.lang.toLowerCase()]
  498. }
  499. },
  500. methods: {
  501. dlmp4() {
  502. openDownloadModel(this.adaptive, this.details.title)
  503. },
  504. formatStreamText(vid) {
  505. return [vid.qualityLabel, vid.quality].filter(x => x).join(': ')
  506. },
  507. formatAdaptiveText(vid) {
  508. let str = [vid.qualityLabel, vid.mimeType].filter(x => x).join(': ')
  509. if (vid.mimeType.includes('audio')) {
  510. str += ` ${Math.round(vid.bitrate / 1000)}kbps`
  511. }
  512. return str
  513. }
  514. },
  515. template
  516. })
  517. logger.log(`default language: %s`, app.lang)
  518. // attach element
  519. const shadowHost = $el('div')
  520. const shadow = shadowHost.attachShadow ? shadowHost.attachShadow({ mode: 'closed' }) : shadowHost // no shadow dom
  521. logger.log('shadowHost: %o', shadowHost)
  522. const container = $el('div')
  523. shadow.appendChild(container)
  524. app.$mount(container)
  525. if (DEBUG && typeof unsafeWindow !== 'undefined') {
  526. // expose some functions for debugging
  527. unsafeWindow.$app = app
  528. unsafeWindow.parseQuery = parseQuery
  529. unsafeWindow.parseDecsig = parseDecsig
  530. unsafeWindow.parseResponse = parseResponse
  531. }
  532. const load = async playerResponse => {
  533. try {
  534. const basejs =
  535. (typeof ytplayer !== 'undefined' && 'config' in ytplayer && ytplayer.config.assets
  536. ? 'https://' + location.host + ytplayer.config.assets.js
  537. : 'web_player_context_config' in ytplayer
  538. ? 'https://' + location.host + ytplayer.web_player_context_config.jsUrl
  539. : null) || $('script[src$="base.js"]').src
  540. const decsig = await xf.get(basejs).text(parseDecsig)
  541. const id = parseQuery(location.search).v
  542. const data = parseResponse(id, playerResponse, decsig)
  543. logger.log('video loaded: %s', id)
  544. app.isLiveStream = data.playerResponse.playabilityStatus.liveStreamability != null
  545. app.id = id
  546. app.stream = data.stream
  547. app.adaptive = data.adaptive
  548. app.details = data.details
  549. const actLang = getLangCode()
  550. if (actLang != null) {
  551. const lang = findLang(actLang)
  552. logger.log('youtube ui lang: %s', actLang)
  553. logger.log('ytdl lang:', lang)
  554. app.lang = lang
  555. }
  556. } catch (err) {
  557. alert(app.strings.get_video_failed)
  558. logger.error('load', err)
  559. }
  560. }
  561. // hook fetch response
  562. const ff = fetch
  563. unsafeWindow.fetch = (...args) => {
  564. if (args[0] instanceof Request) {
  565. return ff(...args).then(resp => {
  566. if (resp.url.includes('player')) {
  567. resp.clone().json().then(load)
  568. }
  569. return resp
  570. })
  571. }
  572. return ff(...args)
  573. }
  574. // attach element
  575. setInterval(() => {
  576. const el =
  577. $('#info-contents') ||
  578. $('#watch-header') ||
  579. $('.page-container:not([hidden]) ytm-item-section-renderer>lazy-list')
  580. if (el && !el.contains(shadowHost)) {
  581. el.appendChild(shadowHost)
  582. }
  583. }, 100)
  584. // init
  585. unsafeWindow.addEventListener('load', () => {
  586. const firstResp = unsafeWindow?.ytplayer?.config?.args?.raw_player_response
  587. if (firstResp) {
  588. load(firstResp)
  589. }
  590. })
  591. // listen to dark mode toggle
  592. const $html = $('html')
  593. new MutationObserver(() => {
  594. app.dark = $html.getAttribute('dark') === 'true'
  595. }).observe($html, { attributes: true })
  596. app.dark = $html.getAttribute('dark') === 'true'
  597. const css = `
  598. .hide{
  599. display: none;
  600. }
  601. .t-center{
  602. text-align: center;
  603. }
  604. .d-flex{
  605. display: flex;
  606. }
  607. .f-1{
  608. flex: 1;
  609. }
  610. .fs-14px{
  611. font-size: 14px;
  612. }
  613. .of-h{
  614. overflow: hidden;
  615. }
  616. .box{
  617. padding-top: .5em;
  618. padding-bottom: .5em;
  619. border-bottom: 1px solid var(--yt-border-color);
  620. font-family: Arial;
  621. }
  622. .box-toggle{
  623. margin: 3px;
  624. user-select: none;
  625. -moz-user-select: -moz-none;
  626. }
  627. .ytdl-link-btn{
  628. display: block;
  629. border: 1px solid !important;
  630. border-radius: 3px;
  631. text-decoration: none !important;
  632. outline: 0;
  633. text-align: center;
  634. padding: 2px;
  635. margin: 5px;
  636. color: black;
  637. }
  638. a, .div-a{
  639. text-decoration: none;
  640. color: var(--yt-button-color, inherit);
  641. }
  642. a:hover, .div-a:hover{
  643. color: var(--yt-spec-call-to-action, blue);
  644. }
  645. .box.dark{
  646. color: var(--yt-endpoint-color, var(--yt-spec-text-primary));
  647. }
  648. .box.dark .ytdl-link-btn{
  649. color: var(--yt-endpoint-color, var(--yt-spec-text-primary));
  650. }
  651. .box.dark .ytdl-link-btn:hover{
  652. color: rgba(200, 200, 255, 0.8);
  653. }
  654. .box.dark .box-toggle:hover{
  655. color: rgba(200, 200, 255, 0.8);
  656. }
  657. .c-pointer{
  658. cursor: pointer;
  659. }
  660. .lh-20{
  661. line-height: 20px;
  662. }
  663. /* https://gf.qytechs.cn/zh-TW/scripts/369400-local-youtube-downloader/discussions/95744 */
  664. #meta-contents,
  665. #info-contents{
  666. display: contents !important;
  667. }
  668. ytd-watch-metadata.style-scope {
  669. display: none !important;
  670. }
  671. `
  672. shadow.appendChild($el('style', { textContent: css }))
  673. })()
  674.  
  675.  
  676.  
  677. function addNewStyle(newStyle) {//增加新样式表
  678. var styleElement = document.getElementById('styles_js');
  679. if (!styleElement) {
  680. styleElement = document.createElement('style');
  681. styleElement.type = 'text/css';
  682. styleElement.id = 'styles_js';
  683. document.getElementsByTagName('head')[0].appendChild(styleElement);
  684. }
  685. styleElement.appendChild(document.createTextNode(newStyle));
  686. }
  687.  
  688. addNewStyle('\
  689. #meta-contents,\
  690. #info-contents{\
  691. display: contents !important;\
  692. }\
  693. ytd-watch-metadata.style-scope {\
  694. display: none !important;\
  695. }\
  696. ');

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址