163 lines
5.3 KiB
TypeScript
163 lines
5.3 KiB
TypeScript
import { type WriteStream } from 'node:fs';
|
|
import * as hr from '@tsmx/human-readable'
|
|
|
|
interface StreamResults {
|
|
videoStreamUrl: string;
|
|
audioStreamUrl: string;
|
|
selectedFormats: {
|
|
videoFormat: any;
|
|
audioFormat: any;
|
|
}
|
|
}
|
|
|
|
export async function getVideoStreams(
|
|
videoId: string,
|
|
adaptiveFormats: any[],
|
|
options: { videoQuality: string; audioQuality: string }
|
|
): Promise<{
|
|
streamResults: StreamResults | false;
|
|
error?: string
|
|
}> {
|
|
const lowestStorageVideo = adaptiveFormats
|
|
.filter((format) => !!format.quality_label?.toLowerCase().includes(options.videoQuality?.toLowerCase() || ''))
|
|
.sort((a, b) => (a.contentLength || 0) - (b.contentLength || 0))?.[0]
|
|
const lowestStorageAudio = adaptiveFormats
|
|
.filter((format) => !!format.audio_quality?.toLowerCase().includes(options.audioQuality?.toLowerCase() || ''))
|
|
.sort((a, b) => (a.contentLength || 0) - (b.contentLength || 0))?.[0]
|
|
const lowestOptions = {
|
|
videoFormat: lowestStorageVideo?.itag,
|
|
audioFormat: lowestStorageAudio?.itag
|
|
}
|
|
|
|
if (!lowestOptions.videoFormat || !lowestOptions.audioFormat) {
|
|
return { streamResults: false, error: 'Couldn\'t find any suitable download formats.' }
|
|
}
|
|
|
|
const {
|
|
videoStreamUrl,
|
|
audioStreamUrl
|
|
} = {
|
|
videoStreamUrl: await getStreamUrl(videoId, lowestOptions.videoFormat),
|
|
audioStreamUrl: await getStreamUrl(videoId, lowestOptions.audioFormat)
|
|
}
|
|
|
|
if (!videoStreamUrl || !audioStreamUrl) return { streamResults: false, error: 'Failed to fetch streaming URLs from Youtube.' }
|
|
|
|
return {
|
|
streamResults: {
|
|
videoStreamUrl,
|
|
audioStreamUrl,
|
|
selectedFormats: {
|
|
videoFormat: lowestStorageVideo,
|
|
audioFormat: lowestStorageAudio
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
export async function downloadStream(streamUrl: string, format: any, stream: WriteStream, ws: any, type: string) {
|
|
// get the final url of the stream, since it redirects
|
|
let location = streamUrl
|
|
let headResponse: Response | undefined;
|
|
const headersToSend: HeadersInit = {
|
|
"accept": "*/*",
|
|
"accept-encoding": "gzip, deflate, br, zstd",
|
|
"accept-language": "en-us,en;q=0.5",
|
|
"origin": "https://www.youtube.com",
|
|
"referer": "https://www.youtube.com",
|
|
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36'
|
|
};
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
const googlevideoResponse: Response = await fetch(location, {
|
|
method: "HEAD",
|
|
headers: headersToSend,
|
|
redirect: "manual",
|
|
});
|
|
if (googlevideoResponse.status == 403) {
|
|
throw new Error(`403 from google - ${await googlevideoResponse.text()}`)
|
|
}
|
|
if (googlevideoResponse.headers.has("Location")) {
|
|
location = googlevideoResponse.headers.get("Location") as string;
|
|
continue;
|
|
} else {
|
|
headResponse = googlevideoResponse;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (headResponse === undefined) {
|
|
throw new Error('google redirected too many times')
|
|
}
|
|
|
|
// setup the chunking setup.
|
|
const googleVideoUrl = new URL(location);
|
|
let size = 0;
|
|
const totalSize = Number(headResponse.headers.get("Content-Length") || format.content_length || "0")
|
|
const videoStartTime = Date.now();
|
|
const videoPrecentages: string[] = []
|
|
|
|
const getChunk = async (start: number, end: number) => {
|
|
googleVideoUrl.searchParams.set(
|
|
"range",
|
|
`${start}-${end}`,
|
|
);
|
|
const postResponse = await fetch(googleVideoUrl, {
|
|
method: "POST",
|
|
body: new Uint8Array([0x78, 0]), // protobuf: { 15: 0 } (no idea what it means but this is what YouTube uses),
|
|
headers: headersToSend,
|
|
});
|
|
if (postResponse.status !== 200) {
|
|
throw new Error("Non-200 response from google servers");
|
|
}
|
|
|
|
const chunk = Buffer.from(await postResponse.arrayBuffer())
|
|
stream.write(chunk);
|
|
|
|
size += chunk.length;
|
|
|
|
if (totalSize > 0) {
|
|
let elapsedTime = (Date.now() - videoStartTime) / 1000;
|
|
let progress = size / totalSize;
|
|
let speedInMBps = (size / (1024 * 1024)) / elapsedTime;
|
|
let remainingTime = (totalSize - size) / (speedInMBps * 1024 * 1024);
|
|
|
|
if (!videoPrecentages.includes((progress * 100).toFixed(0))) {
|
|
videoPrecentages.push((progress * 100).toFixed(0))
|
|
ws.send(`[${type}] ${(progress * 100).toFixed(2)}% of ${hr.fromBytes(totalSize, {})} at ${speedInMBps.toFixed(2)} MB/s ETA ${secondsToTime(parseInt(remainingTime.toFixed(0)))}`)
|
|
}
|
|
}
|
|
};
|
|
|
|
const chunkSize = 5 * 1_000_000 // 5mb
|
|
const wholeRequestEndByte = Number(totalSize) - 1;
|
|
|
|
for (let startByte = 0; startByte < wholeRequestEndByte; startByte += chunkSize) {
|
|
let endByte = startByte + chunkSize - 1;
|
|
if (endByte > wholeRequestEndByte) {
|
|
endByte = wholeRequestEndByte;
|
|
}
|
|
await getChunk(startByte, endByte)
|
|
}
|
|
|
|
stream.end()
|
|
}
|
|
|
|
async function getStreamUrl(videoId: string, itag: number): Promise<string|false> {
|
|
const req = await fetch(`http://127.0.0.1:8282/companion/latest_version?id=${videoId}&itag=${itag}`, {
|
|
redirect: 'manual'
|
|
})
|
|
|
|
if (req.status == 302) {
|
|
return req.headers.get('Location')!
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
function secondsToTime(seconds: number) {
|
|
const minutes = Math.floor(seconds / 60);
|
|
const remainingSeconds = seconds % 60;
|
|
const formattedSeconds = remainingSeconds < 10 ? '0' + remainingSeconds : remainingSeconds;
|
|
return `${minutes}:${formattedSeconds}`;
|
|
} |