@distube/ytdl-core
Version:
DisTube fork of ytdl-core. YouTube video downloader in pure javascript.
450 lines (400 loc) • 14.4 kB
JavaScript
const { request } = require("undici");
const { writeFileSync } = require("fs");
const AGENT = require("./agent");
/**
* Extract string inbetween another.
*
* @param {string} haystack
* @param {string} left
* @param {string} right
* @returns {string}
*/
const between = (exports.between = (haystack, left, right) => {
let pos;
if (left instanceof RegExp) {
const match = haystack.match(left);
if (!match) {
return "";
}
pos = match.index + match[0].length;
} else {
pos = haystack.indexOf(left);
if (pos === -1) {
return "";
}
pos += left.length;
}
haystack = haystack.slice(pos);
pos = haystack.indexOf(right);
if (pos === -1) {
return "";
}
haystack = haystack.slice(0, pos);
return haystack;
});
exports.tryParseBetween = (body, left, right, prepend = "", append = "") => {
try {
let data = between(body, left, right);
if (!data) return null;
return JSON.parse(`${prepend}${data}${append}`);
} catch (e) {
return null;
}
};
/**
* Get a number from an abbreviated number string.
*
* @param {string} string
* @returns {number}
*/
exports.parseAbbreviatedNumber = string => {
const match = string
.replace(",", ".")
.replace(" ", "")
.match(/([\d,.]+)([MK]?)/);
if (match) {
let [, num, multi] = match;
num = parseFloat(num);
return Math.round(multi === "M" ? num * 1000000 : multi === "K" ? num * 1000 : num);
}
return null;
};
/**
* Escape sequences for cutAfterJS
* @param {string} start the character string the escape sequence
* @param {string} end the character string to stop the escape seequence
* @param {undefined|Regex} startPrefix a regex to check against the preceding 10 characters
*/
const ESCAPING_SEQUENZES = [
// Strings
{ start: '"', end: '"' },
{ start: "'", end: "'" },
{ start: "`", end: "`" },
// RegeEx
{ start: "/", end: "/", startPrefix: /(^|[[{:;,/])\s?$/ },
];
/**
* Match begin and end braces of input JS, return only JS
*
* @param {string} mixedJson
* @returns {string}
*/
exports.cutAfterJS = mixedJson => {
// Define the general open and closing tag
let open, close;
if (mixedJson[0] === "[") {
open = "[";
close = "]";
} else if (mixedJson[0] === "{") {
open = "{";
close = "}";
}
if (!open) {
throw new Error(`Can't cut unsupported JSON (need to begin with [ or { ) but got: ${mixedJson[0]}`);
}
// States if the loop is currently inside an escaped js object
let isEscapedObject = null;
// States if the current character is treated as escaped or not
let isEscaped = false;
// Current open brackets to be closed
let counter = 0;
let i;
// Go through all characters from the start
for (i = 0; i < mixedJson.length; i++) {
// End of current escaped object
if (!isEscaped && isEscapedObject !== null && mixedJson[i] === isEscapedObject.end) {
isEscapedObject = null;
continue;
// Might be the start of a new escaped object
} else if (!isEscaped && isEscapedObject === null) {
for (const escaped of ESCAPING_SEQUENZES) {
if (mixedJson[i] !== escaped.start) continue;
// Test startPrefix against last 10 characters
if (!escaped.startPrefix || mixedJson.substring(i - 10, i).match(escaped.startPrefix)) {
isEscapedObject = escaped;
break;
}
}
// Continue if we found a new escaped object
if (isEscapedObject !== null) {
continue;
}
}
// Toggle the isEscaped boolean for every backslash
// Reset for every regular character
isEscaped = mixedJson[i] === "\\" && !isEscaped;
if (isEscapedObject !== null) continue;
if (mixedJson[i] === open) {
counter++;
} else if (mixedJson[i] === close) {
counter--;
}
// All brackets have been closed, thus end of JSON is reached
if (counter === 0) {
// Return the cut JSON
return mixedJson.substring(0, i + 1);
}
}
// We ran through the whole string and ended up with an unclosed bracket
throw Error("Can't cut unsupported JSON (no matching closing bracket found)");
};
class UnrecoverableError extends Error {}
/**
* Checks if there is a playability error.
*
* @param {Object} player_response
* @returns {!Error}
*/
exports.playError = player_response => {
const playability = player_response?.playabilityStatus;
if (!playability) return null;
if (["ERROR", "LOGIN_REQUIRED"].includes(playability.status)) {
return new UnrecoverableError(playability.reason || playability.messages?.[0]);
}
if (playability.status === "LIVE_STREAM_OFFLINE") {
return new UnrecoverableError(playability.reason || "The live stream is offline.");
}
if (playability.status === "UNPLAYABLE") {
return new UnrecoverableError(playability.reason || "This video is unavailable.");
}
return null;
};
// Undici request
const useFetch = async (fetch, url, requestOptions) => {
// embed query to url
const query = requestOptions.query;
if (query) {
const urlObject = new URL(url);
for (const key in query) {
urlObject.searchParams.append(key, query[key]);
}
url = urlObject.toString();
}
const response = await fetch(url, requestOptions);
// convert webstandard response to undici request's response
const statusCode = response.status;
const body = Object.assign(response, response.body || {});
const headers = Object.fromEntries(response.headers.entries());
return { body, statusCode, headers };
};
exports.request = async (url, options = {}) => {
let { requestOptions, rewriteRequest, fetch } = options;
if (typeof rewriteRequest === "function") {
const rewritten = rewriteRequest(url, requestOptions);
requestOptions = rewritten.requestOptions || requestOptions;
url = rewritten.url || url;
}
const req =
typeof fetch === "function" ? await useFetch(fetch, url, requestOptions) : await request(url, requestOptions);
const code = req.statusCode.toString();
if (code.startsWith("2")) {
if (req.headers["content-type"].includes("application/json")) return req.body.json();
return req.body.text();
}
if (code.startsWith("3")) return exports.request(req.headers.location, options);
const e = new Error(`Status code: ${code}`);
e.statusCode = req.statusCode;
throw e;
};
/**
* Temporary helper to help deprecating a few properties.
*
* @param {Object} obj
* @param {string} prop
* @param {Object} value
* @param {string} oldPath
* @param {string} newPath
*/
exports.deprecate = (obj, prop, value, oldPath, newPath) => {
Object.defineProperty(obj, prop, {
get: () => {
console.warn(`\`${oldPath}\` will be removed in a near future release, ` + `use \`${newPath}\` instead.`);
return value;
},
});
};
// Check for updates.
const pkg = require("../package.json");
const UPDATE_INTERVAL = 1000 * 60 * 60 * 12;
let updateWarnTimes = 0;
exports.lastUpdateCheck = 0;
exports.checkForUpdates = () => {
if (
!process.env.YTDL_NO_UPDATE &&
!pkg.version.startsWith("0.0.0-") &&
Date.now() - exports.lastUpdateCheck >= UPDATE_INTERVAL
) {
exports.lastUpdateCheck = Date.now();
return exports
.request("https://api.github.com/repos/distubejs/ytdl-core/contents/package.json", {
requestOptions: {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.3",
},
},
})
.then(
response => {
const buf = Buffer.from(response.content, response.encoding);
const pkgFile = JSON.parse(buf.toString("ascii"));
if (pkgFile.version !== pkg.version && updateWarnTimes++ < 5) {
// eslint-disable-next-line max-len
console.warn(
'\x1b[33mWARNING:\x1B[0m @distube/ytdl-core is out of date! Update with "npm install @distube/ytdl-core@latest".',
);
}
},
err => {
console.warn("Error checking for updates:", err.message);
console.warn("You can disable this check by setting the `YTDL_NO_UPDATE` env variable.");
},
);
}
return null;
};
/**
* Gets random IPv6 Address from a block
*
* @param {string} ip the IPv6 block in CIDR-Notation
* @returns {string}
*/
const getRandomIPv6 = ip => {
if (!isIPv6(ip)) {
throw new Error("Invalid IPv6 format");
}
const [rawAddr, rawMask] = ip.split("/");
const mask = parseInt(rawMask, 10);
if (isNaN(mask) || mask > 128 || mask < 1) {
throw new Error("Invalid IPv6 subnet mask (must be between 1 and 128)");
}
const base10addr = normalizeIP(rawAddr);
const fullMaskGroups = Math.floor(mask / 16);
const remainingBits = mask % 16;
const result = new Array(8).fill(0);
for (let i = 0; i < 8; i++) {
if (i < fullMaskGroups) {
result[i] = base10addr[i];
} else if (i === fullMaskGroups && remainingBits > 0) {
const groupMask = 0xffff << (16 - remainingBits);
const randomPart = Math.floor(Math.random() * (1 << (16 - remainingBits)));
result[i] = (base10addr[i] & groupMask) | randomPart;
} else {
result[i] = Math.floor(Math.random() * 0x10000);
}
}
return result.map(x => x.toString(16).padStart(4, "0")).join(":");
};
const isIPv6 = ip => {
const IPV6_REGEX =
/^(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?:(?::[0-9a-fA-F]{1,4}){1,6})|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(?::[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(?:ffff(?::0{1,4}){0,1}:){0,1}(?:(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])|(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9]))(?:\/(?:1[0-1][0-9]|12[0-8]|[1-9][0-9]|[1-9]))?$/;
return IPV6_REGEX.test(ip);
};
/**
* Normalizes an IPv6 address into an array of 8 integers
* @param {string} ip - IPv6 address
* @returns {number[]} - Array of 8 integers representing the address
*/
const normalizeIP = ip => {
const parts = ip.split("::");
let start = parts[0] ? parts[0].split(":") : [];
let end = parts[1] ? parts[1].split(":") : [];
const missing = 8 - (start.length + end.length);
const zeros = new Array(missing).fill("0");
const full = [...start, ...zeros, ...end];
return full.map(part => parseInt(part || "0", 16));
};
exports.saveDebugFile = (name, body) => {
const filename = `${+new Date()}-${name}`;
writeFileSync(filename, body);
return filename;
};
const findPropKeyInsensitive = (obj, prop) =>
Object.keys(obj).find(p => p.toLowerCase() === prop.toLowerCase()) || null;
exports.getPropInsensitive = (obj, prop) => {
const key = findPropKeyInsensitive(obj, prop);
return key && obj[key];
};
exports.setPropInsensitive = (obj, prop, value) => {
const key = findPropKeyInsensitive(obj, prop);
obj[key || prop] = value;
return key;
};
let oldCookieWarning = true;
let oldDispatcherWarning = true;
exports.applyDefaultAgent = options => {
if (!options.agent) {
const { jar } = AGENT.defaultAgent;
const c = exports.getPropInsensitive(options.requestOptions.headers, "cookie");
if (c) {
jar.removeAllCookiesSync();
AGENT.addCookiesFromString(jar, c);
if (oldCookieWarning) {
oldCookieWarning = false;
console.warn(
"\x1b[33mWARNING:\x1B[0m Using old cookie format, " +
"please use the new one instead. (https://github.com/distubejs/ytdl-core#cookies-support)",
);
}
}
if (options.requestOptions.dispatcher && oldDispatcherWarning) {
oldDispatcherWarning = false;
console.warn(
"\x1b[33mWARNING:\x1B[0m Your dispatcher is overridden by `ytdl.Agent`. " +
"To implement your own, check out the documentation. " +
"(https://github.com/distubejs/ytdl-core#how-to-implement-ytdlagent-with-your-own-dispatcher)",
);
}
options.agent = AGENT.defaultAgent;
}
};
let oldLocalAddressWarning = true;
exports.applyOldLocalAddress = options => {
if (!options?.requestOptions?.localAddress || options.requestOptions.localAddress === options.agent.localAddress)
return;
options.agent = AGENT.createAgent(undefined, { localAddress: options.requestOptions.localAddress });
if (oldLocalAddressWarning) {
oldLocalAddressWarning = false;
console.warn(
"\x1b[33mWARNING:\x1B[0m Using old localAddress option, " +
"please add it to the agent options instead. (https://github.com/distubejs/ytdl-core#ip-rotation)",
);
}
};
let oldIpRotationsWarning = true;
exports.applyIPv6Rotations = options => {
if (options.IPv6Block) {
options.requestOptions = Object.assign({}, options.requestOptions, {
localAddress: getRandomIPv6(options.IPv6Block),
});
if (oldIpRotationsWarning) {
oldIpRotationsWarning = false;
oldLocalAddressWarning = false;
console.warn(
"\x1b[33mWARNING:\x1B[0m IPv6Block option is deprecated, " +
"please create your own ip rotation instead. (https://github.com/distubejs/ytdl-core#ip-rotation)",
);
}
}
};
exports.applyDefaultHeaders = options => {
options.requestOptions = Object.assign({}, options.requestOptions);
options.requestOptions.headers = Object.assign(
{},
{
// eslint-disable-next-line max-len
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.101 Safari/537.36",
},
options.requestOptions.headers,
);
};
exports.generateClientPlaybackNonce = length => {
const CPN_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
return Array.from({ length }, () => CPN_CHARS[Math.floor(Math.random() * CPN_CHARS.length)]).join("");
};
exports.applyPlayerClients = options => {
if (!options.playerClients || options.playerClients.length === 0) {
options.playerClients = ["WEB_EMBEDDED", "IOS", "ANDROID", "TV"];
}
};