upd / src /routes /videoPlaybackProxy.ts
cursorpro's picture
Upload 57 files
7a4c980 verified
import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import { encodeRFC5987ValueChars } from "../lib/helpers/encodeRFC5987ValueChars.ts";
import { decryptQuery } from "../lib/helpers/encryptQuery.ts";
import { StreamingApi } from "hono/utils/stream";
let getFetchClientLocation = "getFetchClient";
if (Deno.env.get("GET_FETCH_CLIENT_LOCATION")) {
if (Deno.env.has("DENO_COMPILED")) {
getFetchClientLocation = Deno.mainModule.replace("src/main.ts", "") +
Deno.env.get("GET_FETCH_CLIENT_LOCATION");
} else {
getFetchClientLocation = Deno.env.get(
"GET_FETCH_CLIENT_LOCATION",
) as string;
}
}
const { getFetchClient } = await import(getFetchClientLocation);
const videoPlaybackProxy = new Hono();
videoPlaybackProxy.options("/", () => {
const headersForResponse: Record<string, string> = {
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET, OPTIONS",
"access-control-allow-headers": "Content-Type, Range",
};
return new Response("OK", {
status: 200,
headers: headersForResponse,
});
});
videoPlaybackProxy.get("/", async (c) => {
const { c: client, expire, title } = c.req.query();
const urlReq = new URL(c.req.url);
const config = c.get("config");
const queryParams = new URLSearchParams(urlReq.search);
if (c.req.query("enc") === "true") {
const { data: encryptedQuery } = c.req.query();
const decryptedQueryParams = decryptQuery(encryptedQuery, config);
const parsedDecryptedQueryParams = new URLSearchParams(
JSON.parse(decryptedQueryParams),
);
queryParams.delete("enc");
queryParams.delete("data");
queryParams.set("pot", parsedDecryptedQueryParams.get("pot") as string);
queryParams.set("ip", parsedDecryptedQueryParams.get("ip") as string);
}
if (
expire == undefined ||
Number(expire) < Number(Date.now().toString().slice(0, -3))
) {
throw new HTTPException(400, {
res: new Response(
"Expire query string undefined or videoplayback URL has expired.",
),
});
}
if (client == undefined) {
throw new HTTPException(400, {
res: new Response("'c' query string undefined."),
});
}
queryParams.delete("title");
const rangeHeader = c.req.header("range");
const requestBytes = rangeHeader ? rangeHeader.split("=")[1] : null;
const [firstByte, lastByte] = requestBytes?.split("-") || [];
if (requestBytes) {
queryParams.append(
"range",
requestBytes,
);
}
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",
};
if (client == "ANDROID") {
headersToSend["user-agent"] =
"com.google.android.youtube/1537338816 (Linux; U; Android 13; en_US; ; Build/TQ2A.230505.002; Cronet/113.0.5672.24)";
} else if (client == "IOS") {
headersToSend["user-agent"] =
"com.google.ios.youtube/19.32.8 (iPhone14,5; U; CPU iOS 17_6 like Mac OS X;)";
} else {
headersToSend["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";
}
const fetchClient = await getFetchClient(config);
let headResponse: Response | undefined;
let location = `https://redirector.googlevideo.com/videoplayback?${queryParams.toString()}`;
// https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-p2-semantics-17#section-7.3
// A maximum of 5 redirections is defined in the note of the section 7.3
// of this RFC, that's why `i < 5`
for (let i = 0; i < 5; i++) {
const googlevideoResponse: Response = await fetchClient(location, {
method: "HEAD",
headers: headersToSend,
redirect: "manual",
});
if (googlevideoResponse.status == 403) {
return new Response(googlevideoResponse.body, {
status: googlevideoResponse.status,
statusText: googlevideoResponse.statusText,
});
}
if (googlevideoResponse.headers.has("Location")) {
location = googlevideoResponse.headers.get("Location") as string;
continue;
} else {
headResponse = googlevideoResponse;
break;
}
}
if (headResponse === undefined) {
throw new HTTPException(502, {
res: new Response(
"Google headResponse redirected too many times",
),
});
}
// =================== REQUEST CHUNKING =======================
// if the requested response is larger than the chunkSize, break up the response
// into chunks and stream the response back to the client to avoid rate limiting
const { readable, writable } = new TransformStream();
const stream = new StreamingApi(writable, readable);
const googleVideoUrl = new URL(location);
const getChunk = async (start: number, end: number) => {
googleVideoUrl.searchParams.set(
"range",
`${start}-${end}`,
);
const postResponse = await fetchClient(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");
}
await stream.pipe(postResponse.body);
};
const chunkSize =
config.networking.videoplayback.video_fetch_chunk_size_mb * 1_000_000;
const totalBytes = Number(
headResponse.headers.get("Content-Length") || "0",
);
// if no range sent, the client wants thw whole file, i.e. for downloads
const wholeRequestStartByte = Number(firstByte || "0");
const wholeRequestEndByte = wholeRequestStartByte + Number(totalBytes) - 1;
let chunk = Promise.resolve();
for (
let startByte = wholeRequestStartByte;
startByte < wholeRequestEndByte;
startByte += chunkSize
) {
// i.e.
// 0 - 4_999_999, then
// 5_000_000 - 9_999_999, then
// 10_000_000 - 14_999_999
let endByte = startByte + chunkSize - 1;
if (endByte > wholeRequestEndByte) {
endByte = wholeRequestEndByte;
}
chunk = chunk.then(() => getChunk(startByte, endByte));
}
chunk.catch(() => {
stream.abort();
});
// =================== REQUEST CHUNKING =======================
const headersForResponse: Record<string, string> = {
"content-length": headResponse.headers.get("content-length") || "",
"access-control-allow-origin": "*",
"accept-ranges": headResponse.headers.get("accept-ranges") || "",
"content-type": headResponse.headers.get("content-type") || "",
"expires": headResponse.headers.get("expires") || "",
"last-modified": headResponse.headers.get("last-modified") || "",
};
if (title) {
headersForResponse["content-disposition"] = `attachment; filename="${encodeURIComponent(title)
}"; filename*=UTF-8''${encodeRFC5987ValueChars(title)}`;
}
let responseStatus = headResponse.status;
if (requestBytes && responseStatus == 200) {
// check for range headers in the forms:
// "bytes=0-" get full length from start
// "bytes=500-" get full length from 500 bytes in
// "bytes=500-1000" get 500 bytes starting from 500
if (lastByte) {
responseStatus = 206;
headersForResponse["content-range"] = `bytes ${requestBytes}/${queryParams.get("clen") || "*"
}`;
} else {
// i.e. "bytes=0-", "bytes=600-"
// full size of content is able to be calculated, so a full Content-Range header can be constructed
const bytesReceived = headersForResponse["content-length"];
// last byte should always be one less than the length
const totalContentLength = Number(firstByte) +
Number(bytesReceived);
const lastByte = totalContentLength - 1;
if (firstByte !== "0") {
// only part of the total content returned, 206
responseStatus = 206;
}
headersForResponse["content-range"] =
`bytes ${firstByte}-${lastByte}/${totalContentLength}`;
}
}
return new Response(stream.responseReadable, {
status: responseStatus,
statusText: headResponse.statusText,
headers: headersForResponse,
});
});
export default videoPlaybackProxy;