Skip to content

Commit 379de63

Browse files
committed
youtube local downloader - WIP
1 parent 0a1cd79 commit 379de63

File tree

2 files changed

+255
-54
lines changed

2 files changed

+255
-54
lines changed

scripts/content-scripts/scripts/ufs_global_webpage_context.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ const UsefulScriptGlobalPageContext = {
357357
alert("Error: " + error);
358358
}
359359
},
360-
async downloadBlobUrlWithProgress(url, fileName, progressCallback) {
360+
async downloadBlobUrlWithProgress(url, progressCallback) {
361361
const response = await fetch(url);
362362
if (!response.ok) {
363363
throw new Error(`Error: ${response.status} - ${response.statusText}`);
@@ -379,6 +379,8 @@ const UsefulScriptGlobalPageContext = {
379379
const blob = new Blob(chunks, {
380380
type: response.headers.get("content-type"),
381381
});
382+
383+
return blob;
382384
UsefulScriptGlobalPageContext.Utils.downloadBlob(blob, fileName);
383385
},
384386
async downloadBlobUrl(url, title) {

scripts/youtube_localDownloader.js

Lines changed: 252 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)