@@ -11,16 +11,59 @@ export default {
1111
1212 whiteList : [ "https://*.youtube.com/*" ] ,
1313
14- onDocumentStart : ( ) => {
15- const app = { } ;
14+ onClick : ( ) => { } ,
1615
16+ onDocumentStart : async ( ) => {
17+ const app = { } ;
1718 const $ = ( s , x = document ) => x . querySelector ( s ) ;
18- const $el = ( tag , opts ) => {
19- const el = document . createElement ( tag ) ;
20- Object . assign ( el , opts ) ;
21- return el ;
19+
20+ // hook fetch response
21+ const ff = fetch ;
22+ window . fetch = ( ...args ) => {
23+ if ( args [ 0 ] instanceof Request ) {
24+ return ff ( ...args ) . then ( ( resp ) => {
25+ if ( resp . url . includes ( "player" ) ) {
26+ resp . clone ( ) . json ( ) . then ( load ) ;
27+ }
28+ return resp ;
29+ } ) ;
30+ }
31+ return ff ( ...args ) ;
2232 } ;
2333
34+ async function load ( playerResponse ) {
35+ try {
36+ const basejs =
37+ ( typeof ytplayer !== "undefined" &&
38+ "config" in ytplayer &&
39+ ytplayer . config . assets
40+ ? "https://" + location . host + ytplayer . config . assets . js
41+ : "web_player_context_config" in ytplayer
42+ ? "https://" +
43+ location . host +
44+ ytplayer . web_player_context_config . jsUrl
45+ : null ) || $ ( 'script[src$="base.js"]' ) . src ;
46+ const res = await fetch ( basejs ) ;
47+ const text = await res . text ( ) ;
48+ const decsig = parseDecsig ( text ) ;
49+ const id = parseQuery ( location . search ) . v ;
50+ const data = parseResponse ( id , playerResponse , decsig ) ;
51+ console . log ( "video loaded: %s" , id ) ;
52+ app . isLiveStream =
53+ data . playerResponse . playabilityStatus . liveStreamability != null ;
54+ app . id = id ;
55+ app . stream = data . stream ;
56+ app . adaptive = data . adaptive ;
57+ app . details = data . details ;
58+ console . log ( app ) ;
59+ } catch ( err ) {
60+ alert (
61+ "Failed to get video infomation for unknown reason, refresh the page may work."
62+ ) ;
63+ console . error ( "load" , err ) ;
64+ }
65+ }
66+
2467 const escapeRegExp = ( s ) => s . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ;
2568 const parseDecsig = ( data ) => {
2669 try {
@@ -106,59 +149,215 @@ export default {
106149 } ;
107150 } ;
108151
109- const load = async ( playerResponse ) => {
110- try {
111- const basejs =
112- ( typeof ytplayer !== "undefined" &&
113- "config" in ytplayer &&
114- ytplayer . config . assets
115- ? "https://" + location . host + ytplayer . config . assets . js
116- : "web_player_context_config" in ytplayer
117- ? "https://" +
118- location . host +
119- ytplayer . web_player_context_config . jsUrl
120- : null ) || $ ( 'script[src$="base.js"]' ) . src ;
121- debugger ;
122- const res = await fetch ( basejs ) ;
123- const text = await res . text ( ) ;
124- const decsig = parseDecsig ( text ) ;
125- const id = parseQuery ( location . search ) . v ;
126- const data = parseResponse ( id , playerResponse , decsig ) ;
127- console . log ( "video loaded: %s" , id ) ;
128- app . isLiveStream =
129- data . playerResponse . playabilityStatus . liveStreamability != null ;
130- app . id = id ;
131- app . stream = data . stream ;
132- app . adaptive = data . adaptive ;
133- app . details = data . details ;
134- } catch ( err ) {
135- alert ( app . strings . get_video_failed ) ;
136- console . error ( "load" , err ) ;
152+ window . addEventListener ( "load" , ( ) => {
153+ const firstResp = window ?. ytplayer ?. config ?. args ?. raw_player_response ;
154+ if ( firstResp ) {
155+ load ( firstResp ) ;
156+ }
157+ } ) ;
158+
159+ // ========================== video downloader ==========================
160+ UsefulScriptGlobalPageContext . DOM . injectScriptSrc (
161+ "https://unpkg.com/@ffmpeg/ffmpeg@0.6.1/dist/ffmpeg.min.js"
162+ ) ;
163+ UsefulScriptGlobalPageContext . DOM . injectScriptSrc (
164+ "https://unpkg.com/vue@2.6.10/dist/vue.js"
165+ ) ;
166+
167+ const xhrDownloadUint8Array = async (
168+ { url, contentLength } ,
169+ progressCb
170+ ) => {
171+ if ( typeof contentLength === "string" )
172+ contentLength = parseInt ( contentLength ) ;
173+
174+ progressCb ( {
175+ loaded : 0 ,
176+ total : contentLength ,
177+ speed : 0 ,
178+ } ) ;
179+
180+ const chunkSize = 65536 ;
181+ const getBuffer = async ( start , end ) => {
182+ let res = await fetch ( url + `&range=${ start } -${ end ? end - 1 : "" } ` ) ;
183+ return await res . arrayBuffer ( ) ;
184+ } ;
185+
186+ const data = new Uint8Array ( contentLength ) ;
187+ let downloaded = 0 ;
188+ const startTime = Date . now ( ) ;
189+ for ( let start = 0 ; start < contentLength ; start += chunkSize ) {
190+ try {
191+ const exceeded = start + chunkSize > contentLength ;
192+ const curChunkSize = exceeded ? contentLength - start : chunkSize ;
193+ const end = exceeded ? null : start + chunkSize ;
194+ const buf = await getBuffer ( start , end ) ;
195+ console . log ( "dl done" , url , start , end ) ;
196+ downloaded += curChunkSize ;
197+ data . set ( new Uint8Array ( buf ) , start ) ;
198+ const ds = ( Date . now ( ) - startTime + 1 ) / 1000 ;
199+ progressCb ( {
200+ loaded : downloaded ,
201+ total : contentLength ,
202+ speed : downloaded / ds ,
203+ } ) ;
204+ } catch ( e ) {
205+ console . log ( "Download error" , e ) ;
206+ }
137207 }
208+ return data ;
138209 } ;
139210
140- // hook fetch response
141- const ff = fetch ;
142- window . fetch = ( ...args ) => {
143- if ( args [ 0 ] instanceof Request ) {
144- return ff ( ...args ) . then ( ( resp ) => {
145- if ( resp . url . includes ( "player" ) ) {
146- resp . clone ( ) . json ( ) . then ( load ) ;
147- }
148- return resp ;
211+ let ffWorker ;
212+ const mergeVideo = async ( video , audio ) => {
213+ if ( ! ffWorker ) {
214+ ffWorker = FFmpeg . createWorker ( {
215+ logger : DEBUG ? ( m ) => logger . log ( m . message ) : ( ) => { } ,
149216 } ) ;
217+ await ffWorker . load ( ) ;
150218 }
151- return ff ( ...args ) ;
219+ await ffWorker . write ( "video.mp4" , video ) ;
220+ await ffWorker . write ( "audio.mp4" , audio ) ;
221+ await ffWorker . run ( "-i video.mp4 -i audio.mp4 -c copy output.mp4" , {
222+ input : [ "video.mp4" , "audio.mp4" ] ,
223+ output : "output.mp4" ,
224+ } ) ;
225+ const { data } = await ffWorker . read ( "output.mp4" ) ;
226+ await ffWorker . remove ( "output.mp4" ) ;
227+ return data ;
152228 } ;
153229
154- window . addEventListener ( "load" , ( ) => {
155- const firstResp = window ?. ytplayer ?. config ?. args ?. raw_player_response ;
156- if ( firstResp ) {
157- load ( firstResp ) ;
158- }
159- } ) ;
230+ const triggerDownload = ( url , filename ) => {
231+ const a = document . createElement ( "a" ) ;
232+ a . href = url ;
233+ a . download = filename ;
234+ document . body . appendChild ( a ) ;
235+ a . click ( ) ;
236+ a . remove ( ) ;
237+ } ;
238+
239+ const dlModalTemplate = `
240+ <div style="width: 100%; height: 100%;">
241+ <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>
242+ <div v-else style="height: 100%; width: 100%; display: flex; flex-direction: column;">
243+ <div style="flex: 1; margin: 10px;">
244+ <p style="font-size: 24px;">Video</p>
245+ <progress style="width: 100%;" :value="video.progress" min="0" max="100"></progress>
246+ <div style="display: flex; justify-content: space-between;">
247+ <span>{{video.speed}} kB/s</span>
248+ <span>{{video.loaded}}/{{video.total}} MB</span>
249+ </div>
250+ </div>
251+ <div style="flex: 1; margin: 10px;">
252+ <p style="font-size: 24px;">Audio</p>
253+ <progress style="width: 100%;" :value="audio.progress" min="0" max="100"></progress>
254+ <div style="display: flex; justify-content: space-between;">
255+ <span>{{audio.speed}} kB/s</span>
256+ <span>{{audio.loaded}}/{{audio.total}} MB</span>
257+ </div>
258+ </div>
259+ </div>
260+ </div>
261+ ` ;
262+
263+ async function openDownloadModel ( adaptive , title ) {
264+ const win = open (
265+ "" ,
266+ "Video Download" ,
267+ `toolbar=no,height=400,width=400,left=${ screenLeft } ,top=${ screenTop } `
268+ ) ;
269+ const div = win . document . createElement ( "div" ) ;
270+ win . document . body . appendChild ( div ) ;
271+ win . document . title = `Downloading "${ title } "` ;
272+ const dlModalApp = new Vue ( {
273+ template : dlModalTemplate ,
274+ data ( ) {
275+ return {
276+ video : {
277+ progress : 0 ,
278+ total : 0 ,
279+ loaded : 0 ,
280+ speed : 0 ,
281+ } ,
282+ audio : {
283+ progress : 0 ,
284+ total : 0 ,
285+ loaded : 0 ,
286+ speed : 0 ,
287+ } ,
288+ merging : false ,
289+ } ;
290+ } ,
291+ methods : {
292+ async start ( adaptive , title ) {
293+ win . onbeforeunload = ( ) => true ;
294+ // YouTube's default order is descending by video quality
295+ const videoObj = adaptive
296+ . filter (
297+ ( x ) =>
298+ x . mimeType . includes ( "video/mp4" ) ||
299+ x . mimeType . includes ( "video/webm" )
300+ )
301+ . map ( ( v ) => {
302+ const [ _ , quality , fps ] = / ( \d + ) p ( \d * ) / . exec ( v . qualityLabel ) ;
303+ v . qualityNum = parseInt ( quality ) ;
304+ v . fps = fps ? parseInt ( fps ) : 30 ;
305+ return v ;
306+ } )
307+ . sort ( ( a , b ) => {
308+ if ( a . qualityNum === b . qualityNum ) return b . fps - a . fps ; // ex: 30-60=-30, then a will be put before b
309+ return b . qualityNum - a . qualityNum ;
310+ } ) [ 0 ] ;
311+ const audioObj = adaptive . find ( ( x ) =>
312+ x . mimeType . includes ( "audio/mp4" )
313+ ) ;
314+ const vPromise = xhrDownloadUint8Array ( videoObj , ( e ) => {
315+ this . video . progress = ( e . loaded / e . total ) * 100 ;
316+ this . video . loaded = ( e . loaded / 1024 / 1024 ) . toFixed ( 2 ) ;
317+ this . video . total = ( e . total / 1024 / 1024 ) . toFixed ( 2 ) ;
318+ this . video . speed = ( e . speed / 1024 ) . toFixed ( 2 ) ;
319+ } ) ;
320+ const aPromise = xhrDownloadUint8Array ( audioObj , ( e ) => {
321+ this . audio . progress = ( e . loaded / e . total ) * 100 ;
322+ this . audio . loaded = ( e . loaded / 1024 / 1024 ) . toFixed ( 2 ) ;
323+ this . audio . total = ( e . total / 1024 / 1024 ) . toFixed ( 2 ) ;
324+ this . audio . speed = ( e . speed / 1024 ) . toFixed ( 2 ) ;
325+ } ) ;
326+ const [ varr , aarr ] = await Promise . all ( [ vPromise , aPromise ] ) ;
327+ this . merging = true ;
328+ win . onunload = ( ) => {
329+ // trigger download when user close it
330+ const bvurl = URL . createObjectURL ( new Blob ( [ varr ] ) ) ;
331+ const baurl = URL . createObjectURL ( new Blob ( [ aarr ] ) ) ;
332+ triggerDownload ( bvurl , title + "-videoonly.mp4" ) ;
333+ triggerDownload ( baurl , title + "-audioonly.mp4" ) ;
334+ } ;
335+ const result = await Promise . race ( [
336+ mergeVideo ( varr , aarr ) ,
337+ sleep ( 1000 * 25 ) . then ( ( ) => null ) ,
338+ ] ) ;
339+ if ( ! result ) {
340+ alert ( "An error has occurred when merging video" ) ;
341+ const bvurl = URL . createObjectURL ( new Blob ( [ varr ] ) ) ;
342+ const baurl = URL . createObjectURL ( new Blob ( [ aarr ] ) ) ;
343+ triggerDownload ( bvurl , title + "-videoonly.mp4" ) ;
344+ triggerDownload ( baurl , title + "-audioonly.mp4" ) ;
345+ return this . close ( ) ;
346+ }
347+ this . merging = false ;
348+ const url = URL . createObjectURL ( new Blob ( [ result ] ) ) ;
349+ triggerDownload ( url , title + ".mp4" ) ;
350+ win . onbeforeunload = null ;
351+ win . onunload = null ;
352+ win . close ( ) ;
353+ } ,
354+ } ,
355+ } ) . $mount ( div ) ;
356+ dlModalApp . start ( adaptive , title ) ;
357+ }
358+
359+ window . ufs_download_video = ( ) => {
360+ openDownloadModel ( app . adaptive , app . details ?. title ) ;
361+ } ;
160362 } ,
161363} ;
162-
163- // functions/attributes that other scripts can import and use
164- export const shared = { } ;
0 commit comments