├── .gitattributes ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── config.yml └── workflows │ └── publish.yml ├── .gitignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── lib ├── agent.js ├── cache.js ├── format-utils.js ├── formats.js ├── index.js ├── info-extras.js ├── info.js ├── sig.js ├── url-utils.js └── utils.js ├── package.json ├── pnpm-lock.yaml ├── tsconfig.json ├── tslint.json └── typings └── index.d.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | test/files/html5player/*.js linguist-detectable=false 2 | test/files/videos/**/* linguist-detectable=false 3 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [INSERT CONTACT METHOD]. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][mozilla coc]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][faq]. Translations are available 126 | at [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 130 | [mozilla coc]: https://github.com/mozilla/diversity 131 | [faq]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | **The issue tracker is only for bug reports. If you have a question or an enhancement suggestion, please post it in [GitHub Discussions (preferred)](https://github.com/distubejs/ytdl-core/discussions) or the [DisTube Support Server](https://discord.gg/feaDd9h) instead of opening an issue.** 4 | 5 | If you wish to contribute to @distube/ytdl-core, feel free to fork the repository and submit a pull request. 6 | 7 | ## Setup 8 | 9 | 1. Fork & clone the repository, make sure you are on the correct branch 10 | 2. Run `npm ci` 11 | 3. Code your idea 12 | 4. Run `npm run lint` to run ESLint 13 | 5. [Submit a pull request](https://github.com/distubejs/ytdl-core/pulls) (Make sure you follow the [conventional commit format](https://www.conventionalcommits.org/en/v1.0.0/)) 14 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: skick 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug found in @distube/ytdl-core 4 | title: "" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | ## Describe the bug and how to reproduce it 10 | 11 | 12 | 13 | ## Environment 14 | 15 | - @distube/ytdl-core version: 16 | - Node.js version: 17 | - Operating system: 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-issue-config.json 2 | blank_issues_enabled: false 3 | contact_links: 4 | - name: "Error: Sign in to confirm you’re not a bot" 5 | url: "https://github.com/distubejs/ytdl-core/issues/21" 6 | about: "Don't create a new issue. Please check how to use cookie with the #21 issue and comment if you have any questions." 7 | - name: "WARNING: Could not parse *** function" 8 | url: "https://github.com/distubejs/ytdl-core/issues/144" 9 | about: "Don't create a new issue. Please upload your file to the #144 issue and comment if you have any questions." 10 | - name: Discord Support Server 11 | url: https://discord.gg/feaDd9h 12 | about: > 13 | Any kind of questions should go onto discord support server. 14 | I recommend using this even if you suspect a bug or have 15 | a feature request for keeping the issue tracker clean. 16 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish @distube/ytdl-core 2 | on: 3 | push: 4 | tags: 5 | - "*" 6 | jobs: 7 | publish: 8 | name: Build & Publish 9 | runs-on: ubuntu-latest 10 | permissions: 11 | id-token: write 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v4 15 | 16 | - name: Install pnpm 17 | uses: pnpm/action-setup@v3 18 | with: 19 | version: latest 20 | 21 | - name: Install Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 20 25 | cache: "pnpm" 26 | registry-url: "https://registry.npmjs.org" 27 | 28 | - name: Install dependencies 29 | run: pnpm install --ignore-scripts --frozen-lockfile 30 | 31 | - name: Publish 32 | run: | 33 | pnpm publish --access public --no-git-checks 34 | pnpm deprecate @distube/ytdl-core@"< ${{ github.ref_name }}" "This version is deprecated, please upgrade to the latest version." 35 | env: 36 | NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 37 | NPM_CONFIG_PROVENANCE: true 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .nyc_output 4 | .DS_Store 5 | .idea/* 6 | yarn.lock 7 | package-lock.json -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "printWidth": 120, 4 | "endOfLine": "lf", 5 | "quoteProps": "as-needed", 6 | "arrowParens": "avoid", 7 | "tabWidth": 2 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (C) 2012-present by fent 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @distube/ytdl-core 2 | 3 | > [!IMPORTANT] 4 | > [@distube/youtube](https://github.com/distubejs/extractor-plugins) depends on [youtubei.js](https://github.com/LuanRT/YouTube.js) from now on.\ 5 | > This fork will be no longer maintained. Please use alternatives (e.g. [youtubei.js](https://github.com/LuanRT/YouTube.js)) instead. 6 | 7 | DisTube fork of `ytdl-core`. This fork is dedicated to fixing bugs and adding features that are not merged into the original repo as soon as possible. 8 | 9 | Buy Me a Coffee at ko-fi.com 10 | 11 | ## Installation 12 | 13 | ```bash 14 | npm install @distube/ytdl-core@latest 15 | ``` 16 | 17 | Make sure you're installing the latest version of `@distube/ytdl-core` to keep up with the latest fixes. 18 | 19 | ## Usage 20 | 21 | ```js 22 | const ytdl = require("@distube/ytdl-core"); 23 | // TypeScript: import ytdl from '@distube/ytdl-core'; with --esModuleInterop 24 | // TypeScript: import * as ytdl from '@distube/ytdl-core'; with --allowSyntheticDefaultImports 25 | // TypeScript: import ytdl = require('@distube/ytdl-core'); with neither of the above 26 | 27 | // Download a video 28 | ytdl("http://www.youtube.com/watch?v=aqz-KE-bpKQ").pipe(require("fs").createWriteStream("video.mp4")); 29 | 30 | // Get video info 31 | ytdl.getBasicInfo("http://www.youtube.com/watch?v=aqz-KE-bpKQ").then(info => { 32 | console.log(info.videoDetails.title); 33 | }); 34 | 35 | // Get video info with download formats 36 | ytdl.getInfo("http://www.youtube.com/watch?v=aqz-KE-bpKQ").then(info => { 37 | console.log(info.formats); 38 | }); 39 | ``` 40 | 41 | ### Cookies Support 42 | 43 | ```js 44 | const ytdl = require("@distube/ytdl-core"); 45 | 46 | // (Optional) Below are examples, NOT the recommended options 47 | const cookies = [ 48 | { name: "cookie1", value: "COOKIE1_HERE" }, 49 | { name: "cookie2", value: "COOKIE2_HERE" }, 50 | ]; 51 | 52 | // (Optional) http-cookie-agent / undici agent options 53 | // Below are examples, NOT the recommended options 54 | const agentOptions = { 55 | pipelining: 5, 56 | maxRedirections: 0, 57 | localAddress: "127.0.0.1", 58 | }; 59 | 60 | // agent should be created once if you don't want to change your cookie 61 | const agent = ytdl.createAgent(cookies, agentOptions); 62 | 63 | ytdl.getBasicInfo("http://www.youtube.com/watch?v=aqz-KE-bpKQ", { agent }); 64 | ytdl.getInfo("http://www.youtube.com/watch?v=aqz-KE-bpKQ", { agent }); 65 | ``` 66 | 67 | #### How to get cookies 68 | 69 | - Install [EditThisCookie](http://www.editthiscookie.com/) extension for your browser. 70 | - Go to [YouTube](https://www.youtube.com/). 71 | - Log in to your account. (You should use a new account for this purpose) 72 | - Click on the extension icon and click "Export" icon. 73 | - Your cookies will be added to your clipboard and paste it into your code. 74 | 75 | > [!WARNING] 76 | > Don't logout it by clicking logout button on youtube/google account manager, it will expire your cookies. 77 | > You can delete your browser's cookies to log it out on your browser. 78 | > Or use incognito mode to get your cookies then close it. 79 | 80 | > [!WARNING] 81 | > Paste all the cookies array from clipboard into `createAgent` function. Don't remove/edit any cookies if you don't know what you're doing. 82 | 83 | > [!WARNING] 84 | > Make sure your account, which logged in when you getting your cookies, use 1 IP at the same time only. It will make your cookies alive longer. 85 | 86 | ```js 87 | const ytdl = require("@distube/ytdl-core"); 88 | const agent = ytdl.createAgent([ 89 | { 90 | domain: ".youtube.com", 91 | expirationDate: 1234567890, 92 | hostOnly: false, 93 | httpOnly: true, 94 | name: "---xxx---", 95 | path: "/", 96 | sameSite: "no_restriction", 97 | secure: true, 98 | session: false, 99 | value: "---xxx---", 100 | }, 101 | { 102 | "...": "...", 103 | }, 104 | ]); 105 | ``` 106 | 107 | - Or you can paste your cookies array into a file and use `fs.readFileSync` to read it. 108 | 109 | ```js 110 | const ytdl = require("@distube/ytdl-core"); 111 | const fs = require("fs"); 112 | const agent = ytdl.createAgent(JSON.parse(fs.readFileSync("cookies.json"))); 113 | ``` 114 | 115 | ### Proxy Support 116 | 117 | ```js 118 | const ytdl = require("@distube/ytdl-core"); 119 | 120 | const agent = ytdl.createProxyAgent({ uri: "my.proxy.server" }); 121 | 122 | ytdl.getBasicInfo("http://www.youtube.com/watch?v=aqz-KE-bpKQ", { agent }); 123 | ytdl.getInfo("http://www.youtube.com/watch?v=aqz-KE-bpKQ", { agent }); 124 | ``` 125 | 126 | Use both proxy and cookies: 127 | 128 | ```js 129 | const ytdl = require("@distube/ytdl-core"); 130 | 131 | const agent = ytdl.createProxyAgent({ uri: "my.proxy.server" }, [{ name: "cookie", value: "COOKIE_HERE" }]); 132 | 133 | ytdl.getBasicInfo("http://www.youtube.com/watch?v=aqz-KE-bpKQ", { agent }); 134 | ytdl.getInfo("http://www.youtube.com/watch?v=aqz-KE-bpKQ", { agent }); 135 | ``` 136 | 137 | ### IP Rotation 138 | 139 | _Built-in ip rotation (`getRandomIPv6`) won't be updated and will be removed in the future, create your own ip rotation instead._ 140 | 141 | To implement IP rotation, you need to assign the desired IP address to the `localAddress` property within `undici.Agent.Options`. 142 | Therefore, you'll need to use a different `ytdl.Agent` for each IP address you want to use. 143 | 144 | ```js 145 | const ytdl = require("@distube/ytdl-core"); 146 | const { getRandomIPv6 } = require("@distube/ytdl-core/lib/utils"); 147 | 148 | const agentForARandomIP = ytdl.createAgent(undefined, { 149 | localAddress: getRandomIPv6("2001:2::/48"), 150 | }); 151 | 152 | ytdl.getBasicInfo("http://www.youtube.com/watch?v=aqz-KE-bpKQ", { agent: agentForARandomIP }); 153 | 154 | const agentForAnotherRandomIP = ytdl.createAgent(undefined, { 155 | localAddress: getRandomIPv6("2001:2::/48"), 156 | }); 157 | 158 | ytdl.getInfo("http://www.youtube.com/watch?v=aqz-KE-bpKQ", { agent: agentForAnotherRandomIP }); 159 | ``` 160 | 161 | ## API 162 | 163 | You can find the API documentation in the [original repo](https://github.com/fent/node-ytdl-core#api). Except a few changes: 164 | 165 | ### `ytdl.getInfoOptions` 166 | 167 | - `requestOptions` is now `undici`'s [`RequestOptions`](https://github.com/nodejs/undici#undicirequesturl-options-promise). 168 | - `agent`: [`ytdl.Agent`](https://github.com/distubejs/ytdl-core/blob/master/typings/index.d.ts#L10-L14) 169 | - `playerClients`: An array of player clients to use. Accepts `WEB`, `WEB_EMBEDDED`, `TV`, `IOS`, and `ANDROID`. Defaults to `["WEB_EMBEDDED", "IOS", "ANDROID","TV"]`. 170 | - `fetch`: Custom fetch implementation. Defaults to `undici`'s request. 171 | 172 | ### `ytdl.createAgent([cookies]): ytdl.Agent` 173 | 174 | `cookies`: an array of json cookies exported with [EditThisCookie](http://www.editthiscookie.com/). 175 | 176 | ### `ytdl.createProxyAgent(proxy[, cookies]): ytdl.Agent` 177 | 178 | `proxy`: [`ProxyAgentOptions`](https://github.com/nodejs/undici/blob/main/docs/api/ProxyAgent.md#parameter-proxyagentoptions) contains your proxy server information. 179 | 180 | #### How to implement `ytdl.Agent` with your own Dispatcher 181 | 182 | You can find the example [here](https://github.com/distubejs/ytdl-core/blob/master/lib/cookie.js#L73-L86) 183 | 184 | ## Limitations 185 | 186 | ytdl cannot download videos that fall into the following 187 | 188 | - Regionally restricted (requires a [proxy](#proxy-support)) 189 | - Private (if you have access, requires [cookies](#cookies-support)) 190 | - Rentals (if you have access, requires [cookies](#cookies-support)) 191 | - YouTube Premium content (if you have access, requires [cookies](#cookies-support)) 192 | - Only [HLS Livestreams](https://en.wikipedia.org/wiki/HTTP_Live_Streaming) are currently supported. Other formats will get filtered out in ytdl.chooseFormats 193 | 194 | Generated download links are valid for 6 hours, and may only be downloadable from the same IP address. 195 | 196 | ## Rate Limiting 197 | 198 | When doing too many requests YouTube might block. This will result in your requests getting denied with HTTP-StatusCode 429. The following steps might help you: 199 | 200 | - Update `@distube/ytdl-core` to the latest version 201 | - Use proxies (you can find an example [here](#proxy-support)) 202 | - Extend the Proxy Idea by rotating (IPv6-)Addresses 203 | - read [this](https://github.com/fent/node-ytdl-core#how-does-using-an-ipv6-block-help) for more information about this 204 | - Use cookies (you can find an example [here](#cookies-support)) 205 | - for this to take effect you have to FIRST wait for the current rate limit to expire 206 | - Wait it out (it usually goes away within a few days) 207 | 208 | ## Update Checks 209 | 210 | The issue of using an outdated version of ytdl-core became so prevalent, that ytdl-core now checks for updates at run time, and every 12 hours. If it finds an update, it will print a warning to the console advising you to update. Due to the nature of this library, it is important to always use the latest version as YouTube continues to update. 211 | 212 | If you'd like to disable this update check, you can do so by providing the `YTDL_NO_UPDATE` env variable. 213 | 214 | ``` 215 | env YTDL_NO_UPDATE=1 node myapp.js 216 | ``` 217 | 218 | ## Related Projects 219 | 220 | - [DisTube](https://github.com/skick1234/DisTube) - A Discord.js module to simplify your music commands and play songs with audio filters on Discord without any API key. 221 | - [@distube/ytsr](https://github.com/distubejs/ytsr) - DisTube fork of [ytsr](https://github.com/TimeForANinja/node-ytsr). 222 | - [@distube/ytpl](https://github.com/distubejs/ytpl) - DisTube fork of [ytpl](https://github.com/TimeForANinja/node-ytpl). 223 | -------------------------------------------------------------------------------- /lib/agent.js: -------------------------------------------------------------------------------- 1 | const { ProxyAgent } = require("undici"); 2 | const { HttpsProxyAgent } = require("https-proxy-agent"); 3 | const { Cookie, CookieJar, canonicalDomain } = require("tough-cookie"); 4 | const { CookieAgent, cookie } = require("http-cookie-agent/undici"); 5 | 6 | const convertSameSite = sameSite => { 7 | switch (sameSite) { 8 | case "strict": 9 | return "strict"; 10 | case "lax": 11 | return "lax"; 12 | case "no_restriction": 13 | case "unspecified": 14 | default: 15 | return "none"; 16 | } 17 | }; 18 | 19 | const convertCookie = cookie => 20 | cookie instanceof Cookie 21 | ? cookie 22 | : new Cookie({ 23 | key: cookie.name, 24 | value: cookie.value, 25 | expires: typeof cookie.expirationDate === "number" ? new Date(cookie.expirationDate * 1000) : "Infinity", 26 | domain: canonicalDomain(cookie.domain), 27 | path: cookie.path, 28 | secure: cookie.secure, 29 | httpOnly: cookie.httpOnly, 30 | sameSite: convertSameSite(cookie.sameSite), 31 | hostOnly: cookie.hostOnly, 32 | }); 33 | 34 | const addCookies = (exports.addCookies = (jar, cookies) => { 35 | if (!cookies || !Array.isArray(cookies)) { 36 | throw new Error("cookies must be an array"); 37 | } 38 | if (!cookies.some(c => c.name === "SOCS")) { 39 | cookies.push({ 40 | domain: ".youtube.com", 41 | hostOnly: false, 42 | httpOnly: false, 43 | name: "SOCS", 44 | path: "/", 45 | sameSite: "lax", 46 | secure: true, 47 | session: false, 48 | value: "CAI", 49 | }); 50 | } 51 | for (const cookie of cookies) { 52 | jar.setCookieSync(convertCookie(cookie), "https://www.youtube.com"); 53 | } 54 | }); 55 | 56 | exports.addCookiesFromString = (jar, cookies) => { 57 | if (!cookies || typeof cookies !== "string") { 58 | throw new Error("cookies must be a string"); 59 | } 60 | return addCookies( 61 | jar, 62 | cookies 63 | .split(";") 64 | .map(c => Cookie.parse(c)) 65 | .filter(Boolean), 66 | ); 67 | }; 68 | 69 | const createAgent = (exports.createAgent = (cookies = [], opts = {}) => { 70 | const options = Object.assign({}, opts); 71 | if (!options.cookies) { 72 | const jar = new CookieJar(); 73 | addCookies(jar, cookies); 74 | options.cookies = { jar }; 75 | } 76 | return { 77 | dispatcher: new CookieAgent(options), 78 | localAddress: options.localAddress, 79 | jar: options.cookies.jar, 80 | }; 81 | }); 82 | 83 | exports.createProxyAgent = (options, cookies = []) => { 84 | if (!cookies) cookies = []; 85 | if (typeof options === "string") options = { uri: options }; 86 | const jar = new CookieJar(); 87 | addCookies(jar, cookies); 88 | 89 | // ProxyAgent type that node httplibrary supports 90 | const agent = new HttpsProxyAgent(options.uri); 91 | 92 | // ProxyAgent type that undici supports 93 | const dispatcher = new ProxyAgent(proxyOptions).compose(cookie({ jar })); 94 | 95 | return { dispatcher, agent, jar, localAddress: options.localAddress }; 96 | }; 97 | 98 | exports.defaultAgent = createAgent(); 99 | -------------------------------------------------------------------------------- /lib/cache.js: -------------------------------------------------------------------------------- 1 | const { setTimeout } = require("timers"); 2 | 3 | // A cache that expires. 4 | module.exports = class Cache extends Map { 5 | constructor(timeout = 1000) { 6 | super(); 7 | this.timeout = timeout; 8 | } 9 | set(key, value) { 10 | if (this.has(key)) { 11 | clearTimeout(super.get(key).tid); 12 | } 13 | super.set(key, { 14 | tid: setTimeout(this.delete.bind(this, key), this.timeout).unref(), 15 | value, 16 | }); 17 | } 18 | get(key) { 19 | let entry = super.get(key); 20 | if (entry) { 21 | return entry.value; 22 | } 23 | return null; 24 | } 25 | getOrSet(key, fn) { 26 | if (this.has(key)) { 27 | return this.get(key); 28 | } else { 29 | let value = fn(); 30 | this.set(key, value); 31 | (async () => { 32 | try { 33 | await value; 34 | } catch (err) { 35 | this.delete(key); 36 | } 37 | })(); 38 | return value; 39 | } 40 | } 41 | delete(key) { 42 | let entry = super.get(key); 43 | if (entry) { 44 | clearTimeout(entry.tid); 45 | super.delete(key); 46 | } 47 | } 48 | clear() { 49 | for (let entry of this.values()) { 50 | clearTimeout(entry.tid); 51 | } 52 | super.clear(); 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /lib/format-utils.js: -------------------------------------------------------------------------------- 1 | const utils = require("./utils"); 2 | const FORMATS = require("./formats"); 3 | 4 | // Use these to help sort formats, higher index is better. 5 | const audioEncodingRanks = ["mp4a", "mp3", "vorbis", "aac", "opus", "flac"]; 6 | const videoEncodingRanks = ["mp4v", "avc1", "Sorenson H.283", "MPEG-4 Visual", "VP8", "VP9", "H.264"]; 7 | 8 | const getVideoBitrate = format => format.bitrate || 0; 9 | const getVideoEncodingRank = format => videoEncodingRanks.findIndex(enc => format.codecs?.includes(enc)); 10 | const getAudioBitrate = format => format.audioBitrate || 0; 11 | const getAudioEncodingRank = format => audioEncodingRanks.findIndex(enc => format.codecs?.includes(enc)); 12 | 13 | /** 14 | * Sort formats by a list of functions. 15 | * 16 | * @param {Object} a 17 | * @param {Object} b 18 | * @param {Array.} sortBy 19 | * @returns {number} 20 | */ 21 | const sortFormatsBy = (a, b, sortBy) => { 22 | let res = 0; 23 | for (let fn of sortBy) { 24 | res = fn(b) - fn(a); 25 | if (res !== 0) { 26 | break; 27 | } 28 | } 29 | return res; 30 | }; 31 | 32 | const sortFormatsByVideo = (a, b) => 33 | sortFormatsBy(a, b, [format => parseInt(format.qualityLabel), getVideoBitrate, getVideoEncodingRank]); 34 | 35 | const sortFormatsByAudio = (a, b) => sortFormatsBy(a, b, [getAudioBitrate, getAudioEncodingRank]); 36 | 37 | /** 38 | * Sort formats from highest quality to lowest. 39 | * 40 | * @param {Object} a 41 | * @param {Object} b 42 | * @returns {number} 43 | */ 44 | exports.sortFormats = (a, b) => 45 | sortFormatsBy(a, b, [ 46 | // Formats with both video and audio are ranked highest. 47 | format => +!!format.isHLS, 48 | format => +!!format.isDashMPD, 49 | format => +(format.contentLength > 0), 50 | format => +(format.hasVideo && format.hasAudio), 51 | format => +format.hasVideo, 52 | format => parseInt(format.qualityLabel) || 0, 53 | getVideoBitrate, 54 | getAudioBitrate, 55 | getVideoEncodingRank, 56 | getAudioEncodingRank, 57 | ]); 58 | 59 | /** 60 | * Choose a format depending on the given options. 61 | * 62 | * @param {Array.} formats 63 | * @param {Object} options 64 | * @returns {Object} 65 | * @throws {Error} when no format matches the filter/format rules 66 | */ 67 | exports.chooseFormat = (formats, options) => { 68 | if (typeof options.format === "object") { 69 | if (!options.format.url) { 70 | throw Error("Invalid format given, did you use `ytdl.getInfo()`?"); 71 | } 72 | return options.format; 73 | } 74 | 75 | if (options.filter) { 76 | formats = exports.filterFormats(formats, options.filter); 77 | } 78 | 79 | // We currently only support HLS-Formats for livestreams 80 | // So we (now) remove all non-HLS streams 81 | if (formats.some(fmt => fmt.isHLS)) { 82 | formats = formats.filter(fmt => fmt.isHLS || !fmt.isLive); 83 | } 84 | 85 | let format; 86 | const quality = options.quality || "highest"; 87 | switch (quality) { 88 | case "highest": 89 | format = formats[0]; 90 | break; 91 | 92 | case "lowest": 93 | format = formats[formats.length - 1]; 94 | break; 95 | 96 | case "highestaudio": { 97 | formats = exports.filterFormats(formats, "audio"); 98 | formats.sort(sortFormatsByAudio); 99 | // Filter for only the best audio format 100 | const bestAudioFormat = formats[0]; 101 | formats = formats.filter(f => sortFormatsByAudio(bestAudioFormat, f) === 0); 102 | // Check for the worst video quality for the best audio quality and pick according 103 | // This does not loose default sorting of video encoding and bitrate 104 | const worstVideoQuality = formats.map(f => parseInt(f.qualityLabel) || 0).sort((a, b) => a - b)[0]; 105 | format = formats.find(f => (parseInt(f.qualityLabel) || 0) === worstVideoQuality); 106 | break; 107 | } 108 | 109 | case "lowestaudio": 110 | formats = exports.filterFormats(formats, "audio"); 111 | formats.sort(sortFormatsByAudio); 112 | format = formats[formats.length - 1]; 113 | break; 114 | 115 | case "highestvideo": { 116 | formats = exports.filterFormats(formats, "video"); 117 | formats.sort(sortFormatsByVideo); 118 | // Filter for only the best video format 119 | const bestVideoFormat = formats[0]; 120 | formats = formats.filter(f => sortFormatsByVideo(bestVideoFormat, f) === 0); 121 | // Check for the worst audio quality for the best video quality and pick according 122 | // This does not loose default sorting of audio encoding and bitrate 123 | const worstAudioQuality = formats.map(f => f.audioBitrate || 0).sort((a, b) => a - b)[0]; 124 | format = formats.find(f => (f.audioBitrate || 0) === worstAudioQuality); 125 | break; 126 | } 127 | 128 | case "lowestvideo": 129 | formats = exports.filterFormats(formats, "video"); 130 | formats.sort(sortFormatsByVideo); 131 | format = formats[formats.length - 1]; 132 | break; 133 | 134 | default: 135 | format = getFormatByQuality(quality, formats); 136 | break; 137 | } 138 | 139 | if (!format) { 140 | throw Error(`No such format found: ${quality}`); 141 | } 142 | return format; 143 | }; 144 | 145 | /** 146 | * Gets a format based on quality or array of quality's 147 | * 148 | * @param {string|[string]} quality 149 | * @param {[Object]} formats 150 | * @returns {Object} 151 | */ 152 | const getFormatByQuality = (quality, formats) => { 153 | let getFormat = itag => formats.find(format => `${format.itag}` === `${itag}`); 154 | if (Array.isArray(quality)) { 155 | return getFormat(quality.find(q => getFormat(q))); 156 | } else { 157 | return getFormat(quality); 158 | } 159 | }; 160 | 161 | /** 162 | * @param {Array.} formats 163 | * @param {Function} filter 164 | * @returns {Array.} 165 | */ 166 | exports.filterFormats = (formats, filter) => { 167 | let fn; 168 | switch (filter) { 169 | case "videoandaudio": 170 | case "audioandvideo": 171 | fn = format => format.hasVideo && format.hasAudio; 172 | break; 173 | 174 | case "video": 175 | fn = format => format.hasVideo; 176 | break; 177 | 178 | case "videoonly": 179 | fn = format => format.hasVideo && !format.hasAudio; 180 | break; 181 | 182 | case "audio": 183 | fn = format => format.hasAudio; 184 | break; 185 | 186 | case "audioonly": 187 | fn = format => !format.hasVideo && format.hasAudio; 188 | break; 189 | 190 | default: 191 | if (typeof filter === "function") { 192 | fn = filter; 193 | } else { 194 | throw TypeError(`Given filter (${filter}) is not supported`); 195 | } 196 | } 197 | return formats.filter(format => !!format.url && fn(format)); 198 | }; 199 | 200 | /** 201 | * @param {Object} format 202 | * @returns {Object} 203 | */ 204 | exports.addFormatMeta = format => { 205 | format = Object.assign({}, FORMATS[format.itag], format); 206 | format.hasVideo = !!format.qualityLabel; 207 | format.hasAudio = !!format.audioBitrate; 208 | format.container = format.mimeType ? format.mimeType.split(";")[0].split("/")[1] : null; 209 | format.codecs = format.mimeType ? utils.between(format.mimeType, 'codecs="', '"') : null; 210 | format.videoCodec = format.hasVideo && format.codecs ? format.codecs.split(", ")[0] : null; 211 | format.audioCodec = format.hasAudio && format.codecs ? format.codecs.split(", ").slice(-1)[0] : null; 212 | format.isLive = /\bsource[/=]yt_live_broadcast\b/.test(format.url); 213 | format.isHLS = /\/manifest\/hls_(variant|playlist)\//.test(format.url); 214 | format.isDashMPD = /\/manifest\/dash\//.test(format.url); 215 | return format; 216 | }; 217 | -------------------------------------------------------------------------------- /lib/formats.js: -------------------------------------------------------------------------------- 1 | /** 2 | * http://en.wikipedia.org/wiki/YouTube#Quality_and_formats 3 | */ 4 | module.exports = { 5 | 5: { 6 | mimeType: 'video/flv; codecs="Sorenson H.283, mp3"', 7 | qualityLabel: "240p", 8 | bitrate: 250000, 9 | audioBitrate: 64, 10 | }, 11 | 12 | 6: { 13 | mimeType: 'video/flv; codecs="Sorenson H.263, mp3"', 14 | qualityLabel: "270p", 15 | bitrate: 800000, 16 | audioBitrate: 64, 17 | }, 18 | 19 | 13: { 20 | mimeType: 'video/3gp; codecs="MPEG-4 Visual, aac"', 21 | qualityLabel: null, 22 | bitrate: 500000, 23 | audioBitrate: null, 24 | }, 25 | 26 | 17: { 27 | mimeType: 'video/3gp; codecs="MPEG-4 Visual, aac"', 28 | qualityLabel: "144p", 29 | bitrate: 50000, 30 | audioBitrate: 24, 31 | }, 32 | 33 | 18: { 34 | mimeType: 'video/mp4; codecs="H.264, aac"', 35 | qualityLabel: "360p", 36 | bitrate: 500000, 37 | audioBitrate: 96, 38 | }, 39 | 40 | 22: { 41 | mimeType: 'video/mp4; codecs="H.264, aac"', 42 | qualityLabel: "720p", 43 | bitrate: 2000000, 44 | audioBitrate: 192, 45 | }, 46 | 47 | 34: { 48 | mimeType: 'video/flv; codecs="H.264, aac"', 49 | qualityLabel: "360p", 50 | bitrate: 500000, 51 | audioBitrate: 128, 52 | }, 53 | 54 | 35: { 55 | mimeType: 'video/flv; codecs="H.264, aac"', 56 | qualityLabel: "480p", 57 | bitrate: 800000, 58 | audioBitrate: 128, 59 | }, 60 | 61 | 36: { 62 | mimeType: 'video/3gp; codecs="MPEG-4 Visual, aac"', 63 | qualityLabel: "240p", 64 | bitrate: 175000, 65 | audioBitrate: 32, 66 | }, 67 | 68 | 37: { 69 | mimeType: 'video/mp4; codecs="H.264, aac"', 70 | qualityLabel: "1080p", 71 | bitrate: 3000000, 72 | audioBitrate: 192, 73 | }, 74 | 75 | 38: { 76 | mimeType: 'video/mp4; codecs="H.264, aac"', 77 | qualityLabel: "3072p", 78 | bitrate: 3500000, 79 | audioBitrate: 192, 80 | }, 81 | 82 | 43: { 83 | mimeType: 'video/webm; codecs="VP8, vorbis"', 84 | qualityLabel: "360p", 85 | bitrate: 500000, 86 | audioBitrate: 128, 87 | }, 88 | 89 | 44: { 90 | mimeType: 'video/webm; codecs="VP8, vorbis"', 91 | qualityLabel: "480p", 92 | bitrate: 1000000, 93 | audioBitrate: 128, 94 | }, 95 | 96 | 45: { 97 | mimeType: 'video/webm; codecs="VP8, vorbis"', 98 | qualityLabel: "720p", 99 | bitrate: 2000000, 100 | audioBitrate: 192, 101 | }, 102 | 103 | 46: { 104 | mimeType: 'audio/webm; codecs="vp8, vorbis"', 105 | qualityLabel: "1080p", 106 | bitrate: null, 107 | audioBitrate: 192, 108 | }, 109 | 110 | 82: { 111 | mimeType: 'video/mp4; codecs="H.264, aac"', 112 | qualityLabel: "360p", 113 | bitrate: 500000, 114 | audioBitrate: 96, 115 | }, 116 | 117 | 83: { 118 | mimeType: 'video/mp4; codecs="H.264, aac"', 119 | qualityLabel: "240p", 120 | bitrate: 500000, 121 | audioBitrate: 96, 122 | }, 123 | 124 | 84: { 125 | mimeType: 'video/mp4; codecs="H.264, aac"', 126 | qualityLabel: "720p", 127 | bitrate: 2000000, 128 | audioBitrate: 192, 129 | }, 130 | 131 | 85: { 132 | mimeType: 'video/mp4; codecs="H.264, aac"', 133 | qualityLabel: "1080p", 134 | bitrate: 3000000, 135 | audioBitrate: 192, 136 | }, 137 | 138 | 91: { 139 | mimeType: 'video/ts; codecs="H.264, aac"', 140 | qualityLabel: "144p", 141 | bitrate: 100000, 142 | audioBitrate: 48, 143 | }, 144 | 145 | 92: { 146 | mimeType: 'video/ts; codecs="H.264, aac"', 147 | qualityLabel: "240p", 148 | bitrate: 150000, 149 | audioBitrate: 48, 150 | }, 151 | 152 | 93: { 153 | mimeType: 'video/ts; codecs="H.264, aac"', 154 | qualityLabel: "360p", 155 | bitrate: 500000, 156 | audioBitrate: 128, 157 | }, 158 | 159 | 94: { 160 | mimeType: 'video/ts; codecs="H.264, aac"', 161 | qualityLabel: "480p", 162 | bitrate: 800000, 163 | audioBitrate: 128, 164 | }, 165 | 166 | 95: { 167 | mimeType: 'video/ts; codecs="H.264, aac"', 168 | qualityLabel: "720p", 169 | bitrate: 1500000, 170 | audioBitrate: 256, 171 | }, 172 | 173 | 96: { 174 | mimeType: 'video/ts; codecs="H.264, aac"', 175 | qualityLabel: "1080p", 176 | bitrate: 2500000, 177 | audioBitrate: 256, 178 | }, 179 | 180 | 100: { 181 | mimeType: 'audio/webm; codecs="VP8, vorbis"', 182 | qualityLabel: "360p", 183 | bitrate: null, 184 | audioBitrate: 128, 185 | }, 186 | 187 | 101: { 188 | mimeType: 'audio/webm; codecs="VP8, vorbis"', 189 | qualityLabel: "360p", 190 | bitrate: null, 191 | audioBitrate: 192, 192 | }, 193 | 194 | 102: { 195 | mimeType: 'audio/webm; codecs="VP8, vorbis"', 196 | qualityLabel: "720p", 197 | bitrate: null, 198 | audioBitrate: 192, 199 | }, 200 | 201 | 120: { 202 | mimeType: 'video/flv; codecs="H.264, aac"', 203 | qualityLabel: "720p", 204 | bitrate: 2000000, 205 | audioBitrate: 128, 206 | }, 207 | 208 | 127: { 209 | mimeType: 'audio/ts; codecs="aac"', 210 | qualityLabel: null, 211 | bitrate: null, 212 | audioBitrate: 96, 213 | }, 214 | 215 | 128: { 216 | mimeType: 'audio/ts; codecs="aac"', 217 | qualityLabel: null, 218 | bitrate: null, 219 | audioBitrate: 96, 220 | }, 221 | 222 | 132: { 223 | mimeType: 'video/ts; codecs="H.264, aac"', 224 | qualityLabel: "240p", 225 | bitrate: 150000, 226 | audioBitrate: 48, 227 | }, 228 | 229 | 133: { 230 | mimeType: 'video/mp4; codecs="H.264"', 231 | qualityLabel: "240p", 232 | bitrate: 200000, 233 | audioBitrate: null, 234 | }, 235 | 236 | 134: { 237 | mimeType: 'video/mp4; codecs="H.264"', 238 | qualityLabel: "360p", 239 | bitrate: 300000, 240 | audioBitrate: null, 241 | }, 242 | 243 | 135: { 244 | mimeType: 'video/mp4; codecs="H.264"', 245 | qualityLabel: "480p", 246 | bitrate: 500000, 247 | audioBitrate: null, 248 | }, 249 | 250 | 136: { 251 | mimeType: 'video/mp4; codecs="H.264"', 252 | qualityLabel: "720p", 253 | bitrate: 1000000, 254 | audioBitrate: null, 255 | }, 256 | 257 | 137: { 258 | mimeType: 'video/mp4; codecs="H.264"', 259 | qualityLabel: "1080p", 260 | bitrate: 2500000, 261 | audioBitrate: null, 262 | }, 263 | 264 | 138: { 265 | mimeType: 'video/mp4; codecs="H.264"', 266 | qualityLabel: "4320p", 267 | bitrate: 13500000, 268 | audioBitrate: null, 269 | }, 270 | 271 | 139: { 272 | mimeType: 'audio/mp4; codecs="aac"', 273 | qualityLabel: null, 274 | bitrate: null, 275 | audioBitrate: 48, 276 | }, 277 | 278 | 140: { 279 | mimeType: 'audio/m4a; codecs="aac"', 280 | qualityLabel: null, 281 | bitrate: null, 282 | audioBitrate: 128, 283 | }, 284 | 285 | 141: { 286 | mimeType: 'audio/mp4; codecs="aac"', 287 | qualityLabel: null, 288 | bitrate: null, 289 | audioBitrate: 256, 290 | }, 291 | 292 | 151: { 293 | mimeType: 'video/ts; codecs="H.264, aac"', 294 | qualityLabel: "720p", 295 | bitrate: 50000, 296 | audioBitrate: 24, 297 | }, 298 | 299 | 160: { 300 | mimeType: 'video/mp4; codecs="H.264"', 301 | qualityLabel: "144p", 302 | bitrate: 100000, 303 | audioBitrate: null, 304 | }, 305 | 306 | 171: { 307 | mimeType: 'audio/webm; codecs="vorbis"', 308 | qualityLabel: null, 309 | bitrate: null, 310 | audioBitrate: 128, 311 | }, 312 | 313 | 172: { 314 | mimeType: 'audio/webm; codecs="vorbis"', 315 | qualityLabel: null, 316 | bitrate: null, 317 | audioBitrate: 192, 318 | }, 319 | 320 | 231: { 321 | mimeType: 'video/ts; codecs="H.264, aac"', 322 | qualityLabel: "480p", 323 | bitrate: 500000, 324 | audioBitrate: null, 325 | }, 326 | 327 | 232: { 328 | mimeType: 'video/ts; codecs="H.264, aac"', 329 | qualityLabel: "720p", 330 | bitrate: 800000, 331 | audioBitrate: null, 332 | }, 333 | 334 | 242: { 335 | mimeType: 'video/webm; codecs="VP9"', 336 | qualityLabel: "240p", 337 | bitrate: 100000, 338 | audioBitrate: null, 339 | }, 340 | 341 | 243: { 342 | mimeType: 'video/webm; codecs="VP9"', 343 | qualityLabel: "360p", 344 | bitrate: 250000, 345 | audioBitrate: null, 346 | }, 347 | 348 | 244: { 349 | mimeType: 'video/webm; codecs="VP9"', 350 | qualityLabel: "480p", 351 | bitrate: 500000, 352 | audioBitrate: null, 353 | }, 354 | 355 | 247: { 356 | mimeType: 'video/webm; codecs="VP9"', 357 | qualityLabel: "720p", 358 | bitrate: 700000, 359 | audioBitrate: null, 360 | }, 361 | 362 | 248: { 363 | mimeType: 'video/webm; codecs="VP9"', 364 | qualityLabel: "1080p", 365 | bitrate: 1500000, 366 | audioBitrate: null, 367 | }, 368 | 369 | 249: { 370 | mimeType: 'audio/webm; codecs="opus"', 371 | qualityLabel: null, 372 | bitrate: null, 373 | audioBitrate: 48, 374 | }, 375 | 376 | 250: { 377 | mimeType: 'audio/webm; codecs="opus"', 378 | qualityLabel: null, 379 | bitrate: null, 380 | audioBitrate: 64, 381 | }, 382 | 383 | 251: { 384 | mimeType: 'audio/webm; codecs="opus"', 385 | qualityLabel: null, 386 | bitrate: null, 387 | audioBitrate: 160, 388 | }, 389 | 390 | 264: { 391 | mimeType: 'video/mp4; codecs="H.264"', 392 | qualityLabel: "1440p", 393 | bitrate: 4000000, 394 | audioBitrate: null, 395 | }, 396 | 397 | 266: { 398 | mimeType: 'video/mp4; codecs="H.264"', 399 | qualityLabel: "2160p", 400 | bitrate: 12500000, 401 | audioBitrate: null, 402 | }, 403 | 404 | 270: { 405 | mimeType: 'video/mp4; codecs="H.264"', 406 | qualityLabel: "1080p", 407 | bitrate: 2500000, 408 | audioBitrate: null, 409 | }, 410 | 411 | 271: { 412 | mimeType: 'video/webm; codecs="VP9"', 413 | qualityLabel: "1440p", 414 | bitrate: 9000000, 415 | audioBitrate: null, 416 | }, 417 | 418 | 272: { 419 | mimeType: 'video/webm; codecs="VP9"', 420 | qualityLabel: "4320p", 421 | bitrate: 20000000, 422 | audioBitrate: null, 423 | }, 424 | 425 | 278: { 426 | mimeType: 'video/webm; codecs="VP9"', 427 | qualityLabel: "144p 30fps", 428 | bitrate: 80000, 429 | audioBitrate: null, 430 | }, 431 | 432 | 298: { 433 | mimeType: 'video/mp4; codecs="H.264"', 434 | qualityLabel: "720p", 435 | bitrate: 3000000, 436 | audioBitrate: null, 437 | }, 438 | 439 | 299: { 440 | mimeType: 'video/mp4; codecs="H.264"', 441 | qualityLabel: "1080p", 442 | bitrate: 5500000, 443 | audioBitrate: null, 444 | }, 445 | 446 | 300: { 447 | mimeType: 'video/ts; codecs="H.264, aac"', 448 | qualityLabel: "720p", 449 | bitrate: 1318000, 450 | audioBitrate: 48, 451 | }, 452 | 453 | 301: { 454 | mimeType: 'video/ts; codecs="H.264, aac"', 455 | qualityLabel: "1080p", 456 | bitrate: 3000000, 457 | audioBitrate: 128, 458 | }, 459 | 460 | 302: { 461 | mimeType: 'video/webm; codecs="VP9"', 462 | qualityLabel: "720p HFR", 463 | bitrate: 2500000, 464 | audioBitrate: null, 465 | }, 466 | 467 | 303: { 468 | mimeType: 'video/webm; codecs="VP9"', 469 | qualityLabel: "1080p HFR", 470 | bitrate: 5000000, 471 | audioBitrate: null, 472 | }, 473 | 474 | 308: { 475 | mimeType: 'video/webm; codecs="VP9"', 476 | qualityLabel: "1440p HFR", 477 | bitrate: 10000000, 478 | audioBitrate: null, 479 | }, 480 | 481 | 311: { 482 | mimeType: 'video/webm; codecs="VP9"', 483 | qualityLabel: "720p", 484 | bitrate: 1250000, 485 | audioBitrate: null, 486 | }, 487 | 488 | 312: { 489 | mimeType: 'video/mp4; codecs="H.264"', 490 | qualityLabel: "1080p", 491 | bitrate: 2500000, 492 | audioBitrate: null, 493 | }, 494 | 495 | 313: { 496 | mimeType: 'video/webm; codecs="VP9"', 497 | qualityLabel: "2160p", 498 | bitrate: 13000000, 499 | audioBitrate: null, 500 | }, 501 | 502 | 315: { 503 | mimeType: 'video/webm; codecs="VP9"', 504 | qualityLabel: "2160p HFR", 505 | bitrate: 20000000, 506 | audioBitrate: null, 507 | }, 508 | 509 | 330: { 510 | mimeType: 'video/webm; codecs="VP9"', 511 | qualityLabel: "144p HDR, HFR", 512 | bitrate: 80000, 513 | audioBitrate: null, 514 | }, 515 | 516 | 331: { 517 | mimeType: 'video/webm; codecs="VP9"', 518 | qualityLabel: "240p HDR, HFR", 519 | bitrate: 100000, 520 | audioBitrate: null, 521 | }, 522 | 523 | 332: { 524 | mimeType: 'video/webm; codecs="VP9"', 525 | qualityLabel: "360p HDR, HFR", 526 | bitrate: 250000, 527 | audioBitrate: null, 528 | }, 529 | 530 | 333: { 531 | mimeType: 'video/webm; codecs="VP9"', 532 | qualityLabel: "240p HDR, HFR", 533 | bitrate: 500000, 534 | audioBitrate: null, 535 | }, 536 | 537 | 334: { 538 | mimeType: 'video/webm; codecs="VP9"', 539 | qualityLabel: "720p HDR, HFR", 540 | bitrate: 1000000, 541 | audioBitrate: null, 542 | }, 543 | 544 | 335: { 545 | mimeType: 'video/webm; codecs="VP9"', 546 | qualityLabel: "1080p HDR, HFR", 547 | bitrate: 1500000, 548 | audioBitrate: null, 549 | }, 550 | 551 | 336: { 552 | mimeType: 'video/webm; codecs="VP9"', 553 | qualityLabel: "1440p HDR, HFR", 554 | bitrate: 5000000, 555 | audioBitrate: null, 556 | }, 557 | 558 | 337: { 559 | mimeType: 'video/webm; codecs="VP9"', 560 | qualityLabel: "2160p HDR, HFR", 561 | bitrate: 12000000, 562 | audioBitrate: null, 563 | }, 564 | }; 565 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | const PassThrough = require("stream").PassThrough; 2 | const getInfo = require("./info"); 3 | const utils = require("./utils"); 4 | const formatUtils = require("./format-utils"); 5 | const urlUtils = require("./url-utils"); 6 | const miniget = require("miniget"); 7 | const m3u8stream = require("m3u8stream"); 8 | const { parseTimestamp } = require("m3u8stream"); 9 | const agent = require("./agent"); 10 | 11 | /** 12 | * @param {string} link 13 | * @param {!Object} options 14 | * @returns {ReadableStream} 15 | */ 16 | const ytdl = (link, options) => { 17 | const stream = createStream(options); 18 | ytdl.getInfo(link, options).then( 19 | info => { 20 | downloadFromInfoCallback(stream, info, options); 21 | }, 22 | stream.emit.bind(stream, "error"), 23 | ); 24 | return stream; 25 | }; 26 | module.exports = ytdl; 27 | 28 | ytdl.getBasicInfo = getInfo.getBasicInfo; 29 | ytdl.getInfo = getInfo.getInfo; 30 | ytdl.chooseFormat = formatUtils.chooseFormat; 31 | ytdl.filterFormats = formatUtils.filterFormats; 32 | ytdl.validateID = urlUtils.validateID; 33 | ytdl.validateURL = urlUtils.validateURL; 34 | ytdl.getURLVideoID = urlUtils.getURLVideoID; 35 | ytdl.getVideoID = urlUtils.getVideoID; 36 | ytdl.createAgent = agent.createAgent; 37 | ytdl.createProxyAgent = agent.createProxyAgent; 38 | ytdl.cache = { 39 | info: getInfo.cache, 40 | watch: getInfo.watchPageCache, 41 | }; 42 | ytdl.version = require("../package.json").version; 43 | 44 | const createStream = options => { 45 | const stream = new PassThrough({ highWaterMark: options?.highWaterMark || 1024 * 512 }); 46 | stream._destroy = () => { 47 | stream.destroyed = true; 48 | }; 49 | return stream; 50 | }; 51 | 52 | const pipeAndSetEvents = (req, stream, end) => { 53 | // Forward events from the request to the stream. 54 | ["abort", "request", "response", "error", "redirect", "retry", "reconnect"].forEach(event => { 55 | req.prependListener(event, stream.emit.bind(stream, event)); 56 | }); 57 | req.pipe(stream, { end }); 58 | }; 59 | 60 | /** 61 | * Chooses a format to download. 62 | * 63 | * @param {stream.Readable} stream 64 | * @param {Object} info 65 | * @param {Object} options 66 | */ 67 | const downloadFromInfoCallback = (stream, info, options) => { 68 | options = options || {}; 69 | 70 | let err = utils.playError(info.player_response); 71 | if (err) { 72 | stream.emit("error", err); 73 | return; 74 | } 75 | 76 | if (!info.formats.length) { 77 | stream.emit("error", Error("This video is unavailable")); 78 | return; 79 | } 80 | 81 | let format; 82 | try { 83 | format = formatUtils.chooseFormat(info.formats, options); 84 | } catch (e) { 85 | stream.emit("error", e); 86 | return; 87 | } 88 | stream.emit("info", info, format); 89 | if (stream.destroyed) { 90 | return; 91 | } 92 | 93 | let contentLength, 94 | downloaded = 0; 95 | const ondata = chunk => { 96 | downloaded += chunk.length; 97 | stream.emit("progress", chunk.length, downloaded, contentLength); 98 | }; 99 | 100 | utils.applyDefaultHeaders(options); 101 | if (options.IPv6Block) { 102 | options.requestOptions = Object.assign({}, options.requestOptions, { 103 | localAddress: utils.getRandomIPv6(options.IPv6Block), 104 | }); 105 | } 106 | 107 | if (options.agent) { 108 | // Set agent on both the miniget and m3u8stream requests 109 | options.requestOptions.agent = options.agent.agent; 110 | 111 | if (options.agent.jar) { 112 | utils.setPropInsensitive( 113 | options.requestOptions.headers, 114 | "cookie", 115 | options.agent.jar.getCookieStringSync("https://www.youtube.com"), 116 | ); 117 | } 118 | if (options.agent.localAddress) { 119 | options.requestOptions.localAddress = options.agent.localAddress; 120 | } 121 | } 122 | 123 | // Download the file in chunks, in this case the default is 10MB, 124 | // anything over this will cause youtube to throttle the download 125 | const dlChunkSize = typeof options.dlChunkSize === "number" ? options.dlChunkSize : 1024 * 1024 * 10; 126 | let req; 127 | let shouldEnd = true; 128 | 129 | if (format.isHLS || format.isDashMPD) { 130 | req = m3u8stream(format.url, { 131 | chunkReadahead: +info.live_chunk_readahead, 132 | begin: options.begin || (format.isLive && Date.now()), 133 | liveBuffer: options.liveBuffer, 134 | // Now we have passed not only custom "dispatcher" with undici ProxyAgent, but also "agent" field which is compatible for node http 135 | requestOptions: options.requestOptions, 136 | parser: format.isDashMPD ? "dash-mpd" : "m3u8", 137 | id: format.itag, 138 | }); 139 | 140 | req.on("progress", (segment, totalSegments) => { 141 | stream.emit("progress", segment.size, segment.num, totalSegments); 142 | }); 143 | pipeAndSetEvents(req, stream, shouldEnd); 144 | } else { 145 | const requestOptions = Object.assign({}, options.requestOptions, { 146 | maxReconnects: 6, 147 | maxRetries: 3, 148 | backoff: { inc: 500, max: 10000 }, 149 | }); 150 | 151 | let shouldBeChunked = dlChunkSize !== 0 && (!format.hasAudio || !format.hasVideo); 152 | 153 | if (shouldBeChunked) { 154 | let start = options.range?.start || 0; 155 | let end = start + dlChunkSize; 156 | const rangeEnd = options.range?.end; 157 | 158 | contentLength = options.range 159 | ? (rangeEnd ? rangeEnd + 1 : parseInt(format.contentLength)) - start 160 | : parseInt(format.contentLength); 161 | 162 | const getNextChunk = () => { 163 | if (stream.destroyed) return; 164 | if (!rangeEnd && end >= contentLength) end = 0; 165 | if (rangeEnd && end > rangeEnd) end = rangeEnd; 166 | shouldEnd = !end || end === rangeEnd; 167 | 168 | requestOptions.headers = Object.assign({}, requestOptions.headers, { 169 | Range: `bytes=${start}-${end || ""}`, 170 | }); 171 | req = miniget(format.url, requestOptions); 172 | req.on("data", ondata); 173 | req.on("end", () => { 174 | if (stream.destroyed) return; 175 | if (end && end !== rangeEnd) { 176 | start = end + 1; 177 | end += dlChunkSize; 178 | getNextChunk(); 179 | } 180 | }); 181 | pipeAndSetEvents(req, stream, shouldEnd); 182 | }; 183 | getNextChunk(); 184 | } else { 185 | // Audio only and video only formats don't support begin 186 | if (options.begin) { 187 | format.url += `&begin=${parseTimestamp(options.begin)}`; 188 | } 189 | if (options.range?.start || options.range?.end) { 190 | requestOptions.headers = Object.assign({}, requestOptions.headers, { 191 | Range: `bytes=${options.range.start || "0"}-${options.range.end || ""}`, 192 | }); 193 | } 194 | req = miniget(format.url, requestOptions); 195 | req.on("response", res => { 196 | if (stream.destroyed) return; 197 | contentLength = contentLength || parseInt(res.headers["content-length"]); 198 | }); 199 | req.on("data", ondata); 200 | pipeAndSetEvents(req, stream, shouldEnd); 201 | } 202 | } 203 | 204 | stream._destroy = () => { 205 | stream.destroyed = true; 206 | if (req) { 207 | req.destroy(); 208 | req.end(); 209 | } 210 | }; 211 | }; 212 | 213 | /** 214 | * Can be used to download video after its `info` is gotten through 215 | * `ytdl.getInfo()`. In case the user might want to look at the 216 | * `info` object before deciding to download. 217 | * 218 | * @param {Object} info 219 | * @param {!Object} options 220 | * @returns {ReadableStream} 221 | */ 222 | ytdl.downloadFromInfo = (info, options) => { 223 | const stream = createStream(options); 224 | if (!info.full) { 225 | throw Error("Cannot use `ytdl.downloadFromInfo()` when called with info from `ytdl.getBasicInfo()`"); 226 | } 227 | setImmediate(() => { 228 | downloadFromInfoCallback(stream, info, options); 229 | }); 230 | return stream; 231 | }; 232 | -------------------------------------------------------------------------------- /lib/info-extras.js: -------------------------------------------------------------------------------- 1 | const utils = require("./utils"); 2 | const qs = require("querystring"); 3 | const { parseTimestamp } = require("m3u8stream"); 4 | 5 | const BASE_URL = "https://www.youtube.com/watch?v="; 6 | const TITLE_TO_CATEGORY = { 7 | song: { name: "Music", url: "https://music.youtube.com/" }, 8 | }; 9 | 10 | const getText = obj => obj?.runs?.[0]?.text ?? obj?.simpleText; 11 | 12 | /** 13 | * Get video media. 14 | * 15 | * @param {Object} info 16 | * @returns {Object} 17 | */ 18 | exports.getMedia = info => { 19 | let media = {}; 20 | let results = []; 21 | try { 22 | results = info.response.contents.twoColumnWatchNextResults.results.results.contents; 23 | } catch (err) { 24 | // Do nothing 25 | } 26 | 27 | let result = results.find(v => v.videoSecondaryInfoRenderer); 28 | if (!result) { 29 | return {}; 30 | } 31 | 32 | try { 33 | let metadataRows = (result.metadataRowContainer || result.videoSecondaryInfoRenderer.metadataRowContainer) 34 | .metadataRowContainerRenderer.rows; 35 | for (let row of metadataRows) { 36 | if (row.metadataRowRenderer) { 37 | let title = getText(row.metadataRowRenderer.title).toLowerCase(); 38 | let contents = row.metadataRowRenderer.contents[0]; 39 | media[title] = getText(contents); 40 | let runs = contents.runs; 41 | if (runs?.[0]?.navigationEndpoint) { 42 | media[`${title}_url`] = new URL( 43 | runs[0].navigationEndpoint.commandMetadata.webCommandMetadata.url, 44 | BASE_URL, 45 | ).toString(); 46 | } 47 | if (title in TITLE_TO_CATEGORY) { 48 | media.category = TITLE_TO_CATEGORY[title].name; 49 | media.category_url = TITLE_TO_CATEGORY[title].url; 50 | } 51 | } else if (row.richMetadataRowRenderer) { 52 | let contents = row.richMetadataRowRenderer.contents; 53 | let boxArt = contents.filter( 54 | meta => meta.richMetadataRenderer.style === "RICH_METADATA_RENDERER_STYLE_BOX_ART", 55 | ); 56 | for (let { richMetadataRenderer } of boxArt) { 57 | let meta = richMetadataRenderer; 58 | media.year = getText(meta.subtitle); 59 | let type = getText(meta.callToAction).split(" ")[1]; 60 | media[type] = getText(meta.title); 61 | media[`${type}_url`] = new URL(meta.endpoint.commandMetadata.webCommandMetadata.url, BASE_URL).toString(); 62 | media.thumbnails = meta.thumbnail.thumbnails; 63 | } 64 | let topic = contents.filter(meta => meta.richMetadataRenderer.style === "RICH_METADATA_RENDERER_STYLE_TOPIC"); 65 | for (let { richMetadataRenderer } of topic) { 66 | let meta = richMetadataRenderer; 67 | media.category = getText(meta.title); 68 | media.category_url = new URL(meta.endpoint.commandMetadata.webCommandMetadata.url, BASE_URL).toString(); 69 | } 70 | } 71 | } 72 | } catch (err) { 73 | // Do nothing. 74 | } 75 | 76 | return media; 77 | }; 78 | 79 | const isVerified = badges => !!badges?.find(b => b.metadataBadgeRenderer.tooltip === "Verified"); 80 | 81 | /** 82 | * Get video author. 83 | * 84 | * @param {Object} info 85 | * @returns {Object} 86 | */ 87 | exports.getAuthor = info => { 88 | let channelId, 89 | thumbnails = [], 90 | subscriberCount, 91 | verified = false; 92 | try { 93 | let results = info.response.contents.twoColumnWatchNextResults.results.results.contents; 94 | let v = results.find(v2 => v2?.videoSecondaryInfoRenderer?.owner?.videoOwnerRenderer); 95 | let videoOwnerRenderer = v.videoSecondaryInfoRenderer.owner.videoOwnerRenderer; 96 | channelId = videoOwnerRenderer.navigationEndpoint.browseEndpoint.browseId; 97 | thumbnails = videoOwnerRenderer.thumbnail.thumbnails.map(thumbnail => { 98 | thumbnail.url = new URL(thumbnail.url, BASE_URL).toString(); 99 | return thumbnail; 100 | }); 101 | subscriberCount = utils.parseAbbreviatedNumber(getText(videoOwnerRenderer.subscriberCountText)); 102 | verified = isVerified(videoOwnerRenderer.badges); 103 | } catch (err) { 104 | // Do nothing. 105 | } 106 | try { 107 | let videoDetails = info.player_response.microformat?.playerMicroformatRenderer; 108 | let id = videoDetails?.channelId || channelId || info.player_response.videoDetails.channelId; 109 | let author = { 110 | id: id, 111 | name: videoDetails?.ownerChannelName ?? info.player_response.videoDetails.author, 112 | user: videoDetails?.ownerProfileUrl.split("/").slice(-1)[0] ?? null, 113 | channel_url: `https://www.youtube.com/channel/${id}`, 114 | external_channel_url: videoDetails ? `https://www.youtube.com/channel/${videoDetails.externalChannelId}` : "", 115 | user_url: videoDetails ? new URL(videoDetails.ownerProfileUrl, BASE_URL).toString() : "", 116 | thumbnails, 117 | verified, 118 | subscriber_count: subscriberCount, 119 | }; 120 | if (thumbnails.length) { 121 | utils.deprecate(author, "avatar", author.thumbnails[0].url, "author.avatar", "author.thumbnails[0].url"); 122 | } 123 | return author; 124 | } catch (err) { 125 | return {}; 126 | } 127 | }; 128 | 129 | const parseRelatedVideo = (details, rvsParams) => { 130 | if (!details) return; 131 | try { 132 | let viewCount = getText(details.viewCountText); 133 | let shortViewCount = getText(details.shortViewCountText); 134 | let rvsDetails = rvsParams.find(elem => elem.id === details.videoId); 135 | if (!/^\d/.test(shortViewCount)) { 136 | shortViewCount = rvsDetails?.short_view_count_text || ""; 137 | } 138 | viewCount = (/^\d/.test(viewCount) ? viewCount : shortViewCount).split(" ")[0]; 139 | let browseEndpoint = details.shortBylineText.runs[0].navigationEndpoint.browseEndpoint; 140 | let channelId = browseEndpoint.browseId; 141 | let name = getText(details.shortBylineText); 142 | let user = (browseEndpoint.canonicalBaseUrl || "").split("/").slice(-1)[0]; 143 | let video = { 144 | id: details.videoId, 145 | title: getText(details.title), 146 | published: getText(details.publishedTimeText), 147 | author: { 148 | id: channelId, 149 | name, 150 | user, 151 | channel_url: `https://www.youtube.com/channel/${channelId}`, 152 | user_url: `https://www.youtube.com/user/${user}`, 153 | thumbnails: details.channelThumbnail.thumbnails.map(thumbnail => { 154 | thumbnail.url = new URL(thumbnail.url, BASE_URL).toString(); 155 | return thumbnail; 156 | }), 157 | verified: isVerified(details.ownerBadges), 158 | 159 | [Symbol.toPrimitive]() { 160 | console.warn( 161 | `\`relatedVideo.author\` will be removed in a near future release, ` + 162 | `use \`relatedVideo.author.name\` instead.`, 163 | ); 164 | return video.author.name; 165 | }, 166 | }, 167 | short_view_count_text: shortViewCount.split(" ")[0], 168 | view_count: viewCount.replace(/,/g, ""), 169 | length_seconds: details.lengthText 170 | ? Math.floor(parseTimestamp(getText(details.lengthText)) / 1000) 171 | : rvsParams 172 | ? `${rvsParams.length_seconds}` 173 | : undefined, 174 | thumbnails: details.thumbnail.thumbnails, 175 | richThumbnails: details.richThumbnail 176 | ? details.richThumbnail.movingThumbnailRenderer.movingThumbnailDetails.thumbnails 177 | : [], 178 | isLive: !!details.badges?.find(b => b.metadataBadgeRenderer.label === "LIVE NOW"), 179 | }; 180 | 181 | utils.deprecate( 182 | video, 183 | "author_thumbnail", 184 | video.author.thumbnails[0].url, 185 | "relatedVideo.author_thumbnail", 186 | "relatedVideo.author.thumbnails[0].url", 187 | ); 188 | utils.deprecate(video, "ucid", video.author.id, "relatedVideo.ucid", "relatedVideo.author.id"); 189 | utils.deprecate( 190 | video, 191 | "video_thumbnail", 192 | video.thumbnails[0].url, 193 | "relatedVideo.video_thumbnail", 194 | "relatedVideo.thumbnails[0].url", 195 | ); 196 | return video; 197 | } catch (err) { 198 | // Skip. 199 | } 200 | }; 201 | 202 | /** 203 | * Get related videos. 204 | * 205 | * @param {Object} info 206 | * @returns {Array.} 207 | */ 208 | exports.getRelatedVideos = info => { 209 | let rvsParams = [], 210 | secondaryResults = []; 211 | try { 212 | rvsParams = info.response.webWatchNextResponseExtensionData.relatedVideoArgs.split(",").map(e => qs.parse(e)); 213 | } catch (err) { 214 | // Do nothing. 215 | } 216 | try { 217 | secondaryResults = info.response.contents.twoColumnWatchNextResults.secondaryResults.secondaryResults.results; 218 | } catch (err) { 219 | return []; 220 | } 221 | let videos = []; 222 | for (let result of secondaryResults || []) { 223 | let details = result.compactVideoRenderer; 224 | if (details) { 225 | let video = parseRelatedVideo(details, rvsParams); 226 | if (video) videos.push(video); 227 | } else { 228 | let autoplay = result.compactAutoplayRenderer || result.itemSectionRenderer; 229 | if (!autoplay || !Array.isArray(autoplay.contents)) continue; 230 | for (let content of autoplay.contents) { 231 | let video = parseRelatedVideo(content.compactVideoRenderer, rvsParams); 232 | if (video) videos.push(video); 233 | } 234 | } 235 | } 236 | return videos; 237 | }; 238 | 239 | /** 240 | * Get like count. 241 | * 242 | * @param {Object} info 243 | * @returns {number} 244 | */ 245 | exports.getLikes = info => { 246 | try { 247 | let contents = info.response.contents.twoColumnWatchNextResults.results.results.contents; 248 | let video = contents.find(r => r.videoPrimaryInfoRenderer); 249 | let buttons = video.videoPrimaryInfoRenderer.videoActions.menuRenderer.topLevelButtons; 250 | let accessibilityText = buttons.find(b => b.segmentedLikeDislikeButtonViewModel).segmentedLikeDislikeButtonViewModel 251 | .likeButtonViewModel.likeButtonViewModel.toggleButtonViewModel.toggleButtonViewModel.defaultButtonViewModel 252 | .buttonViewModel.accessibilityText; 253 | return parseInt(accessibilityText.match(/[\d,.]+/)[0].replace(/\D+/g, "")); 254 | } catch (err) { 255 | return null; 256 | } 257 | }; 258 | 259 | /** 260 | * Cleans up a few fields on `videoDetails`. 261 | * 262 | * @param {Object} videoDetails 263 | * @param {Object} info 264 | * @returns {Object} 265 | */ 266 | exports.cleanVideoDetails = (videoDetails, info) => { 267 | videoDetails.thumbnails = videoDetails.thumbnail.thumbnails; 268 | delete videoDetails.thumbnail; 269 | utils.deprecate( 270 | videoDetails, 271 | "thumbnail", 272 | { thumbnails: videoDetails.thumbnails }, 273 | "videoDetails.thumbnail.thumbnails", 274 | "videoDetails.thumbnails", 275 | ); 276 | videoDetails.description = videoDetails.shortDescription || getText(videoDetails.description); 277 | delete videoDetails.shortDescription; 278 | utils.deprecate( 279 | videoDetails, 280 | "shortDescription", 281 | videoDetails.description, 282 | "videoDetails.shortDescription", 283 | "videoDetails.description", 284 | ); 285 | 286 | // Use more reliable `lengthSeconds` from `playerMicroformatRenderer`. 287 | videoDetails.lengthSeconds = 288 | info.player_response.microformat?.playerMicroformatRenderer?.lengthSeconds || 289 | info.player_response.videoDetails.lengthSeconds; 290 | return videoDetails; 291 | }; 292 | 293 | /** 294 | * Get storyboards info. 295 | * 296 | * @param {Object} info 297 | * @returns {Array.} 298 | */ 299 | exports.getStoryboards = info => { 300 | const parts = info.player_response?.storyboards?.playerStoryboardSpecRenderer?.spec?.split("|"); 301 | 302 | if (!parts) return []; 303 | 304 | const url = new URL(parts.shift()); 305 | 306 | return parts.map((part, i) => { 307 | let [thumbnailWidth, thumbnailHeight, thumbnailCount, columns, rows, interval, nameReplacement, sigh] = 308 | part.split("#"); 309 | 310 | url.searchParams.set("sigh", sigh); 311 | 312 | thumbnailCount = parseInt(thumbnailCount, 10); 313 | columns = parseInt(columns, 10); 314 | rows = parseInt(rows, 10); 315 | 316 | const storyboardCount = Math.ceil(thumbnailCount / (columns * rows)); 317 | 318 | return { 319 | templateUrl: url.toString().replace("$L", i).replace("$N", nameReplacement), 320 | thumbnailWidth: parseInt(thumbnailWidth, 10), 321 | thumbnailHeight: parseInt(thumbnailHeight, 10), 322 | thumbnailCount, 323 | interval: parseInt(interval, 10), 324 | columns, 325 | rows, 326 | storyboardCount, 327 | }; 328 | }); 329 | }; 330 | 331 | /** 332 | * Get chapters info. 333 | * 334 | * @param {Object} info 335 | * @returns {Array.} 336 | */ 337 | exports.getChapters = info => { 338 | const playerOverlayRenderer = info.response?.playerOverlays?.playerOverlayRenderer; 339 | const playerBar = playerOverlayRenderer?.decoratedPlayerBarRenderer?.decoratedPlayerBarRenderer?.playerBar; 340 | const markersMap = playerBar?.multiMarkersPlayerBarRenderer?.markersMap; 341 | const marker = Array.isArray(markersMap) && markersMap.find(m => Array.isArray(m.value?.chapters)); 342 | if (!marker) return []; 343 | const chapters = marker.value.chapters; 344 | 345 | return chapters.map(chapter => ({ 346 | title: getText(chapter.chapterRenderer.title), 347 | start_time: chapter.chapterRenderer.timeRangeStartMillis / 1000, 348 | })); 349 | }; 350 | -------------------------------------------------------------------------------- /lib/info.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | const sax = require("sax"); 3 | 4 | const utils = require("./utils"); 5 | // Forces Node JS version of setTimeout for Electron based applications 6 | const { setTimeout } = require("timers"); 7 | const formatUtils = require("./format-utils"); 8 | const urlUtils = require("./url-utils"); 9 | const extras = require("./info-extras"); 10 | const Cache = require("./cache"); 11 | const sig = require("./sig"); 12 | 13 | const BASE_URL = "https://www.youtube.com/watch?v="; 14 | 15 | // Cached for storing basic/full info. 16 | exports.cache = new Cache(); 17 | exports.watchPageCache = new Cache(); 18 | 19 | // List of URLs that show up in `notice_url` for age restricted videos. 20 | const AGE_RESTRICTED_URLS = ["support.google.com/youtube/?p=age_restrictions", "youtube.com/t/community_guidelines"]; 21 | 22 | /** 23 | * Gets info from a video without getting additional formats. 24 | * 25 | * @param {string} id 26 | * @param {Object} options 27 | * @returns {Promise} 28 | */ 29 | exports.getBasicInfo = async (id, options) => { 30 | utils.applyIPv6Rotations(options); 31 | utils.applyDefaultHeaders(options); 32 | utils.applyDefaultAgent(options); 33 | utils.applyOldLocalAddress(options); 34 | const retryOptions = Object.assign({}, options.requestOptions); 35 | const { jar, dispatcher } = options.agent; 36 | utils.setPropInsensitive( 37 | options.requestOptions.headers, 38 | "cookie", 39 | jar.getCookieStringSync("https://www.youtube.com"), 40 | ); 41 | options.requestOptions.dispatcher = dispatcher; 42 | const info = await retryFunc(getWatchHTMLPage, [id, options], retryOptions); 43 | 44 | const playErr = utils.playError(info.player_response); 45 | if (playErr) throw playErr; 46 | 47 | Object.assign(info, { 48 | // Replace with formats from iosPlayerResponse 49 | // formats: parseFormats(info.player_response), 50 | related_videos: extras.getRelatedVideos(info), 51 | }); 52 | 53 | // Add additional properties to info. 54 | const media = extras.getMedia(info); 55 | const additional = { 56 | author: extras.getAuthor(info), 57 | media, 58 | likes: extras.getLikes(info), 59 | age_restricted: !!( 60 | media && AGE_RESTRICTED_URLS.some(url => Object.values(media).some(v => typeof v === "string" && v.includes(url))) 61 | ), 62 | 63 | // Give the standard link to the video. 64 | video_url: BASE_URL + id, 65 | storyboards: extras.getStoryboards(info), 66 | chapters: extras.getChapters(info), 67 | }; 68 | 69 | info.videoDetails = extras.cleanVideoDetails( 70 | Object.assign( 71 | {}, 72 | info.player_response?.microformat?.playerMicroformatRenderer, 73 | info.player_response?.videoDetails, 74 | additional, 75 | ), 76 | info, 77 | ); 78 | 79 | return info; 80 | }; 81 | 82 | const getWatchHTMLURL = (id, options) => 83 | `${BASE_URL + id}&hl=${options.lang || "en"}&bpctr=${Math.ceil(Date.now() / 1000)}&has_verified=1`; 84 | const getWatchHTMLPageBody = (id, options) => { 85 | const url = getWatchHTMLURL(id, options); 86 | return exports.watchPageCache.getOrSet(url, () => utils.request(url, options)); 87 | }; 88 | 89 | const EMBED_URL = "https://www.youtube.com/embed/"; 90 | const getEmbedPageBody = (id, options) => { 91 | const embedUrl = `${EMBED_URL + id}?hl=${options.lang || "en"}`; 92 | return utils.request(embedUrl, options); 93 | }; 94 | 95 | const getHTML5player = body => { 96 | const html5playerRes = 97 | /|"jsUrl":"([^"]+)"/.exec(body); 98 | return html5playerRes?.[1] || html5playerRes?.[2]; 99 | }; 100 | 101 | /** 102 | * Given a function, calls it with `args` until it's successful, 103 | * or until it encounters an unrecoverable error. 104 | * Currently, any error from miniget is considered unrecoverable. Errors such as 105 | * too many redirects, invalid URL, status code 404, status code 502. 106 | * 107 | * @param {Function} func 108 | * @param {Array.} args 109 | * @param {Object} options 110 | * @param {number} options.maxRetries 111 | * @param {Object} options.backoff 112 | * @param {number} options.backoff.inc 113 | */ 114 | const retryFunc = async (func, args, options) => { 115 | let currentTry = 0, 116 | result; 117 | if (!options.maxRetries) options.maxRetries = 3; 118 | if (!options.backoff) options.backoff = { inc: 500, max: 5000 }; 119 | while (currentTry <= options.maxRetries) { 120 | try { 121 | result = await func(...args); 122 | break; 123 | } catch (err) { 124 | if (err?.statusCode < 500 || currentTry >= options.maxRetries) throw err; 125 | const wait = Math.min(++currentTry * options.backoff.inc, options.backoff.max); 126 | await new Promise(resolve => setTimeout(resolve, wait)); 127 | } 128 | } 129 | return result; 130 | }; 131 | 132 | const jsonClosingChars = /^[)\]}'\s]+/; 133 | const parseJSON = (source, varName, json) => { 134 | if (!json || typeof json === "object") { 135 | return json; 136 | } else { 137 | try { 138 | json = json.replace(jsonClosingChars, ""); 139 | return JSON.parse(json); 140 | } catch (err) { 141 | throw Error(`Error parsing ${varName} in ${source}: ${err.message}`); 142 | } 143 | } 144 | }; 145 | 146 | const findJSON = (source, varName, body, left, right, prependJSON) => { 147 | const jsonStr = utils.between(body, left, right); 148 | if (!jsonStr) { 149 | throw Error(`Could not find ${varName} in ${source}`); 150 | } 151 | return parseJSON(source, varName, utils.cutAfterJS(`${prependJSON}${jsonStr}`)); 152 | }; 153 | 154 | const findPlayerResponse = (source, info) => { 155 | if (!info) return {}; 156 | const player_response = 157 | info.args?.player_response || info.player_response || info.playerResponse || info.embedded_player_response; 158 | return parseJSON(source, "player_response", player_response); 159 | }; 160 | 161 | const getWatchHTMLPage = async (id, options) => { 162 | const body = await getWatchHTMLPageBody(id, options); 163 | const info = { page: "watch" }; 164 | try { 165 | try { 166 | info.player_response = 167 | utils.tryParseBetween(body, "var ytInitialPlayerResponse = ", "}};", "", "}}") || 168 | utils.tryParseBetween(body, "var ytInitialPlayerResponse = ", ";var") || 169 | utils.tryParseBetween(body, "var ytInitialPlayerResponse = ", ";") || 170 | findJSON("watch.html", "player_response", body, /\bytInitialPlayerResponse\s*=\s*\{/i, "", "{"); 171 | } catch (_e) { 172 | let args = findJSON("watch.html", "player_response", body, /\bytplayer\.config\s*=\s*{/, "", "{"); 173 | info.player_response = findPlayerResponse("watch.html", args); 174 | } 175 | 176 | info.response = 177 | utils.tryParseBetween(body, "var ytInitialData = ", "}};", "", "}}") || 178 | utils.tryParseBetween(body, "var ytInitialData = ", ";") || 179 | utils.tryParseBetween(body, 'window["ytInitialData"] = ', "}};", "", "}}") || 180 | utils.tryParseBetween(body, 'window["ytInitialData"] = ', ";") || 181 | findJSON("watch.html", "response", body, /\bytInitialData("\])?\s*=\s*\{/i, "", "{"); 182 | info.html5player = getHTML5player(body); 183 | } catch (_) { 184 | throw Error( 185 | "Error when parsing watch.html, maybe YouTube made a change.\n" + 186 | `Please report this issue with the "${utils.saveDebugFile( 187 | "watch.html", 188 | body, 189 | )}" file on https://github.com/distubejs/ytdl-core/issues.`, 190 | ); 191 | } 192 | return info; 193 | }; 194 | 195 | /** 196 | * @param {Object} player_response 197 | * @returns {Array.} 198 | */ 199 | const parseFormats = player_response => { 200 | return (player_response?.streamingData?.formats || [])?.concat(player_response?.streamingData?.adaptiveFormats || []); 201 | }; 202 | 203 | const parseAdditionalManifests = (player_response, options) => { 204 | const streamingData = player_response?.streamingData, 205 | manifests = []; 206 | if (streamingData) { 207 | if (streamingData.dashManifestUrl) { 208 | manifests.push(getDashManifest(streamingData.dashManifestUrl, options)); 209 | } 210 | if (streamingData.hlsManifestUrl) { 211 | manifests.push(getM3U8(streamingData.hlsManifestUrl, options)); 212 | } 213 | } 214 | return manifests; 215 | }; 216 | 217 | // TODO: Clean up this function for readability and support more clients 218 | /** 219 | * Gets info from a video additional formats and deciphered URLs. 220 | * 221 | * @param {string} id 222 | * @param {Object} options 223 | * @returns {Promise} 224 | */ 225 | exports.getInfo = async (id, options) => { 226 | // Initialize request options 227 | utils.applyIPv6Rotations(options); 228 | utils.applyDefaultHeaders(options); 229 | utils.applyDefaultAgent(options); 230 | utils.applyOldLocalAddress(options); 231 | utils.applyPlayerClients(options); 232 | 233 | const info = await exports.getBasicInfo(id, options); 234 | 235 | info.html5player = 236 | info.html5player || 237 | getHTML5player(await getWatchHTMLPageBody(id, options)) || 238 | getHTML5player(await getEmbedPageBody(id, options)); 239 | 240 | if (!info.html5player) { 241 | throw Error("Unable to find html5player file"); 242 | } 243 | 244 | info.html5player = new URL(info.html5player, BASE_URL).toString(); 245 | 246 | const formatPromises = []; 247 | 248 | try { 249 | const clientPromises = []; 250 | 251 | if (options.playerClients.includes("WEB_EMBEDDED")) clientPromises.push(fetchWebEmbeddedPlayer(id, info, options)); 252 | if (options.playerClients.includes("TV")) clientPromises.push(fetchTvPlayer(id, info, options)); 253 | if (options.playerClients.includes("IOS")) clientPromises.push(fetchIosJsonPlayer(id, options)); 254 | if (options.playerClients.includes("ANDROID")) clientPromises.push(fetchAndroidJsonPlayer(id, options)); 255 | 256 | if (clientPromises.length > 0) { 257 | const responses = await Promise.allSettled(clientPromises); 258 | const successfulResponses = responses 259 | .filter(r => r.status === "fulfilled") 260 | .map(r => r.value) 261 | .filter(r => r); 262 | 263 | for (const response of successfulResponses) { 264 | const formats = parseFormats(response); 265 | if (formats && formats.length > 0) { 266 | formatPromises.push(sig.decipherFormats(formats, info.html5player, options)); 267 | } 268 | 269 | const manifestPromises = parseAdditionalManifests(response, options); 270 | formatPromises.push(...manifestPromises); 271 | } 272 | } 273 | 274 | if (options.playerClients.includes("WEB")) { 275 | bestPlayerResponse = info.player_response; 276 | 277 | const formats = parseFormats(info.player_response); 278 | if (formats && formats.length > 0) { 279 | formatPromises.push(sig.decipherFormats(formats, info.html5player, options)); 280 | } 281 | 282 | const manifestPromises = parseAdditionalManifests(info.player_response, options); 283 | formatPromises.push(...manifestPromises); 284 | } 285 | } catch (error) { 286 | console.error("Error fetching formats:", error); 287 | 288 | const formats = parseFormats(info.player_response); 289 | if (formats && formats.length > 0) { 290 | formatPromises.push(sig.decipherFormats(formats, info.html5player, options)); 291 | } 292 | 293 | const manifestPromises = parseAdditionalManifests(info.player_response, options); 294 | formatPromises.push(...manifestPromises); 295 | } 296 | 297 | if (formatPromises.length === 0) { 298 | throw new Error("Failed to find any playable formats"); 299 | } 300 | 301 | const results = await Promise.all(formatPromises); 302 | info.formats = Object.values(Object.assign({}, ...results)); 303 | 304 | info.formats = info.formats.filter(format => format && format.url && format.mimeType); 305 | 306 | if (info.formats.length === 0) { 307 | throw new Error("No playable formats found"); 308 | } 309 | 310 | info.formats = info.formats.map(format => { 311 | const enhancedFormat = formatUtils.addFormatMeta(format); 312 | 313 | if (!enhancedFormat.audioBitrate && enhancedFormat.hasAudio) { 314 | enhancedFormat.audioBitrate = estimateAudioBitrate(enhancedFormat); 315 | } 316 | 317 | if ( 318 | !enhancedFormat.isHLS && 319 | enhancedFormat.mimeType && 320 | (enhancedFormat.mimeType.includes("hls") || 321 | enhancedFormat.mimeType.includes("x-mpegURL") || 322 | enhancedFormat.mimeType.includes("application/vnd.apple.mpegurl")) 323 | ) { 324 | enhancedFormat.isHLS = true; 325 | } 326 | 327 | return enhancedFormat; 328 | }); 329 | 330 | info.formats.sort(formatUtils.sortFormats); 331 | 332 | const bestFormat = 333 | info.formats.find(format => format.hasVideo && format.hasAudio) || 334 | info.formats.find(format => format.hasVideo) || 335 | info.formats.find(format => format.hasAudio) || 336 | info.formats[0]; 337 | 338 | info.bestFormat = bestFormat; 339 | info.videoUrl = bestFormat.url; 340 | info.selectedFormat = bestFormat; 341 | info.full = true; 342 | 343 | return info; 344 | }; 345 | 346 | const getPlaybackContext = async (html5player, options) => { 347 | const body = await utils.request(html5player, options); 348 | const mo = body.match(/(signatureTimestamp|sts):(\d+)/); 349 | return { 350 | contentPlaybackContext: { 351 | html5Preference: "HTML5_PREF_WANTS", 352 | signatureTimestamp: mo?.[2], 353 | }, 354 | }; 355 | }; 356 | 357 | const getVisitorData = (info, _options) => { 358 | for (const respKey of ["player_response", "response"]) { 359 | try { 360 | return info[respKey].responseContext.serviceTrackingParams 361 | .find(x => x.service === "GFEEDBACK").params 362 | .find(x => x.key === "visitor_data").value; 363 | } 364 | catch { /* not present */ } 365 | } 366 | return undefined; 367 | }; 368 | 369 | const LOCALE = { hl: "en", timeZone: "UTC", utcOffsetMinutes: 0 }, 370 | CHECK_FLAGS = { contentCheckOk: true, racyCheckOk: true }; 371 | 372 | const WEB_EMBEDDED_CONTEXT = { 373 | client: { 374 | clientName: "WEB_EMBEDDED_PLAYER", 375 | clientVersion: "1.20240723.01.00", 376 | ...LOCALE, 377 | }, 378 | }; 379 | 380 | const TVHTML5_CONTEXT = { 381 | client: { 382 | clientName: "TVHTML5", 383 | clientVersion: "7.20240724.13.00", 384 | ...LOCALE, 385 | }, 386 | }; 387 | 388 | const fetchWebEmbeddedPlayer = async (videoId, info, options) => { 389 | const payload = { 390 | context: WEB_EMBEDDED_CONTEXT, 391 | videoId, 392 | playbackContext: await getPlaybackContext(info.html5player, options), 393 | ...CHECK_FLAGS, 394 | }; 395 | return await playerAPI(videoId, payload, options); 396 | }; 397 | const fetchTvPlayer = async (videoId, info, options) => { 398 | const payload = { 399 | context: TVHTML5_CONTEXT, 400 | videoId, 401 | playbackContext: await getPlaybackContext(info.html5player, options), 402 | ...CHECK_FLAGS, 403 | }; 404 | 405 | options.visitorId = getVisitorData(info, options); 406 | 407 | return await playerAPI(videoId, payload, options); 408 | }; 409 | 410 | const playerAPI = async (videoId, payload, options) => { 411 | const { jar, dispatcher } = options.agent; 412 | const opts = { 413 | requestOptions: { 414 | method: "POST", 415 | dispatcher, 416 | query: { 417 | prettyPrint: false, 418 | t: utils.generateClientPlaybackNonce(12), 419 | id: videoId, 420 | }, 421 | headers: { 422 | "Content-Type": "application/json", 423 | Cookie: jar.getCookieStringSync("https://www.youtube.com"), 424 | "X-Goog-Api-Format-Version": "2", 425 | }, 426 | body: JSON.stringify(payload), 427 | }, 428 | }; 429 | if (options.visitorId) opts.requestOptions.headers["X-Goog-Visitor-Id"] = options.visitorId; 430 | const response = await utils.request("https://youtubei.googleapis.com/youtubei/v1/player", opts); 431 | const playErr = utils.playError(response); 432 | if (playErr) throw playErr; 433 | if (!response.videoDetails || videoId !== response.videoDetails.videoId) { 434 | const err = new Error("Malformed response from YouTube"); 435 | err.response = response; 436 | throw err; 437 | } 438 | return response; 439 | }; 440 | 441 | const IOS_CLIENT_VERSION = "19.45.4", 442 | IOS_DEVICE_MODEL = "iPhone16,2", 443 | IOS_USER_AGENT_VERSION = "17_5_1", 444 | IOS_OS_VERSION = "17.5.1.21F90"; 445 | 446 | const fetchIosJsonPlayer = async (videoId, options) => { 447 | const payload = { 448 | videoId, 449 | cpn: utils.generateClientPlaybackNonce(16), 450 | contentCheckOk: true, 451 | racyCheckOk: true, 452 | context: { 453 | client: { 454 | clientName: "IOS", 455 | clientVersion: IOS_CLIENT_VERSION, 456 | deviceMake: "Apple", 457 | deviceModel: IOS_DEVICE_MODEL, 458 | platform: "MOBILE", 459 | osName: "iOS", 460 | osVersion: IOS_OS_VERSION, 461 | hl: "en", 462 | gl: "US", 463 | utcOffsetMinutes: -240, 464 | }, 465 | request: { 466 | internalExperimentFlags: [], 467 | useSsl: true, 468 | }, 469 | user: { 470 | lockedSafetyMode: false, 471 | }, 472 | }, 473 | }; 474 | 475 | const { jar, dispatcher } = options.agent; 476 | const opts = { 477 | requestOptions: { 478 | method: "POST", 479 | dispatcher, 480 | query: { 481 | prettyPrint: false, 482 | t: utils.generateClientPlaybackNonce(12), 483 | id: videoId, 484 | }, 485 | headers: { 486 | "Content-Type": "application/json", 487 | cookie: jar.getCookieStringSync("https://www.youtube.com"), 488 | "User-Agent": `com.google.ios.youtube/${IOS_CLIENT_VERSION}(${ 489 | IOS_DEVICE_MODEL 490 | }; U; CPU iOS ${IOS_USER_AGENT_VERSION} like Mac OS X; en_US)`, 491 | "X-Goog-Api-Format-Version": "2", 492 | }, 493 | body: JSON.stringify(payload), 494 | }, 495 | }; 496 | const response = await utils.request("https://youtubei.googleapis.com/youtubei/v1/player", opts); 497 | const playErr = utils.playError(response); 498 | if (playErr) throw playErr; 499 | if (!response.videoDetails || videoId !== response.videoDetails.videoId) { 500 | const err = new Error("Malformed response from YouTube"); 501 | err.response = response; 502 | throw err; 503 | } 504 | return response; 505 | }; 506 | 507 | const ANDROID_CLIENT_VERSION = "19.44.38", 508 | ANDROID_OS_VERSION = "11", 509 | ANDROID_SDK_VERSION = "30"; 510 | 511 | const fetchAndroidJsonPlayer = async (videoId, options) => { 512 | const payload = { 513 | videoId, 514 | cpn: utils.generateClientPlaybackNonce(16), 515 | contentCheckOk: true, 516 | racyCheckOk: true, 517 | context: { 518 | client: { 519 | clientName: "ANDROID", 520 | clientVersion: ANDROID_CLIENT_VERSION, 521 | platform: "MOBILE", 522 | osName: "Android", 523 | osVersion: ANDROID_OS_VERSION, 524 | androidSdkVersion: ANDROID_SDK_VERSION, 525 | hl: "en", 526 | gl: "US", 527 | utcOffsetMinutes: -240, 528 | }, 529 | request: { 530 | internalExperimentFlags: [], 531 | useSsl: true, 532 | }, 533 | user: { 534 | lockedSafetyMode: false, 535 | }, 536 | }, 537 | }; 538 | 539 | const { jar, dispatcher } = options.agent; 540 | const opts = { 541 | requestOptions: { 542 | method: "POST", 543 | dispatcher, 544 | query: { 545 | prettyPrint: false, 546 | t: utils.generateClientPlaybackNonce(12), 547 | id: videoId, 548 | }, 549 | headers: { 550 | "Content-Type": "application/json", 551 | cookie: jar.getCookieStringSync("https://www.youtube.com"), 552 | "User-Agent": `com.google.android.youtube/${ 553 | ANDROID_CLIENT_VERSION 554 | } (Linux; U; Android ${ANDROID_OS_VERSION}) gzip`, 555 | "X-Goog-Api-Format-Version": "2", 556 | }, 557 | body: JSON.stringify(payload), 558 | }, 559 | }; 560 | const response = await utils.request("https://youtubei.googleapis.com/youtubei/v1/player", opts); 561 | const playErr = utils.playError(response); 562 | if (playErr) throw playErr; 563 | if (!response.videoDetails || videoId !== response.videoDetails.videoId) { 564 | const err = new Error("Malformed response from YouTube"); 565 | err.response = response; 566 | throw err; 567 | } 568 | return response; 569 | }; 570 | 571 | /** 572 | * Gets additional DASH formats. 573 | * 574 | * @param {string} url 575 | * @param {Object} options 576 | * @returns {Promise>} 577 | */ 578 | const getDashManifest = (url, options) => 579 | new Promise((resolve, reject) => { 580 | const formats = {}; 581 | const parser = sax.parser(false); 582 | parser.onerror = reject; 583 | let adaptationSet; 584 | parser.onopentag = node => { 585 | if (node.name === "ADAPTATIONSET") { 586 | adaptationSet = node.attributes; 587 | } else if (node.name === "REPRESENTATION") { 588 | const itag = parseInt(node.attributes.ID); 589 | if (!isNaN(itag)) { 590 | formats[url] = Object.assign( 591 | { 592 | itag, 593 | url, 594 | bitrate: parseInt(node.attributes.BANDWIDTH), 595 | mimeType: `${adaptationSet.MIMETYPE}; codecs="${node.attributes.CODECS}"`, 596 | }, 597 | node.attributes.HEIGHT 598 | ? { 599 | width: parseInt(node.attributes.WIDTH), 600 | height: parseInt(node.attributes.HEIGHT), 601 | fps: parseInt(node.attributes.FRAMERATE), 602 | } 603 | : { 604 | audioSampleRate: node.attributes.AUDIOSAMPLINGRATE, 605 | }, 606 | ); 607 | } 608 | } 609 | }; 610 | parser.onend = () => { 611 | resolve(formats); 612 | }; 613 | utils 614 | .request(new URL(url, BASE_URL).toString(), options) 615 | .then(res => { 616 | parser.write(res); 617 | parser.close(); 618 | }) 619 | .catch(reject); 620 | }); 621 | 622 | /** 623 | * Gets additional formats. 624 | * 625 | * @param {string} url 626 | * @param {Object} options 627 | * @returns {Promise>} 628 | */ 629 | const getM3U8 = async (url, options) => { 630 | url = new URL(url, BASE_URL); 631 | const body = await utils.request(url.toString(), options); 632 | const formats = {}; 633 | body 634 | .split("\n") 635 | .filter(line => /^https?:\/\//.test(line)) 636 | .forEach(line => { 637 | const itag = parseInt(line.match(/\/itag\/(\d+)\//)[1]); 638 | formats[line] = { itag, url: line }; 639 | }); 640 | return formats; 641 | }; 642 | 643 | // Cache get info functions. 644 | // In case a user wants to get a video's info before downloading. 645 | for (const funcName of ["getBasicInfo", "getInfo"]) { 646 | /** 647 | * @param {string} link 648 | * @param {Object} options 649 | * @returns {Promise} 650 | */ 651 | const func = exports[funcName]; 652 | exports[funcName] = async (link, options = {}) => { 653 | utils.checkForUpdates(); 654 | const id = await urlUtils.getVideoID(link); 655 | const key = [funcName, id, options.lang].join("-"); 656 | return exports.cache.getOrSet(key, () => func(id, options)); 657 | }; 658 | } 659 | 660 | // Export a few helpers. 661 | exports.validateID = urlUtils.validateID; 662 | exports.validateURL = urlUtils.validateURL; 663 | exports.getURLVideoID = urlUtils.getURLVideoID; 664 | exports.getVideoID = urlUtils.getVideoID; 665 | -------------------------------------------------------------------------------- /lib/sig.js: -------------------------------------------------------------------------------- 1 | const querystring = require("querystring"); 2 | const Cache = require("./cache"); 3 | const utils = require("./utils"); 4 | const vm = require("vm"); 5 | 6 | exports.cache = new Cache(1); 7 | 8 | exports.getFunctions = (html5playerfile, options) => 9 | exports.cache.getOrSet(html5playerfile, async () => { 10 | const body = await utils.request(html5playerfile, options); 11 | const functions = exports.extractFunctions(body); 12 | exports.cache.set(html5playerfile, functions); 13 | return functions; 14 | }); 15 | 16 | const VARIABLE_PART = "[a-zA-Z_\\$][a-zA-Z_0-9\\$]*"; 17 | const VARIABLE_PART_DEFINE = "\\\"?" + VARIABLE_PART + "\\\"?"; 18 | const BEFORE_ACCESS = "(?:\\[\\\"|\\.)"; 19 | const AFTER_ACCESS = "(?:\\\"\\]|)"; 20 | const VARIABLE_PART_ACCESS = BEFORE_ACCESS + VARIABLE_PART + AFTER_ACCESS; 21 | const REVERSE_PART = ":function\\(\\w\\)\\{(?:return )?\\w\\.reverse\\(\\)\\}"; 22 | const SLICE_PART = ":function\\(\\w,\\w\\)\\{return \\w\\.slice\\(\\w\\)\\}"; 23 | const SPLICE_PART = ":function\\(\\w,\\w\\)\\{\\w\\.splice\\(0,\\w\\)\\}"; 24 | const SWAP_PART = ":function\\(\\w,\\w\\)\\{" + 25 | "var \\w=\\w\\[0\\];\\w\\[0\\]=\\w\\[\\w%\\w\\.length\\];\\w\\[\\w(?:%\\w.length|)\\]=\\w(?:;return \\w)?\\}"; 26 | 27 | const DECIPHER_REGEXP = 28 | "function(?: " + VARIABLE_PART + ")?\\(([a-zA-Z])\\)\\{" + 29 | "\\1=\\1\\.split\\(\"\"\\);\\s*" + 30 | "((?:(?:\\1=)?" + VARIABLE_PART + VARIABLE_PART_ACCESS + "\\(\\1,\\d+\\);)+)" + 31 | "return \\1\\.join\\(\"\"\\)" + 32 | "\\}"; 33 | 34 | const HELPER_REGEXP = 35 | "var (" + VARIABLE_PART + ")=\\{((?:(?:" + 36 | VARIABLE_PART_DEFINE + REVERSE_PART + "|" + 37 | VARIABLE_PART_DEFINE + SLICE_PART + "|" + 38 | VARIABLE_PART_DEFINE + SPLICE_PART + "|" + 39 | VARIABLE_PART_DEFINE + SWAP_PART + 40 | "),?\\n?)+)\\};"; 41 | 42 | const FUNCTION_TCE_REGEXP = 43 | "function(?:\\s+[a-zA-Z_\\$][a-zA-Z0-9_\\$]*)?\\(\\w\\)\\{" + 44 | "\\w=\\w\\.split\\((?:\"\"|[a-zA-Z0-9_$]*\\[\\d+])\\);" + 45 | "\\s*((?:(?:\\w=)?[a-zA-Z_\\$][a-zA-Z0-9_\\$]*(?:\\[\\\"|\\.)[a-zA-Z_\\$][a-zA-Z0-9_\\$]*(?:\\\"\\]|)\\(\\w,\\d+\\);)+)" + 46 | "return \\w\\.join\\((?:\"\"|[a-zA-Z0-9_$]*\\[\\d+])\\)}"; 47 | 48 | const N_TRANSFORM_REGEXP = 49 | "function\\(\\s*(\\w+)\\s*\\)\\s*\\{" + 50 | "var\\s*(\\w+)=(?:\\1\\.split\\(.*?\\)|String\\.prototype\\.split\\.call\\(\\1,.*?\\))," + 51 | "\\s*(\\w+)=(\\[.*?]);\\s*\\3\\[\\d+]" + 52 | "(.*?try)(\\{.*?})catch\\(\\s*(\\w+)\\s*\\)\\s*\\{" + 53 | '\\s*return"[\\w-]+([A-z0-9-]+)"\\s*\\+\\s*\\1\\s*}' + 54 | '\\s*return\\s*(\\2\\.join\\(""\\)|Array\\.prototype\\.join\\.call\\(\\2,.*?\\))};'; 55 | 56 | const N_TRANSFORM_TCE_REGEXP = 57 | "function\\(\\s*(\\w+)\\s*\\)\\s*\\{" + 58 | "\\s*var\\s*(\\w+)=\\1\\.split\\(\\1\\.slice\\(0,0\\)\\),\\s*(\\w+)=\\[.*?];" + 59 | ".*?catch\\(\\s*(\\w+)\\s*\\)\\s*\\{" + 60 | "\\s*return(?:\"[^\"]+\"|\\s*[a-zA-Z_0-9$]*\\[\\d+])\\s*\\+\\s*\\1\\s*}" + 61 | "\\s*return\\s*\\2\\.join\\((?:\"\"|[a-zA-Z_0-9$]*\\[\\d+])\\)};"; 62 | 63 | const TCE_GLOBAL_VARS_REGEXP = 64 | "(?:^|[;,])\\s*(var\\s+([\\w$]+)\\s*=\\s*" + 65 | "(?:" + 66 | "([\"'])(?:\\\\.|[^\\\\])*?\\3" + 67 | "\\s*\\.\\s*split\\((" + 68 | "([\"'])(?:\\\\.|[^\\\\])*?\\5" + 69 | "\\))" + 70 | "|" + 71 | "\\[\\s*(?:([\"'])(?:\\\\.|[^\\\\])*?\\6\\s*,?\\s*)+\\]" + 72 | "))(?=\\s*[,;])"; 73 | 74 | const NEW_TCE_GLOBAL_VARS_REGEXP = 75 | "('use\\s*strict';)?" + 76 | "(?var\\s*" + 77 | "(?[a-zA-Z0-9_$]+)\\s*=\\s*" + 78 | "(?" + 79 | "(?:\"[^\"\\\\]*(?:\\\\.[^\"\\\\]*)*\"|'[^'\\\\]*(?:\\\\.[^'\\\\]*)*')" + 80 | "\\.split\\(" + 81 | "(?:\"[^\"\\\\]*(?:\\\\.[^\"\\\\]*)*\"|'[^'\\\\]*(?:\\\\.[^'\\\\]*)*')" + 82 | "\\)" + 83 | "|" + 84 | "\\[" + 85 | "(?:(?:\"[^\"\\\\]*(?:\\\\.[^\"\\\\]*)*\"|'[^'\\\\]*(?:\\\\.[^'\\\\]*)*')" + 86 | "\\s*,?\\s*)*" + 87 | "\\]" + 88 | "|" + 89 | "\"[^\"]*\"\\.split\\(\"[^\"]*\"\\)" + 90 | ")" + 91 | ")"; 92 | 93 | const TCE_SIGN_FUNCTION_REGEXP = "function\\(\\s*([a-zA-Z0-9$])\\s*\\)\\s*\\{" + 94 | "\\s*\\1\\s*=\\s*\\1\\[(\\w+)\\[\\d+\\]\\]\\(\\2\\[\\d+\\]\\);" + 95 | "([a-zA-Z0-9$]+)\\[\\2\\[\\d+\\]\\]\\(\\s*\\1\\s*,\\s*\\d+\\s*\\);" + 96 | "\\s*\\3\\[\\2\\[\\d+\\]\\]\\(\\s*\\1\\s*,\\s*\\d+\\s*\\);" + 97 | ".*?return\\s*\\1\\[\\2\\[\\d+\\]\\]\\(\\2\\[\\d+\\]\\)\\};"; 98 | 99 | const TCE_SIGN_FUNCTION_ACTION_REGEXP = "var\\s+([A-Za-z0-9_]+)\\s*=\\s*\\{\\s*(?:[A-Za-z0-9_]+)\\s*:\\s*function\\s*\\([^)]*\\)\\s*\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}\\s*,\\s*(?:[A-Za-z0-9_]+)\\s*:\\s*function\\s*\\([^)]*\\)\\s*\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}\\s*,\\s*(?:[A-Za-z0-9_]+)\\s*:\\s*function\\s*\\([^)]*\\)\\s*\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}\\s*\\};"; 100 | 101 | const TCE_N_FUNCTION_REGEXP = "function\\s*\\((\\w+)\\)\\s*\\{var\\s*\\w+\\s*=\\s*\\1\\[\\w+\\[\\d+\\]\\]\\(\\w+\\[\\d+\\]\\)\\s*,\\s*\\w+\\s*=\\s*\\[.*?\\]\\;.*?catch\\s*\\(\\s*(\\w+)\\s*\\)\\s*\\{return\\s*\\w+\\[\\d+\\]\\s*\\+\\s*\\1\\}\\s*return\\s*\\w+\\[\\w+\\[\\d+\\]\\]\\(\\w+\\[\\d+\\]\\)\\}\\s*\\;"; 102 | 103 | const PATTERN_PREFIX = "(?:^|,)\\\"?(" + VARIABLE_PART + ")\\\"?"; 104 | const REVERSE_PATTERN = new RegExp(PATTERN_PREFIX + REVERSE_PART, "m"); 105 | const SLICE_PATTERN = new RegExp(PATTERN_PREFIX + SLICE_PART, "m"); 106 | const SPLICE_PATTERN = new RegExp(PATTERN_PREFIX + SPLICE_PART, "m"); 107 | const SWAP_PATTERN = new RegExp(PATTERN_PREFIX + SWAP_PART, "m"); 108 | 109 | const DECIPHER_ARGUMENT = "sig"; 110 | const N_ARGUMENT = "ncode"; 111 | const DECIPHER_FUNC_NAME = "DisTubeDecipherFunc"; 112 | const N_TRANSFORM_FUNC_NAME = "DisTubeNTransformFunc"; 113 | 114 | const extractDollarEscapedFirstGroup = (pattern, text) => { 115 | const match = text.match(pattern); 116 | return match ? match[1].replace(/\$/g, "\\$") : null; 117 | }; 118 | 119 | const extractTceFunc = (body) => { 120 | try { 121 | const tceVariableMatcher = body.match(new RegExp(NEW_TCE_GLOBAL_VARS_REGEXP, 'm')); 122 | 123 | if (!tceVariableMatcher) return; 124 | 125 | const tceVariableMatcherGroups = tceVariableMatcher.groups; 126 | if (!tceVariableMatcher.groups) return; 127 | 128 | const code = tceVariableMatcherGroups.code; 129 | const varname = tceVariableMatcherGroups.varname; 130 | 131 | return { name: varname, code: code }; 132 | } catch (e) { 133 | console.error("Error in extractTceFunc:", e); 134 | return null; 135 | } 136 | } 137 | 138 | const extractDecipherFunc = (body, name, code) => { 139 | try { 140 | const callerFunc = DECIPHER_FUNC_NAME + "(" + DECIPHER_ARGUMENT + ");"; 141 | let resultFunc; 142 | 143 | const sigFunctionMatcher = body.match(new RegExp(TCE_SIGN_FUNCTION_REGEXP, 's')); 144 | const sigFunctionActionsMatcher = body.match(new RegExp(TCE_SIGN_FUNCTION_ACTION_REGEXP, 's')); 145 | 146 | if (sigFunctionMatcher && sigFunctionActionsMatcher && code) { 147 | resultFunc = "var " + DECIPHER_FUNC_NAME + "=" + sigFunctionMatcher[0] + sigFunctionActionsMatcher[0] + code + ";\n"; 148 | return resultFunc + callerFunc; 149 | } 150 | 151 | const helperMatch = body.match(new RegExp(HELPER_REGEXP, "s")); 152 | if (!helperMatch) return null; 153 | 154 | const helperObject = helperMatch[0]; 155 | const actionBody = helperMatch[2]; 156 | const helperName = helperMatch[1]; 157 | 158 | const reverseKey = extractDollarEscapedFirstGroup(REVERSE_PATTERN, actionBody); 159 | const sliceKey = extractDollarEscapedFirstGroup(SLICE_PATTERN, actionBody); 160 | const spliceKey = extractDollarEscapedFirstGroup(SPLICE_PATTERN, actionBody); 161 | const swapKey = extractDollarEscapedFirstGroup(SWAP_PATTERN, actionBody); 162 | 163 | const quotedFunctions = [reverseKey, sliceKey, spliceKey, swapKey] 164 | .filter(Boolean) 165 | .map(key => key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); 166 | 167 | if (quotedFunctions.length === 0) return null; 168 | 169 | let funcMatch = body.match(new RegExp(DECIPHER_REGEXP, "s")); 170 | let isTce = false; 171 | let decipherFunc; 172 | 173 | if (funcMatch) { 174 | decipherFunc = funcMatch[0]; 175 | } else { 176 | 177 | const tceFuncMatch = body.match(new RegExp(FUNCTION_TCE_REGEXP, "s")); 178 | if (!tceFuncMatch) return null; 179 | 180 | decipherFunc = tceFuncMatch[0]; 181 | isTce = true; 182 | } 183 | 184 | let tceVars = ""; 185 | if (isTce) { 186 | const tceVarsMatch = body.match(new RegExp(TCE_GLOBAL_VARS_REGEXP, "m")); 187 | if (tceVarsMatch) { 188 | tceVars = tceVarsMatch[1] + ";\n"; 189 | } 190 | } 191 | 192 | resultFunc = tceVars + helperObject + "\nvar " + DECIPHER_FUNC_NAME + "=" + decipherFunc + ";\n"; 193 | return resultFunc + callerFunc; 194 | } catch (e) { 195 | console.error("Error in extractDecipherFunc:", e); 196 | return null; 197 | } 198 | }; 199 | 200 | const extractNTransformFunc = (body, name, code) => { 201 | try { 202 | const callerFunc = N_TRANSFORM_FUNC_NAME + "(" + N_ARGUMENT + ");"; 203 | let resultFunc; 204 | let nFunction; 205 | 206 | const nFunctionMatcher = body.match(new RegExp(TCE_N_FUNCTION_REGEXP, 's')); 207 | 208 | if (nFunctionMatcher && name && code) { 209 | nFunction = nFunctionMatcher[0]; 210 | 211 | const tceEscapeName = name.replace("$", "\\$"); 212 | const shortCircuitPattern = new RegExp( 213 | `;\\s*if\\s*\\(\\s*typeof\\s+[a-zA-Z0-9_$]+\\s*===?\\s*(?:\"undefined\"|'undefined'|${tceEscapeName}\\[\\d+\\])\\s*\\)\\s*return\\s+\\w+;` 214 | ); 215 | 216 | const tceShortCircuitMatcher = nFunction.match(shortCircuitPattern); 217 | 218 | if (tceShortCircuitMatcher) { 219 | nFunction = nFunction.replaceAll(tceShortCircuitMatcher[0], ";"); 220 | } 221 | 222 | resultFunc = "var " + N_TRANSFORM_FUNC_NAME + "=" + nFunction + code + ";\n"; 223 | return resultFunc + callerFunc; 224 | } 225 | 226 | let nMatch = body.match(new RegExp(N_TRANSFORM_REGEXP, "s")); 227 | let isTce = false; 228 | 229 | if (nMatch) { 230 | nFunction = nMatch[0]; 231 | } else { 232 | 233 | const nTceMatch = body.match(new RegExp(N_TRANSFORM_TCE_REGEXP, "s")); 234 | if (!nTceMatch) return null; 235 | 236 | nFunction = nTceMatch[0]; 237 | isTce = true; 238 | } 239 | 240 | const paramMatch = nFunction.match(/function\s*\(\s*(\w+)\s*\)/); 241 | if (!paramMatch) return null; 242 | 243 | const paramName = paramMatch[1]; 244 | 245 | const cleanedFunction = nFunction.replace( 246 | new RegExp(`if\\s*\\(typeof\\s*[^\\s()]+\\s*===?.*?\\)return ${paramName}\\s*;?`, "g"), 247 | "" 248 | ); 249 | 250 | let tceVars = ""; 251 | if (isTce) { 252 | const tceVarsMatch = body.match(new RegExp(TCE_GLOBAL_VARS_REGEXP, "m")); 253 | if (tceVarsMatch) { 254 | tceVars = tceVarsMatch[1] + ";\n"; 255 | } 256 | } 257 | 258 | resultFunc = tceVars + "var " + N_TRANSFORM_FUNC_NAME + "=" + cleanedFunction + ";\n"; 259 | return resultFunc + callerFunc; 260 | } catch (e) { 261 | console.error("Error in extractNTransformFunc:", e); 262 | return null; 263 | } 264 | }; 265 | 266 | let decipherWarning = false; 267 | let nTransformWarning = false; 268 | 269 | const getExtractFunction = (extractFunctions, body, name, code, postProcess = null) => { 270 | for (const extractFunction of extractFunctions) { 271 | try { 272 | const func = extractFunction(body, name, code); 273 | if (!func) continue; 274 | return new vm.Script(postProcess ? postProcess(func) : func); 275 | } catch (err) { 276 | console.error("Failed to extract function:", err); 277 | continue; 278 | } 279 | } 280 | return null; 281 | }; 282 | 283 | const extractDecipher = (body, name, code) => { 284 | const decipherFunc = getExtractFunction([extractDecipherFunc], body, name, code); 285 | if (!decipherFunc && !decipherWarning) { 286 | console.warn( 287 | "\x1b[33mWARNING:\x1B[0m Could not parse decipher function.\n" + 288 | "Stream URLs will be missing.\n" + 289 | `Please report this issue by uploading the "${utils.saveDebugFile( 290 | "player-script.js", 291 | body, 292 | )}" file on https://github.com/distubejs/ytdl-core/issues/144.` 293 | ); 294 | decipherWarning = true; 295 | } 296 | return decipherFunc; 297 | }; 298 | 299 | const extractNTransform = (body, name, code) => { 300 | const nTransformFunc = getExtractFunction([extractNTransformFunc], body, name, code); 301 | 302 | if (!nTransformFunc && !nTransformWarning) { 303 | console.warn( 304 | "\x1b[33mWARNING:\x1B[0m Could not parse n transform function.\n" + 305 | `Please report this issue by uploading the "${utils.saveDebugFile( 306 | "player-script.js", 307 | body, 308 | )}" file on https://github.com/distubejs/ytdl-core/issues/144.` 309 | ); 310 | nTransformWarning = true; 311 | } 312 | 313 | return nTransformFunc; 314 | }; 315 | 316 | exports.extractFunctions = body => { 317 | const { name, code } = extractTceFunc(body); 318 | return [extractDecipher(body, name, code), extractNTransform(body, name, code)]; 319 | } 320 | 321 | exports.setDownloadURL = (format, decipherScript, nTransformScript) => { 322 | if (!format) return; 323 | 324 | const decipher = url => { 325 | const args = querystring.parse(url); 326 | if (!args.s || !decipherScript) return args.url; 327 | 328 | try { 329 | const components = new URL(decodeURIComponent(args.url)); 330 | const context = {}; 331 | context[DECIPHER_ARGUMENT] = decodeURIComponent(args.s); 332 | const decipheredSig = decipherScript.runInNewContext(context); 333 | 334 | components.searchParams.set(args.sp || "sig", decipheredSig); 335 | return components.toString(); 336 | } catch (err) { 337 | console.error("Error applying decipher:", err); 338 | return args.url; 339 | } 340 | }; 341 | 342 | const nTransform = url => { 343 | try { 344 | const components = new URL(decodeURIComponent(url)); 345 | const n = components.searchParams.get("n"); 346 | 347 | if (!n || !nTransformScript) return url; 348 | 349 | const context = {}; 350 | context[N_ARGUMENT] = n; 351 | const transformedN = nTransformScript.runInNewContext(context); 352 | 353 | if (transformedN) { 354 | 355 | if (n === transformedN) { 356 | console.warn("Transformed n parameter is the same as input, n function possibly short-circuited"); 357 | } else if (transformedN.startsWith("enhanced_except_") || transformedN.endsWith("_w8_" + n)) { 358 | console.warn("N function did not complete due to exception"); 359 | } 360 | 361 | components.searchParams.set("n", transformedN); 362 | } else { 363 | console.warn("Transformed n parameter is null, n function possibly faulty"); 364 | } 365 | 366 | return components.toString(); 367 | } catch (err) { 368 | console.error("Error applying n transform:", err); 369 | return url; 370 | } 371 | }; 372 | 373 | const cipher = !format.url; 374 | const url = format.url || format.signatureCipher || format.cipher; 375 | 376 | if (!url) return; 377 | 378 | try { 379 | format.url = nTransform(cipher ? decipher(url) : url); 380 | 381 | delete format.signatureCipher; 382 | delete format.cipher; 383 | } catch (err) { 384 | console.error("Error setting download URL:", err); 385 | } 386 | }; 387 | 388 | exports.decipherFormats = async (formats, html5player, options) => { 389 | try { 390 | const decipheredFormats = {}; 391 | const [decipherScript, nTransformScript] = await exports.getFunctions(html5player, options); 392 | 393 | formats.forEach(format => { 394 | exports.setDownloadURL(format, decipherScript, nTransformScript); 395 | if (format.url) { 396 | decipheredFormats[format.url] = format; 397 | } 398 | }); 399 | 400 | return decipheredFormats; 401 | } catch (err) { 402 | console.error("Error deciphering formats:", err); 403 | return {}; 404 | } 405 | }; -------------------------------------------------------------------------------- /lib/url-utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Get video ID. 3 | * 4 | * There are a few type of video URL formats. 5 | * - https://www.youtube.com/watch?v=VIDEO_ID 6 | * - https://m.youtube.com/watch?v=VIDEO_ID 7 | * - https://youtu.be/VIDEO_ID 8 | * - https://www.youtube.com/v/VIDEO_ID 9 | * - https://www.youtube.com/embed/VIDEO_ID 10 | * - https://music.youtube.com/watch?v=VIDEO_ID 11 | * - https://gaming.youtube.com/watch?v=VIDEO_ID 12 | * 13 | * @param {string} link 14 | * @return {string} 15 | * @throws {Error} If unable to find a id 16 | * @throws {TypeError} If videoid doesn't match specs 17 | */ 18 | const validQueryDomains = new Set([ 19 | "youtube.com", 20 | "www.youtube.com", 21 | "m.youtube.com", 22 | "music.youtube.com", 23 | "gaming.youtube.com", 24 | ]); 25 | const validPathDomains = /^https?:\/\/(youtu\.be\/|(www\.)?youtube\.com\/(embed|v|shorts|live)\/)/; 26 | exports.getURLVideoID = link => { 27 | const parsed = new URL(link.trim()); 28 | let id = parsed.searchParams.get("v"); 29 | if (validPathDomains.test(link.trim()) && !id) { 30 | const paths = parsed.pathname.split("/"); 31 | id = parsed.host === "youtu.be" ? paths[1] : paths[2]; 32 | } else if (parsed.hostname && !validQueryDomains.has(parsed.hostname)) { 33 | throw Error("Not a YouTube domain"); 34 | } 35 | if (!id) { 36 | throw Error(`No video id found: "${link}"`); 37 | } 38 | id = id.substring(0, 11); 39 | if (!exports.validateID(id)) { 40 | throw TypeError(`Video id (${id}) does not match expected ` + `format (${idRegex.toString()})`); 41 | } 42 | return id; 43 | }; 44 | 45 | /** 46 | * Gets video ID either from a url or by checking if the given string 47 | * matches the video ID format. 48 | * 49 | * @param {string} str 50 | * @returns {string} 51 | * @throws {Error} If unable to find a id 52 | * @throws {TypeError} If videoid doesn't match specs 53 | */ 54 | const urlRegex = /^https?:\/\//; 55 | exports.getVideoID = str => { 56 | if (exports.validateID(str)) { 57 | return str; 58 | } else if (urlRegex.test(str.trim())) { 59 | return exports.getURLVideoID(str); 60 | } else { 61 | throw Error(`No video id found: ${str}`); 62 | } 63 | }; 64 | 65 | /** 66 | * Returns true if given id satifies YouTube's id format. 67 | * 68 | * @param {string} id 69 | * @return {boolean} 70 | */ 71 | const idRegex = /^[a-zA-Z0-9-_]{11}$/; 72 | exports.validateID = id => idRegex.test(id.trim()); 73 | 74 | /** 75 | * Checks wether the input string includes a valid id. 76 | * 77 | * @param {string} string 78 | * @returns {boolean} 79 | */ 80 | exports.validateURL = string => { 81 | try { 82 | exports.getURLVideoID(string); 83 | return true; 84 | } catch (e) { 85 | return false; 86 | } 87 | }; 88 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | const { request } = require("undici"); 2 | const { writeFileSync } = require("fs"); 3 | const AGENT = require("./agent"); 4 | 5 | /** 6 | * Extract string inbetween another. 7 | * 8 | * @param {string} haystack 9 | * @param {string} left 10 | * @param {string} right 11 | * @returns {string} 12 | */ 13 | const between = (exports.between = (haystack, left, right) => { 14 | let pos; 15 | if (left instanceof RegExp) { 16 | const match = haystack.match(left); 17 | if (!match) { 18 | return ""; 19 | } 20 | pos = match.index + match[0].length; 21 | } else { 22 | pos = haystack.indexOf(left); 23 | if (pos === -1) { 24 | return ""; 25 | } 26 | pos += left.length; 27 | } 28 | haystack = haystack.slice(pos); 29 | pos = haystack.indexOf(right); 30 | if (pos === -1) { 31 | return ""; 32 | } 33 | haystack = haystack.slice(0, pos); 34 | return haystack; 35 | }); 36 | 37 | exports.tryParseBetween = (body, left, right, prepend = "", append = "") => { 38 | try { 39 | let data = between(body, left, right); 40 | if (!data) return null; 41 | return JSON.parse(`${prepend}${data}${append}`); 42 | } catch (e) { 43 | return null; 44 | } 45 | }; 46 | 47 | /** 48 | * Get a number from an abbreviated number string. 49 | * 50 | * @param {string} string 51 | * @returns {number} 52 | */ 53 | exports.parseAbbreviatedNumber = string => { 54 | const match = string 55 | .replace(",", ".") 56 | .replace(" ", "") 57 | .match(/([\d,.]+)([MK]?)/); 58 | if (match) { 59 | let [, num, multi] = match; 60 | num = parseFloat(num); 61 | return Math.round(multi === "M" ? num * 1000000 : multi === "K" ? num * 1000 : num); 62 | } 63 | return null; 64 | }; 65 | 66 | /** 67 | * Escape sequences for cutAfterJS 68 | * @param {string} start the character string the escape sequence 69 | * @param {string} end the character string to stop the escape seequence 70 | * @param {undefined|Regex} startPrefix a regex to check against the preceding 10 characters 71 | */ 72 | const ESCAPING_SEQUENZES = [ 73 | // Strings 74 | { start: '"', end: '"' }, 75 | { start: "'", end: "'" }, 76 | { start: "`", end: "`" }, 77 | // RegeEx 78 | { start: "/", end: "/", startPrefix: /(^|[[{:;,/])\s?$/ }, 79 | ]; 80 | 81 | /** 82 | * Match begin and end braces of input JS, return only JS 83 | * 84 | * @param {string} mixedJson 85 | * @returns {string} 86 | */ 87 | exports.cutAfterJS = mixedJson => { 88 | // Define the general open and closing tag 89 | let open, close; 90 | if (mixedJson[0] === "[") { 91 | open = "["; 92 | close = "]"; 93 | } else if (mixedJson[0] === "{") { 94 | open = "{"; 95 | close = "}"; 96 | } 97 | 98 | if (!open) { 99 | throw new Error(`Can't cut unsupported JSON (need to begin with [ or { ) but got: ${mixedJson[0]}`); 100 | } 101 | 102 | // States if the loop is currently inside an escaped js object 103 | let isEscapedObject = null; 104 | 105 | // States if the current character is treated as escaped or not 106 | let isEscaped = false; 107 | 108 | // Current open brackets to be closed 109 | let counter = 0; 110 | 111 | let i; 112 | // Go through all characters from the start 113 | for (i = 0; i < mixedJson.length; i++) { 114 | // End of current escaped object 115 | if (!isEscaped && isEscapedObject !== null && mixedJson[i] === isEscapedObject.end) { 116 | isEscapedObject = null; 117 | continue; 118 | // Might be the start of a new escaped object 119 | } else if (!isEscaped && isEscapedObject === null) { 120 | for (const escaped of ESCAPING_SEQUENZES) { 121 | if (mixedJson[i] !== escaped.start) continue; 122 | // Test startPrefix against last 10 characters 123 | if (!escaped.startPrefix || mixedJson.substring(i - 10, i).match(escaped.startPrefix)) { 124 | isEscapedObject = escaped; 125 | break; 126 | } 127 | } 128 | // Continue if we found a new escaped object 129 | if (isEscapedObject !== null) { 130 | continue; 131 | } 132 | } 133 | 134 | // Toggle the isEscaped boolean for every backslash 135 | // Reset for every regular character 136 | isEscaped = mixedJson[i] === "\\" && !isEscaped; 137 | 138 | if (isEscapedObject !== null) continue; 139 | 140 | if (mixedJson[i] === open) { 141 | counter++; 142 | } else if (mixedJson[i] === close) { 143 | counter--; 144 | } 145 | 146 | // All brackets have been closed, thus end of JSON is reached 147 | if (counter === 0) { 148 | // Return the cut JSON 149 | return mixedJson.substring(0, i + 1); 150 | } 151 | } 152 | 153 | // We ran through the whole string and ended up with an unclosed bracket 154 | throw Error("Can't cut unsupported JSON (no matching closing bracket found)"); 155 | }; 156 | 157 | class UnrecoverableError extends Error {} 158 | /** 159 | * Checks if there is a playability error. 160 | * 161 | * @param {Object} player_response 162 | * @returns {!Error} 163 | */ 164 | exports.playError = player_response => { 165 | const playability = player_response?.playabilityStatus; 166 | if (!playability) return null; 167 | if (["ERROR", "LOGIN_REQUIRED"].includes(playability.status)) { 168 | return new UnrecoverableError(playability.reason || playability.messages?.[0]); 169 | } 170 | if (playability.status === "LIVE_STREAM_OFFLINE") { 171 | return new UnrecoverableError(playability.reason || "The live stream is offline."); 172 | } 173 | if (playability.status === "UNPLAYABLE") { 174 | return new UnrecoverableError(playability.reason || "This video is unavailable."); 175 | } 176 | return null; 177 | }; 178 | 179 | // Undici request 180 | const useFetch = async (fetch, url, requestOptions) => { 181 | // embed query to url 182 | const query = requestOptions.query; 183 | if (query) { 184 | const urlObject = new URL(url); 185 | for (const key in query) { 186 | urlObject.searchParams.append(key, query[key]); 187 | } 188 | url = urlObject.toString(); 189 | } 190 | 191 | const response = await fetch(url, requestOptions); 192 | 193 | // convert webstandard response to undici request's response 194 | const statusCode = response.status; 195 | const body = Object.assign(response, response.body || {}); 196 | const headers = Object.fromEntries(response.headers.entries()); 197 | 198 | return { body, statusCode, headers }; 199 | }; 200 | exports.request = async (url, options = {}) => { 201 | let { requestOptions, rewriteRequest, fetch } = options; 202 | 203 | if (typeof rewriteRequest === "function") { 204 | const rewritten = rewriteRequest(url, requestOptions); 205 | requestOptions = rewritten.requestOptions || requestOptions; 206 | url = rewritten.url || url; 207 | } 208 | 209 | const req = 210 | typeof fetch === "function" ? await useFetch(fetch, url, requestOptions) : await request(url, requestOptions); 211 | const code = req.statusCode.toString(); 212 | 213 | if (code.startsWith("2")) { 214 | if (req.headers["content-type"].includes("application/json")) return req.body.json(); 215 | return req.body.text(); 216 | } 217 | if (code.startsWith("3")) return exports.request(req.headers.location, options); 218 | 219 | const e = new Error(`Status code: ${code}`); 220 | e.statusCode = req.statusCode; 221 | throw e; 222 | }; 223 | 224 | /** 225 | * Temporary helper to help deprecating a few properties. 226 | * 227 | * @param {Object} obj 228 | * @param {string} prop 229 | * @param {Object} value 230 | * @param {string} oldPath 231 | * @param {string} newPath 232 | */ 233 | exports.deprecate = (obj, prop, value, oldPath, newPath) => { 234 | Object.defineProperty(obj, prop, { 235 | get: () => { 236 | console.warn(`\`${oldPath}\` will be removed in a near future release, ` + `use \`${newPath}\` instead.`); 237 | return value; 238 | }, 239 | }); 240 | }; 241 | 242 | // Check for updates. 243 | const pkg = require("../package.json"); 244 | const UPDATE_INTERVAL = 1000 * 60 * 60 * 12; 245 | let updateWarnTimes = 0; 246 | exports.lastUpdateCheck = 0; 247 | exports.checkForUpdates = () => { 248 | if ( 249 | !process.env.YTDL_NO_UPDATE && 250 | !pkg.version.startsWith("0.0.0-") && 251 | Date.now() - exports.lastUpdateCheck >= UPDATE_INTERVAL 252 | ) { 253 | exports.lastUpdateCheck = Date.now(); 254 | return exports 255 | .request("https://api.github.com/repos/distubejs/ytdl-core/contents/package.json", { 256 | requestOptions: { 257 | headers: { 258 | "User-Agent": 259 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.3", 260 | }, 261 | }, 262 | }) 263 | .then( 264 | response => { 265 | const buf = Buffer.from(response.content, response.encoding); 266 | const pkgFile = JSON.parse(buf.toString("ascii")); 267 | if (pkgFile.version !== pkg.version && updateWarnTimes++ < 5) { 268 | // eslint-disable-next-line max-len 269 | console.warn( 270 | '\x1b[33mWARNING:\x1B[0m @distube/ytdl-core is out of date! Update with "npm install @distube/ytdl-core@latest".', 271 | ); 272 | } 273 | }, 274 | err => { 275 | console.warn("Error checking for updates:", err.message); 276 | console.warn("You can disable this check by setting the `YTDL_NO_UPDATE` env variable."); 277 | }, 278 | ); 279 | } 280 | return null; 281 | }; 282 | 283 | /** 284 | * Gets random IPv6 Address from a block 285 | * 286 | * @param {string} ip the IPv6 block in CIDR-Notation 287 | * @returns {string} 288 | */ 289 | const getRandomIPv6 = ip => { 290 | if (!isIPv6(ip)) { 291 | throw new Error("Invalid IPv6 format"); 292 | } 293 | 294 | const [rawAddr, rawMask] = ip.split("/"); 295 | const mask = parseInt(rawMask, 10); 296 | 297 | if (isNaN(mask) || mask > 128 || mask < 1) { 298 | throw new Error("Invalid IPv6 subnet mask (must be between 1 and 128)"); 299 | } 300 | 301 | const base10addr = normalizeIP(rawAddr); 302 | 303 | const fullMaskGroups = Math.floor(mask / 16); 304 | const remainingBits = mask % 16; 305 | 306 | const result = new Array(8).fill(0); 307 | 308 | for (let i = 0; i < 8; i++) { 309 | if (i < fullMaskGroups) { 310 | result[i] = base10addr[i]; 311 | } else if (i === fullMaskGroups && remainingBits > 0) { 312 | const groupMask = 0xffff << (16 - remainingBits); 313 | const randomPart = Math.floor(Math.random() * (1 << (16 - remainingBits))); 314 | result[i] = (base10addr[i] & groupMask) | randomPart; 315 | } else { 316 | result[i] = Math.floor(Math.random() * 0x10000); 317 | } 318 | } 319 | 320 | return result.map(x => x.toString(16).padStart(4, "0")).join(":"); 321 | }; 322 | 323 | const isIPv6 = ip => { 324 | const IPV6_REGEX = 325 | /^(?:(?:[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]))?$/; 326 | return IPV6_REGEX.test(ip); 327 | }; 328 | 329 | /** 330 | * Normalizes an IPv6 address into an array of 8 integers 331 | * @param {string} ip - IPv6 address 332 | * @returns {number[]} - Array of 8 integers representing the address 333 | */ 334 | const normalizeIP = ip => { 335 | const parts = ip.split("::"); 336 | let start = parts[0] ? parts[0].split(":") : []; 337 | let end = parts[1] ? parts[1].split(":") : []; 338 | 339 | const missing = 8 - (start.length + end.length); 340 | const zeros = new Array(missing).fill("0"); 341 | 342 | const full = [...start, ...zeros, ...end]; 343 | 344 | return full.map(part => parseInt(part || "0", 16)); 345 | }; 346 | 347 | exports.saveDebugFile = (name, body) => { 348 | if (process.env.YTDL_NO_DEBUG_FILE) { 349 | console.warn(`\x1b[33mWARNING:\x1b[0m Debug file saving is disabled. "${name}"`); 350 | return body; 351 | } 352 | const filename = `${+new Date()}-${name}`; 353 | const debugPath = process.env.YTDL_DEBUG_PATH || '.'; 354 | writeFileSync(`${debugPath}/${filename}`, body); 355 | return filename; 356 | }; 357 | 358 | const findPropKeyInsensitive = (obj, prop) => 359 | Object.keys(obj).find(p => p.toLowerCase() === prop.toLowerCase()) || null; 360 | 361 | exports.getPropInsensitive = (obj, prop) => { 362 | const key = findPropKeyInsensitive(obj, prop); 363 | return key && obj[key]; 364 | }; 365 | 366 | exports.setPropInsensitive = (obj, prop, value) => { 367 | const key = findPropKeyInsensitive(obj, prop); 368 | obj[key || prop] = value; 369 | return key; 370 | }; 371 | 372 | let oldCookieWarning = true; 373 | let oldDispatcherWarning = true; 374 | exports.applyDefaultAgent = options => { 375 | if (!options.agent) { 376 | const { jar } = AGENT.defaultAgent; 377 | const c = exports.getPropInsensitive(options.requestOptions.headers, "cookie"); 378 | if (c) { 379 | jar.removeAllCookiesSync(); 380 | AGENT.addCookiesFromString(jar, c); 381 | if (oldCookieWarning) { 382 | oldCookieWarning = false; 383 | console.warn( 384 | "\x1b[33mWARNING:\x1B[0m Using old cookie format, " + 385 | "please use the new one instead. (https://github.com/distubejs/ytdl-core#cookies-support)", 386 | ); 387 | } 388 | } 389 | if (options.requestOptions.dispatcher && oldDispatcherWarning) { 390 | oldDispatcherWarning = false; 391 | console.warn( 392 | "\x1b[33mWARNING:\x1B[0m Your dispatcher is overridden by `ytdl.Agent`. " + 393 | "To implement your own, check out the documentation. " + 394 | "(https://github.com/distubejs/ytdl-core#how-to-implement-ytdlagent-with-your-own-dispatcher)", 395 | ); 396 | } 397 | options.agent = AGENT.defaultAgent; 398 | } 399 | }; 400 | 401 | let oldLocalAddressWarning = true; 402 | exports.applyOldLocalAddress = options => { 403 | if (!options?.requestOptions?.localAddress || options.requestOptions.localAddress === options.agent.localAddress) 404 | return; 405 | options.agent = AGENT.createAgent(undefined, { localAddress: options.requestOptions.localAddress }); 406 | if (oldLocalAddressWarning) { 407 | oldLocalAddressWarning = false; 408 | console.warn( 409 | "\x1b[33mWARNING:\x1B[0m Using old localAddress option, " + 410 | "please add it to the agent options instead. (https://github.com/distubejs/ytdl-core#ip-rotation)", 411 | ); 412 | } 413 | }; 414 | 415 | let oldIpRotationsWarning = true; 416 | exports.applyIPv6Rotations = options => { 417 | if (options.IPv6Block) { 418 | options.requestOptions = Object.assign({}, options.requestOptions, { 419 | localAddress: getRandomIPv6(options.IPv6Block), 420 | }); 421 | if (oldIpRotationsWarning) { 422 | oldIpRotationsWarning = false; 423 | oldLocalAddressWarning = false; 424 | console.warn( 425 | "\x1b[33mWARNING:\x1B[0m IPv6Block option is deprecated, " + 426 | "please create your own ip rotation instead. (https://github.com/distubejs/ytdl-core#ip-rotation)", 427 | ); 428 | } 429 | } 430 | }; 431 | 432 | exports.applyDefaultHeaders = options => { 433 | options.requestOptions = Object.assign({}, options.requestOptions); 434 | options.requestOptions.headers = Object.assign( 435 | {}, 436 | { 437 | // eslint-disable-next-line max-len 438 | "User-Agent": 439 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.101 Safari/537.36", 440 | }, 441 | options.requestOptions.headers, 442 | ); 443 | }; 444 | 445 | exports.generateClientPlaybackNonce = length => { 446 | const CPN_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; 447 | return Array.from({ length }, () => CPN_CHARS[Math.floor(Math.random() * CPN_CHARS.length)]).join(""); 448 | }; 449 | 450 | exports.applyPlayerClients = options => { 451 | if (!options.playerClients || options.playerClients.length === 0) { 452 | options.playerClients = ["WEB_EMBEDDED", "IOS", "ANDROID", "TV"]; 453 | } 454 | }; 455 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@distube/ytdl-core", 3 | "description": "DisTube fork of ytdl-core. YouTube video downloader in pure javascript.", 4 | "keywords": [ 5 | "youtube", 6 | "video", 7 | "download", 8 | "distube" 9 | ], 10 | "version": "4.16.10", 11 | "repository": { 12 | "type": "git", 13 | "url": "git://github.com/distubejs/ytdl-core.git" 14 | }, 15 | "author": "Skick (https://github.com/skick1234)", 16 | "contributors": [ 17 | "fent (https://github.com/fent)", 18 | "Tobias Kutscha (https://github.com/TimeForANinja)", 19 | "Andrew Kelley (https://github.com/andrewrk)", 20 | "Mauricio Allende (https://github.com/mallendeo)", 21 | "Rodrigo Altamirano (https://github.com/raltamirano)", 22 | "Jim Buck (https://github.com/JimmyBoh)", 23 | "Pawel Rucinski (https://github.com/Roki100)", 24 | "Alexander Paolini (https://github.com/Million900o)" 25 | ], 26 | "main": "./lib/index.js", 27 | "types": "./typings/index.d.ts", 28 | "files": [ 29 | "lib", 30 | "typings" 31 | ], 32 | "scripts": { 33 | "prettier": "prettier --write \"**/*.{js,json,yml,md,ts}\"" 34 | }, 35 | "dependencies": { 36 | "http-cookie-agent": "^7.0.1", 37 | "https-proxy-agent": "^7.0.6", 38 | "m3u8stream": "^0.8.6", 39 | "miniget": "^4.2.3", 40 | "sax": "^1.4.1", 41 | "tough-cookie": "^5.1.2", 42 | "undici": "^7.8.0" 43 | }, 44 | "devDependencies": { 45 | "@types/node": "^22.15.2", 46 | "prettier": "^3.5.3", 47 | "typescript": "^5.8.3" 48 | }, 49 | "engines": { 50 | "node": ">=20.18.1" 51 | }, 52 | "license": "MIT", 53 | "funding": "https://github.com/distubejs/ytdl-core?sponsor" 54 | } 55 | -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: 10 | dependencies: 11 | http-cookie-agent: 12 | specifier: ^7.0.1 13 | version: 7.0.1(tough-cookie@5.1.2)(undici@7.8.0) 14 | https-proxy-agent: 15 | specifier: ^7.0.6 16 | version: 7.0.6 17 | m3u8stream: 18 | specifier: ^0.8.6 19 | version: 0.8.6 20 | miniget: 21 | specifier: ^4.2.3 22 | version: 4.2.3 23 | sax: 24 | specifier: ^1.4.1 25 | version: 1.4.1 26 | tough-cookie: 27 | specifier: ^5.1.2 28 | version: 5.1.2 29 | undici: 30 | specifier: ^7.8.0 31 | version: 7.8.0 32 | devDependencies: 33 | '@types/node': 34 | specifier: ^22.15.2 35 | version: 22.15.2 36 | prettier: 37 | specifier: ^3.5.3 38 | version: 3.5.3 39 | typescript: 40 | specifier: ^5.8.3 41 | version: 5.8.3 42 | 43 | packages: 44 | 45 | '@types/node@22.15.2': 46 | resolution: {integrity: sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A==} 47 | 48 | agent-base@7.1.3: 49 | resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} 50 | engines: {node: '>= 14'} 51 | 52 | debug@4.4.0: 53 | resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} 54 | engines: {node: '>=6.0'} 55 | peerDependencies: 56 | supports-color: '*' 57 | peerDependenciesMeta: 58 | supports-color: 59 | optional: true 60 | 61 | http-cookie-agent@7.0.1: 62 | resolution: {integrity: sha512-lZHFZUdPTw64PdksQac5xbUd4NWjUbyDYnvR//2sbLpcC4UqEUW0x/6O+rDntVzJzJ07QvhtL5XZSC+c5EK+IQ==} 63 | engines: {node: '>=20.0.0'} 64 | peerDependencies: 65 | tough-cookie: ^4.0.0 || ^5.0.0 66 | undici: ^7.0.0 67 | peerDependenciesMeta: 68 | undici: 69 | optional: true 70 | 71 | https-proxy-agent@7.0.6: 72 | resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} 73 | engines: {node: '>= 14'} 74 | 75 | m3u8stream@0.8.6: 76 | resolution: {integrity: sha512-LZj8kIVf9KCphiHmH7sbFQTVe4tOemb202fWwvJwR9W5ENW/1hxJN6ksAWGhQgSBSa3jyWhnjKU1Fw1GaOdbyA==} 77 | engines: {node: '>=12'} 78 | 79 | miniget@4.2.3: 80 | resolution: {integrity: sha512-SjbDPDICJ1zT+ZvQwK0hUcRY4wxlhhNpHL9nJOB2MEAXRGagTljsO8MEDzQMTFf0Q8g4QNi8P9lEm/g7e+qgzA==} 81 | engines: {node: '>=12'} 82 | 83 | ms@2.1.3: 84 | resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 85 | 86 | prettier@3.5.3: 87 | resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==} 88 | engines: {node: '>=14'} 89 | hasBin: true 90 | 91 | sax@1.4.1: 92 | resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} 93 | 94 | tldts-core@6.1.86: 95 | resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} 96 | 97 | tldts@6.1.86: 98 | resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} 99 | hasBin: true 100 | 101 | tough-cookie@5.1.2: 102 | resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} 103 | engines: {node: '>=16'} 104 | 105 | typescript@5.8.3: 106 | resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} 107 | engines: {node: '>=14.17'} 108 | hasBin: true 109 | 110 | undici-types@6.21.0: 111 | resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} 112 | 113 | undici@7.8.0: 114 | resolution: {integrity: sha512-vFv1GA99b7eKO1HG/4RPu2Is3FBTWBrmzqzO0mz+rLxN3yXkE4mqRcb8g8fHxzX4blEysrNZLqg5RbJLqX5buA==} 115 | engines: {node: '>=20.18.1'} 116 | 117 | snapshots: 118 | 119 | '@types/node@22.15.2': 120 | dependencies: 121 | undici-types: 6.21.0 122 | 123 | agent-base@7.1.3: {} 124 | 125 | debug@4.4.0: 126 | dependencies: 127 | ms: 2.1.3 128 | 129 | http-cookie-agent@7.0.1(tough-cookie@5.1.2)(undici@7.8.0): 130 | dependencies: 131 | agent-base: 7.1.3 132 | tough-cookie: 5.1.2 133 | optionalDependencies: 134 | undici: 7.8.0 135 | 136 | https-proxy-agent@7.0.6: 137 | dependencies: 138 | agent-base: 7.1.3 139 | debug: 4.4.0 140 | transitivePeerDependencies: 141 | - supports-color 142 | 143 | m3u8stream@0.8.6: 144 | dependencies: 145 | miniget: 4.2.3 146 | sax: 1.4.1 147 | 148 | miniget@4.2.3: {} 149 | 150 | ms@2.1.3: {} 151 | 152 | prettier@3.5.3: {} 153 | 154 | sax@1.4.1: {} 155 | 156 | tldts-core@6.1.86: {} 157 | 158 | tldts@6.1.86: 159 | dependencies: 160 | tldts-core: 6.1.86 161 | 162 | tough-cookie@5.1.2: 163 | dependencies: 164 | tldts: 6.1.86 165 | 166 | typescript@5.8.3: {} 167 | 168 | undici-types@6.21.0: {} 169 | 170 | undici@7.8.0: {} 171 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "allowJs": true 6 | }, 7 | "include": ["typings/**/*"], 8 | "exclude": ["node_modules"] 9 | } 10 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["dtslint/dtslint.json"], 3 | "rules": { 4 | "prefer-readonly": false, 5 | "await-promise": false, 6 | "no-for-in-array": false, 7 | "no-null-undefined-union": false, 8 | "no-promise-as-boolean": false, 9 | "no-void-expression": false, 10 | "strict-string-expressions": false, 11 | "strict-comparisons": false, 12 | "use-default-type-parameter": false, 13 | "no-boolean-literal-compare": false, 14 | "no-unnecessary-qualifier": false, 15 | "no-unnecessary-type-assertion": false, 16 | "expect": false, 17 | "no-import-default-of-export-equals": false, 18 | "no-relative-import-in-test": false, 19 | "no-unnecessary-generics": false, 20 | "strict-export-declare-modifiers": false, 21 | "no-single-declare-module": false, 22 | "member-access": true, 23 | "no-unnecessary-class": false, 24 | "array-type": [true, "array"], 25 | 26 | "no-any-union": false, 27 | "void-return": false 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /typings/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "tough-cookie" { 2 | export const version: string; 3 | 4 | export const PrefixSecurityEnum: Readonly<{ 5 | DISABLED: string; 6 | SILENT: string; 7 | STRICT: string; 8 | }>; 9 | 10 | /** 11 | * Parse a cookie date string into a Date. 12 | * Parses according to RFC6265 Section 5.1.1, not Date.parse(). 13 | */ 14 | export function parseDate(string: string): Date; 15 | 16 | /** 17 | * Format a Date into a RFC1123 string (the RFC6265-recommended format). 18 | */ 19 | export function formatDate(date: Date): string; 20 | 21 | /** 22 | * Transforms a domain-name into a canonical domain-name. 23 | * The canonical domain-name is a trimmed, lowercased, stripped-of-leading-dot 24 | * and optionally punycode-encoded domain-name (Section 5.1.2 of RFC6265). 25 | * For the most part, this function is idempotent (can be run again on its output without ill effects). 26 | */ 27 | export function canonicalDomain(str: string): string; 28 | 29 | /** 30 | * Answers "does this real domain match the domain in a cookie?". 31 | * The str is the "current" domain-name and the domStr is the "cookie" domain-name. 32 | * Matches according to RFC6265 Section 5.1.3, but it helps to think of it as a "suffix match". 33 | * 34 | * The canonicalize parameter will run the other two parameters through canonicalDomain or not. 35 | */ 36 | export function domainMatch(str: string, domStr: string, canonicalize?: boolean): boolean; 37 | 38 | /** 39 | * Given a current request/response path, gives the Path apropriate for storing in a cookie. 40 | * This is basically the "directory" of a "file" in the path, but is specified by Section 5.1.4 of the RFC. 41 | * 42 | * The path parameter MUST be only the pathname part of a URI (i.e. excludes the hostname, query, fragment, etc.). 43 | * This is the .pathname property of node's uri.parse() output. 44 | */ 45 | export function defaultPath(path: string): string; 46 | 47 | /** 48 | * Answers "does the request-path path-match a given cookie-path?" as per RFC6265 Section 5.1.4. 49 | * Returns a boolean. 50 | * 51 | * This is essentially a prefix-match where cookiePath is a prefix of reqPath. 52 | */ 53 | export function pathMatch(reqPath: string, cookiePath: string): boolean; 54 | 55 | /** 56 | * alias for Cookie.parse(cookieString[, options]) 57 | */ 58 | export function parse(cookieString: string, options?: Cookie.ParseOptions): Cookie | undefined; 59 | 60 | /** 61 | * alias for Cookie.fromJSON(string) 62 | */ 63 | export function fromJSON(string: string): Cookie; 64 | 65 | export function getPublicSuffix(hostname: string): string | null; 66 | 67 | export function cookieCompare(a: Cookie, b: Cookie): number; 68 | 69 | export function permuteDomain(domain: string, allowSpecialUseDomain?: boolean): string[]; 70 | 71 | export function permutePath(path: string): string[]; 72 | 73 | export class Cookie { 74 | static parse(cookieString: string, options?: Cookie.ParseOptions): Cookie | undefined; 75 | 76 | static fromJSON(strOrObj: string | object): Cookie | null; 77 | 78 | constructor(properties?: Cookie.Properties); 79 | 80 | key: string; 81 | value: string; 82 | expires: Date | "Infinity"; 83 | maxAge: number | "Infinity" | "-Infinity"; 84 | domain: string | null; 85 | path: string | null; 86 | secure: boolean; 87 | httpOnly: boolean; 88 | extensions: string[] | null; 89 | creation: Date | null; 90 | creationIndex: number; 91 | 92 | hostOnly: boolean | null; 93 | pathIsDefault: boolean | null; 94 | lastAccessed: Date | null; 95 | sameSite: string; 96 | 97 | toString(): string; 98 | 99 | cookieString(): string; 100 | 101 | setExpires(exp: Date | string): void; 102 | 103 | setMaxAge(number: number): void; 104 | 105 | expiryTime(now?: number): number; 106 | 107 | expiryDate(now?: number): Date; 108 | 109 | TTL(now?: Date): number | typeof Infinity; 110 | 111 | isPersistent(): boolean; 112 | 113 | canonicalizedDomain(): string | null; 114 | 115 | cdomain(): string | null; 116 | 117 | inspect(): string; 118 | 119 | toJSON(): { [key: string]: any }; 120 | 121 | clone(): Cookie; 122 | 123 | validate(): boolean | string; 124 | } 125 | 126 | export namespace Cookie { 127 | interface ParseOptions { 128 | loose?: boolean | undefined; 129 | } 130 | 131 | interface Properties { 132 | key?: string | undefined; 133 | value?: string | undefined; 134 | expires?: Date | "Infinity" | undefined; 135 | maxAge?: number | "Infinity" | "-Infinity" | undefined; 136 | domain?: string | undefined; 137 | path?: string | undefined; 138 | secure?: boolean | undefined; 139 | httpOnly?: boolean | undefined; 140 | extensions?: string[] | undefined; 141 | creation?: Date | undefined; 142 | creationIndex?: number | undefined; 143 | 144 | hostOnly?: boolean | undefined; 145 | pathIsDefault?: boolean | undefined; 146 | lastAccessed?: Date | undefined; 147 | sameSite?: string | undefined; 148 | } 149 | 150 | interface Serialized { 151 | [key: string]: any; 152 | } 153 | } 154 | 155 | export class CookieJar { 156 | static deserialize(serialized: CookieJar.Serialized | string, store?: Store): Promise; 157 | static deserialize( 158 | serialized: CookieJar.Serialized | string, 159 | store: Store, 160 | cb: (err: Error | null, object: CookieJar) => void, 161 | ): void; 162 | static deserialize( 163 | serialized: CookieJar.Serialized | string, 164 | cb: (err: Error | null, object: CookieJar) => void, 165 | ): void; 166 | 167 | static deserializeSync(serialized: CookieJar.Serialized | string, store?: Store): CookieJar; 168 | 169 | static fromJSON(string: string): CookieJar; 170 | 171 | constructor(store?: Store, options?: CookieJar.Options); 172 | 173 | setCookie( 174 | cookieOrString: Cookie | string, 175 | currentUrl: string, 176 | options?: CookieJar.SetCookieOptions, 177 | ): Promise; 178 | setCookie( 179 | cookieOrString: Cookie | string, 180 | currentUrl: string, 181 | options: CookieJar.SetCookieOptions, 182 | cb: (err: Error | null, cookie: Cookie) => void, 183 | ): void; 184 | setCookie( 185 | cookieOrString: Cookie | string, 186 | currentUrl: string, 187 | cb: (err: Error | null, cookie: Cookie) => void, 188 | ): void; 189 | 190 | setCookieSync(cookieOrString: Cookie | string, currentUrl: string, options?: CookieJar.SetCookieOptions): Cookie; 191 | 192 | getCookies(currentUrl: string, options?: CookieJar.GetCookiesOptions): Promise; 193 | getCookies( 194 | currentUrl: string, 195 | options: CookieJar.GetCookiesOptions, 196 | cb: (err: Error | null, cookies: Cookie[]) => void, 197 | ): void; 198 | getCookies(currentUrl: string, cb: (err: Error | null, cookies: Cookie[]) => void): void; 199 | 200 | getCookiesSync(currentUrl: string, options?: CookieJar.GetCookiesOptions): Cookie[]; 201 | 202 | getCookieString(currentUrl: string, options?: CookieJar.GetCookiesOptions): Promise; 203 | getCookieString( 204 | currentUrl: string, 205 | options: CookieJar.GetCookiesOptions, 206 | cb: (err: Error | null, cookies: string) => void, 207 | ): void; 208 | getCookieString(currentUrl: string, cb: (err: Error | null, cookies: string) => void): void; 209 | 210 | getCookieStringSync(currentUrl: string, options?: CookieJar.GetCookiesOptions): string; 211 | 212 | getSetCookieStrings(currentUrl: string, options?: CookieJar.GetCookiesOptions): Promise; 213 | getSetCookieStrings( 214 | currentUrl: string, 215 | options: CookieJar.GetCookiesOptions, 216 | cb: (err: Error | null, cookies: string[]) => void, 217 | ): void; 218 | getSetCookieStrings(currentUrl: string, cb: (err: Error | null, cookies: string[]) => void): void; 219 | 220 | getSetCookieStringsSync(currentUrl: string, options?: CookieJar.GetCookiesOptions): string[]; 221 | 222 | serialize(): Promise; 223 | serialize(cb: (err: Error | null, serializedObject: CookieJar.Serialized) => void): void; 224 | 225 | serializeSync(): CookieJar.Serialized; 226 | 227 | toJSON(): CookieJar.Serialized; 228 | 229 | clone(store?: Store): Promise; 230 | clone(store: Store, cb: (err: Error | null, newJar: CookieJar) => void): void; 231 | clone(cb: (err: Error | null, newJar: CookieJar) => void): void; 232 | 233 | cloneSync(store?: Store): CookieJar; 234 | 235 | removeAllCookies(): Promise; 236 | removeAllCookies(cb: (err: Error | null) => void): void; 237 | 238 | removeAllCookiesSync(): void; 239 | } 240 | 241 | export namespace CookieJar { 242 | interface Options { 243 | allowSpecialUseDomain?: boolean | undefined; 244 | looseMode?: boolean | undefined; 245 | rejectPublicSuffixes?: boolean | undefined; 246 | prefixSecurity?: string | undefined; 247 | } 248 | 249 | interface SetCookieOptions { 250 | http?: boolean | undefined; 251 | secure?: boolean | undefined; 252 | now?: Date | undefined; 253 | ignoreError?: boolean | undefined; 254 | } 255 | 256 | interface GetCookiesOptions { 257 | http?: boolean | undefined; 258 | secure?: boolean | undefined; 259 | now?: Date | undefined; 260 | expire?: boolean | undefined; 261 | allPaths?: boolean | undefined; 262 | } 263 | 264 | interface Serialized { 265 | version: string; 266 | storeType: string; 267 | rejectPublicSuffixes: boolean; 268 | cookies: Cookie.Serialized[]; 269 | } 270 | } 271 | 272 | export abstract class Store { 273 | synchronous: boolean; 274 | 275 | findCookie(domain: string, path: string, key: string, cb: (err: Error | null, cookie: Cookie | null) => void): void; 276 | 277 | findCookies( 278 | domain: string, 279 | path: string, 280 | allowSpecialUseDomain: boolean, 281 | cb: (err: Error | null, cookie: Cookie[]) => void, 282 | ): void; 283 | 284 | putCookie(cookie: Cookie, cb: (err: Error | null) => void): void; 285 | 286 | updateCookie(oldCookie: Cookie, newCookie: Cookie, cb: (err: Error | null) => void): void; 287 | 288 | removeCookie(domain: string, path: string, key: string, cb: (err: Error | null) => void): void; 289 | 290 | removeCookies(domain: string, path: string, cb: (err: Error | null) => void): void; 291 | 292 | getAllCookies(cb: (err: Error | null, cookie: Cookie[]) => void): void; 293 | } 294 | 295 | export class MemoryCookieStore extends Store { 296 | findCookie(domain: string, path: string, key: string, cb: (err: Error | null, cookie: Cookie | null) => void): void; 297 | findCookie(domain: string, path: string, key: string): Promise; 298 | 299 | findCookies( 300 | domain: string, 301 | path: string, 302 | allowSpecialUseDomain: boolean, 303 | cb: (err: Error | null, cookie: Cookie[]) => void, 304 | ): void; 305 | findCookies(domain: string, path: string, cb: (err: Error | null, cookie: Cookie[]) => void): void; 306 | findCookies(domain: string, path: string, allowSpecialUseDomain?: boolean): Promise; 307 | 308 | putCookie(cookie: Cookie, cb: (err: Error | null) => void): void; 309 | putCookie(cookie: Cookie): Promise; 310 | 311 | updateCookie(oldCookie: Cookie, newCookie: Cookie, cb: (err: Error | null) => void): void; 312 | updateCookie(oldCookie: Cookie, newCookie: Cookie): Promise; 313 | 314 | removeCookie(domain: string, path: string, key: string, cb: (err: Error | null) => void): void; 315 | removeCookie(domain: string, path: string, key: string): Promise; 316 | 317 | removeCookies(domain: string, path: string, cb: (err: Error | null) => void): void; 318 | removeCookies(domain: string, path: string): Promise; 319 | 320 | getAllCookies(cb: (err: Error | null, cookie: Cookie[]) => void): void; 321 | getAllCookies(): Promise; 322 | } 323 | } 324 | 325 | declare module "@distube/ytdl-core" { 326 | import { Dispatcher, ProxyAgent, request } from "undici"; 327 | import { Cookie as CK, CookieJar } from "tough-cookie"; 328 | import { CookieAgent } from "http-cookie-agent/undici"; 329 | import { Readable } from "stream"; 330 | 331 | namespace ytdl { 332 | type Filter = 333 | | "audioandvideo" 334 | | "videoandaudio" 335 | | "video" 336 | | "videoonly" 337 | | "audio" 338 | | "audioonly" 339 | | ((format: videoFormat) => boolean); 340 | 341 | interface Agent { 342 | dispatcher: Dispatcher; 343 | jar: CookieJar; 344 | localAddress?: string; 345 | } 346 | 347 | interface getInfoOptions { 348 | lang?: string; 349 | requestCallback?: () => {}; 350 | rewriteRequest?: ( 351 | url: string, 352 | requestOptions: Parameters[1], 353 | ) => { url: string; requestOptions: Parameters[1] }; 354 | fetch?: (url: string, requestOptions: Parameters[1]) => Promise; 355 | requestOptions?: Parameters[1]; 356 | agent?: Agent; 357 | playerClients?: Array<"WEB_EMBEDDED" | "TV" | "IOS" | "ANDROID" | "WEB">; 358 | } 359 | 360 | interface chooseFormatOptions { 361 | quality?: 362 | | "lowest" 363 | | "highest" 364 | | "highestaudio" 365 | | "lowestaudio" 366 | | "highestvideo" 367 | | "lowestvideo" 368 | | (string & {}) 369 | | number 370 | | string[] 371 | | number[]; 372 | filter?: Filter; 373 | format?: videoFormat; 374 | } 375 | 376 | interface downloadOptions extends getInfoOptions, chooseFormatOptions { 377 | range?: { 378 | start?: number; 379 | end?: number; 380 | }; 381 | begin?: string | number | Date; 382 | liveBuffer?: number; 383 | highWaterMark?: number; 384 | IPv6Block?: string; 385 | dlChunkSize?: number; 386 | } 387 | 388 | interface videoFormat { 389 | itag: number; 390 | url: string; 391 | mimeType?: string; 392 | bitrate?: number; 393 | audioBitrate?: number; 394 | width?: number; 395 | height?: number; 396 | initRange?: { start: string; end: string }; 397 | indexRange?: { start: string; end: string }; 398 | lastModified: string; 399 | contentLength: string; 400 | quality: "tiny" | "small" | "medium" | "large" | "hd720" | "hd1080" | "hd1440" | "hd2160" | "highres" | string; 401 | qualityLabel: 402 | | "144p" 403 | | "144p 15fps" 404 | | "144p60 HDR" 405 | | "240p" 406 | | "240p60 HDR" 407 | | "270p" 408 | | "360p" 409 | | "360p60 HDR" 410 | | "480p" 411 | | "480p60 HDR" 412 | | "720p" 413 | | "720p60" 414 | | "720p60 HDR" 415 | | "1080p" 416 | | "1080p60" 417 | | "1080p60 HDR" 418 | | "1440p" 419 | | "1440p60" 420 | | "1440p60 HDR" 421 | | "2160p" 422 | | "2160p60" 423 | | "2160p60 HDR" 424 | | "4320p" 425 | | "4320p60"; 426 | projectionType?: "RECTANGULAR"; 427 | fps?: number; 428 | averageBitrate?: number; 429 | audioQuality?: "AUDIO_QUALITY_LOW" | "AUDIO_QUALITY_MEDIUM"; 430 | colorInfo?: { 431 | primaries: string; 432 | transferCharacteristics: string; 433 | matrixCoefficients: string; 434 | }; 435 | highReplication?: boolean; 436 | approxDurationMs?: string; 437 | targetDurationSec?: number; 438 | maxDvrDurationSec?: number; 439 | audioSampleRate?: string; 440 | audioChannels?: number; 441 | 442 | // Added by ytdl-core 443 | container: "flv" | "3gp" | "mp4" | "webm" | "ts"; 444 | hasVideo: boolean; 445 | hasAudio: boolean; 446 | codecs: string; 447 | videoCodec?: string; 448 | audioCodec?: string; 449 | 450 | isLive: boolean; 451 | isHLS: boolean; 452 | isDashMPD: boolean; 453 | } 454 | 455 | interface thumbnail { 456 | url: string; 457 | width: number; 458 | height: number; 459 | } 460 | 461 | interface captionTrack { 462 | baseUrl: string; 463 | name: { 464 | simpleText: 465 | | "Afrikaans" 466 | | "Albanian" 467 | | "Amharic" 468 | | "Arabic" 469 | | "Armenian" 470 | | "Azerbaijani" 471 | | "Bangla" 472 | | "Basque" 473 | | "Belarusian" 474 | | "Bosnian" 475 | | "Bulgarian" 476 | | "Burmese" 477 | | "Catalan" 478 | | "Cebuano" 479 | | "Chinese (Simplified)" 480 | | "Chinese (Traditional)" 481 | | "Corsican" 482 | | "Croatian" 483 | | "Czech" 484 | | "Danish" 485 | | "Dutch" 486 | | "English" 487 | | "English (auto-generated)" 488 | | "Esperanto" 489 | | "Estonian" 490 | | "Filipino" 491 | | "Finnish" 492 | | "French" 493 | | "Galician" 494 | | "Georgian" 495 | | "German" 496 | | "Greek" 497 | | "Gujarati" 498 | | "Haitian Creole" 499 | | "Hausa" 500 | | "Hawaiian" 501 | | "Hebrew" 502 | | "Hindi" 503 | | "Hmong" 504 | | "Hungarian" 505 | | "Icelandic" 506 | | "Igbo" 507 | | "Indonesian" 508 | | "Irish" 509 | | "Italian" 510 | | "Japanese" 511 | | "Javanese" 512 | | "Kannada" 513 | | "Kazakh" 514 | | "Khmer" 515 | | "Korean" 516 | | "Kurdish" 517 | | "Kyrgyz" 518 | | "Lao" 519 | | "Latin" 520 | | "Latvian" 521 | | "Lithuanian" 522 | | "Luxembourgish" 523 | | "Macedonian" 524 | | "Malagasy" 525 | | "Malay" 526 | | "Malayalam" 527 | | "Maltese" 528 | | "Maori" 529 | | "Marathi" 530 | | "Mongolian" 531 | | "Nepali" 532 | | "Norwegian" 533 | | "Nyanja" 534 | | "Pashto" 535 | | "Persian" 536 | | "Polish" 537 | | "Portuguese" 538 | | "Punjabi" 539 | | "Romanian" 540 | | "Russian" 541 | | "Samoan" 542 | | "Scottish Gaelic" 543 | | "Serbian" 544 | | "Shona" 545 | | "Sindhi" 546 | | "Sinhala" 547 | | "Slovak" 548 | | "Slovenian" 549 | | "Somali" 550 | | "Southern Sotho" 551 | | "Spanish" 552 | | "Spanish (Spain)" 553 | | "Sundanese" 554 | | "Swahili" 555 | | "Swedish" 556 | | "Tajik" 557 | | "Tamil" 558 | | "Telugu" 559 | | "Thai" 560 | | "Turkish" 561 | | "Ukrainian" 562 | | "Urdu" 563 | | "Uzbek" 564 | | "Vietnamese" 565 | | "Welsh" 566 | | "Western Frisian" 567 | | "Xhosa" 568 | | "Yiddish" 569 | | "Yoruba" 570 | | "Zulu" 571 | | string; 572 | }; 573 | vssId: string; 574 | languageCode: 575 | | "af" 576 | | "sq" 577 | | "am" 578 | | "ar" 579 | | "hy" 580 | | "az" 581 | | "bn" 582 | | "eu" 583 | | "be" 584 | | "bs" 585 | | "bg" 586 | | "my" 587 | | "ca" 588 | | "ceb" 589 | | "zh-Hans" 590 | | "zh-Hant" 591 | | "co" 592 | | "hr" 593 | | "cs" 594 | | "da" 595 | | "nl" 596 | | "en" 597 | | "eo" 598 | | "et" 599 | | "fil" 600 | | "fi" 601 | | "fr" 602 | | "gl" 603 | | "ka" 604 | | "de" 605 | | "el" 606 | | "gu" 607 | | "ht" 608 | | "ha" 609 | | "haw" 610 | | "iw" 611 | | "hi" 612 | | "hmn" 613 | | "hu" 614 | | "is" 615 | | "ig" 616 | | "id" 617 | | "ga" 618 | | "it" 619 | | "ja" 620 | | "jv" 621 | | "kn" 622 | | "kk" 623 | | "km" 624 | | "ko" 625 | | "ku" 626 | | "ky" 627 | | "lo" 628 | | "la" 629 | | "lv" 630 | | "lt" 631 | | "lb" 632 | | "mk" 633 | | "mg" 634 | | "ms" 635 | | "ml" 636 | | "mt" 637 | | "mi" 638 | | "mr" 639 | | "mn" 640 | | "ne" 641 | | "no" 642 | | "ny" 643 | | "ps" 644 | | "fa" 645 | | "pl" 646 | | "pt" 647 | | "pa" 648 | | "ro" 649 | | "ru" 650 | | "sm" 651 | | "gd" 652 | | "sr" 653 | | "sn" 654 | | "sd" 655 | | "si" 656 | | "sk" 657 | | "sl" 658 | | "so" 659 | | "st" 660 | | "es" 661 | | "su" 662 | | "sw" 663 | | "sv" 664 | | "tg" 665 | | "ta" 666 | | "te" 667 | | "th" 668 | | "tr" 669 | | "uk" 670 | | "ur" 671 | | "uz" 672 | | "vi" 673 | | "cy" 674 | | "fy" 675 | | "xh" 676 | | "yi" 677 | | "yo" 678 | | "zu" 679 | | string; 680 | kind: string; 681 | rtl?: boolean; 682 | isTranslatable: boolean; 683 | } 684 | 685 | interface audioTrack { 686 | captionTrackIndices: number[]; 687 | } 688 | 689 | interface translationLanguage { 690 | languageCode: captionTrack["languageCode"]; 691 | languageName: captionTrack["name"]; 692 | } 693 | 694 | interface VideoDetails { 695 | videoId: string; 696 | title: string; 697 | shortDescription: string; 698 | lengthSeconds: string; 699 | keywords?: string[]; 700 | channelId: string; 701 | isOwnerViewing: boolean; 702 | isCrawlable: boolean; 703 | thumbnails: thumbnail[]; 704 | averageRating: number; 705 | allowRatings: boolean; 706 | viewCount: string; 707 | author: string; 708 | isPrivate: boolean; 709 | isUnpluggedCorpus: boolean; 710 | isLiveContent: boolean; 711 | isLive: boolean; 712 | } 713 | 714 | interface Media { 715 | category: string; 716 | category_url: string; 717 | game?: string; 718 | game_url?: string; 719 | year?: number; 720 | song?: string; 721 | artist?: string; 722 | artist_url?: string; 723 | writers?: string; 724 | licensed_by?: string; 725 | thumbnails: thumbnail[]; 726 | } 727 | 728 | interface Author { 729 | id: string; 730 | name: string; 731 | avatar: string; // to remove later 732 | thumbnails?: thumbnail[]; 733 | verified: boolean; 734 | user?: string; 735 | channel_url: string; 736 | external_channel_url?: string; 737 | user_url?: string; 738 | subscriber_count?: number; 739 | } 740 | 741 | interface MicroformatRenderer { 742 | thumbnail: { 743 | thumbnails: thumbnail[]; 744 | }; 745 | embed: { 746 | iframeUrl: string; 747 | flashUrl: string; 748 | width: number; 749 | height: number; 750 | flashSecureUrl: string; 751 | }; 752 | title: { 753 | simpleText: string; 754 | }; 755 | description: { 756 | simpleText: string; 757 | }; 758 | lengthSeconds: string; 759 | ownerProfileUrl: string; 760 | ownerGplusProfileUrl?: string; 761 | externalChannelId: string; 762 | isFamilySafe: boolean; 763 | availableCountries: string[]; 764 | isUnlisted: boolean; 765 | hasYpcMetadata: boolean; 766 | viewCount: string; 767 | category: string; 768 | publishDate: string; 769 | ownerChannelName: string; 770 | liveBroadcastDetails?: { 771 | isLiveNow: boolean; 772 | startTimestamp: string; 773 | endTimestamp?: string; 774 | }; 775 | uploadDate: string; 776 | } 777 | 778 | interface storyboard { 779 | templateUrl: string; 780 | thumbnailWidth: number; 781 | thumbnailHeight: number; 782 | thumbnailCount: number; 783 | interval: number; 784 | columns: number; 785 | rows: number; 786 | storyboardCount: number; 787 | } 788 | 789 | interface Chapter { 790 | title: string; 791 | start_time: number; 792 | } 793 | 794 | interface MoreVideoDetails 795 | extends Omit, 796 | Omit { 797 | published: number; 798 | video_url: string; 799 | age_restricted: boolean; 800 | likes: number | null; 801 | media: Media; 802 | author: Author; 803 | thumbnails: thumbnail[]; 804 | storyboards: storyboard[]; 805 | chapters: Chapter[]; 806 | description: string | null; 807 | } 808 | 809 | interface videoInfo { 810 | iv_load_policy?: string; 811 | iv_allow_in_place_switch?: string; 812 | iv_endscreen_url?: string; 813 | iv_invideo_url?: string; 814 | iv3_module?: string; 815 | rmktEnabled?: string; 816 | uid?: string; 817 | vid?: string; 818 | focEnabled?: string; 819 | baseUrl?: string; 820 | storyboard_spec?: string; 821 | serialized_ad_ux_config?: string; 822 | player_error_log_fraction?: string; 823 | sffb?: string; 824 | ldpj?: string; 825 | videostats_playback_base_url?: string; 826 | innertube_context_client_version?: string; 827 | t?: string; 828 | fade_in_start_milliseconds: string; 829 | timestamp: string; 830 | ad3_module: string; 831 | relative_loudness: string; 832 | allow_below_the_player_companion: string; 833 | eventid: string; 834 | token: string; 835 | atc: string; 836 | cr: string; 837 | apply_fade_on_midrolls: string; 838 | cl: string; 839 | fexp: string[]; 840 | apiary_host: string; 841 | fade_in_duration_milliseconds: string; 842 | fflags: string; 843 | ssl: string; 844 | pltype: string; 845 | enabled_engage_types: string; 846 | hl: string; 847 | is_listed: string; 848 | gut_tag: string; 849 | apiary_host_firstparty: string; 850 | enablecsi: string; 851 | csn: string; 852 | status: string; 853 | afv_ad_tag: string; 854 | idpj: string; 855 | sfw_player_response: string; 856 | account_playback_token: string; 857 | encoded_ad_safety_reason: string; 858 | tag_for_children_directed: string; 859 | no_get_video_log: string; 860 | ppv_remarketing_url: string; 861 | fmt_list: string[][]; 862 | ad_slots: string; 863 | fade_out_duration_milliseconds: string; 864 | instream_long: string; 865 | allow_html5_ads: string; 866 | core_dbp: string; 867 | ad_device: string; 868 | itct: string; 869 | root_ve_type: string; 870 | excluded_ads: string; 871 | aftv: string; 872 | loeid: string; 873 | cver: string; 874 | shortform: string; 875 | dclk: string; 876 | csi_page_type: string; 877 | ismb: string; 878 | gpt_migration: string; 879 | loudness: string; 880 | ad_tag: string; 881 | of: string; 882 | probe_url: string; 883 | vm: string; 884 | afv_ad_tag_restricted_to_instream: string; 885 | gapi_hint_params: string; 886 | cid: string; 887 | c: string; 888 | oid: string; 889 | ptchn: string; 890 | as_launched_in_country: string; 891 | avg_rating: string; 892 | fade_out_start_milliseconds: string; 893 | midroll_prefetch_size: string; 894 | allow_ratings: string; 895 | thumbnail_url: string; 896 | iurlsd: string; 897 | iurlmq: string; 898 | iurlhq: string; 899 | iurlmaxres: string; 900 | ad_preroll: string; 901 | tmi: string; 902 | trueview: string; 903 | host_language: string; 904 | innertube_api_key: string; 905 | show_content_thumbnail: string; 906 | afv_instream_max: string; 907 | innertube_api_version: string; 908 | mpvid: string; 909 | allow_embed: string; 910 | ucid: string; 911 | plid: string; 912 | midroll_freqcap: string; 913 | ad_logging_flag: string; 914 | ptk: string; 915 | vmap: string; 916 | watermark: string[]; 917 | dbp: string; 918 | ad_flags: string; 919 | html5player: string; 920 | formats: videoFormat[]; 921 | related_videos: relatedVideo[]; 922 | no_embed_allowed?: boolean; 923 | player_response: { 924 | playabilityStatus: { 925 | status: string; 926 | playableInEmbed: boolean; 927 | miniplayer: { 928 | miniplayerRenderer: { 929 | playbackMode: string; 930 | }; 931 | }; 932 | contextParams: string; 933 | }; 934 | streamingData: { 935 | expiresInSeconds: string; 936 | formats: {}[]; 937 | adaptiveFormats: {}[]; 938 | }; 939 | captions?: { 940 | playerCaptionsRenderer: { 941 | baseUrl: string; 942 | visibility: string; 943 | }; 944 | playerCaptionsTracklistRenderer: { 945 | captionTracks: captionTrack[]; 946 | audioTracks: audioTrack[]; 947 | translationLanguages: translationLanguage[]; 948 | defaultAudioTrackIndex: number; 949 | }; 950 | }; 951 | microformat: { 952 | playerMicroformatRenderer: MicroformatRenderer; 953 | }; 954 | videoDetails: VideoDetails; 955 | playerConfig: { 956 | audioConfig: { 957 | loudnessDb: number; 958 | perceptualLoudnessDb: number; 959 | enablePerFormatLoudness: boolean; 960 | }; 961 | streamSelectionConfig: { maxBitrate: string }; 962 | mediaCommonConfig: { dynamicReadaheadConfig: {}[] }; 963 | webPlayerConfig: { webPlayerActionsPorting: {}[] }; 964 | }; 965 | }; 966 | videoDetails: MoreVideoDetails; 967 | } 968 | 969 | interface relatedVideo { 970 | id?: string; 971 | title?: string; 972 | published?: string; 973 | author: Author | "string"; // to remove the `string` part later 974 | ucid?: string; // to remove later 975 | author_thumbnail?: string; // to remove later 976 | short_view_count_text?: string; 977 | view_count?: string; 978 | length_seconds?: number; 979 | video_thumbnail?: string; // to remove later 980 | thumbnails: thumbnail[]; 981 | richThumbnails: thumbnail[]; 982 | isLive: boolean; 983 | } 984 | 985 | interface Cookie { 986 | name: string; 987 | value: string; 988 | expirationDate?: number; 989 | domain?: string; 990 | path?: string; 991 | secure?: boolean; 992 | httpOnly?: boolean; 993 | hostOnly?: boolean; 994 | sameSite?: string; 995 | } 996 | 997 | function getBasicInfo(url: string, options?: getInfoOptions): Promise; 998 | function getInfo(url: string, options?: getInfoOptions): Promise; 999 | function downloadFromInfo(info: videoInfo, options?: downloadOptions): Readable; 1000 | function chooseFormat(format: videoFormat | videoFormat[], options?: chooseFormatOptions): videoFormat | never; 1001 | function filterFormats(formats: videoFormat | videoFormat[], filter?: Filter): videoFormat[]; 1002 | function validateID(string: string): boolean; 1003 | function validateURL(string: string): boolean; 1004 | function getURLVideoID(string: string): string | never; 1005 | function getVideoID(string: string): string | never; 1006 | function createProxyAgent(options: ProxyAgent.Options | string): Agent; 1007 | function createProxyAgent(options: ProxyAgent.Options | string, cookies?: (Cookie | CK)[]): Agent; 1008 | function createAgent(): Agent; 1009 | function createAgent(cookies?: (Cookie | CK)[]): Agent; 1010 | function createAgent(cookies?: (Cookie | CK)[], opts?: CookieAgent.Options): Agent; 1011 | const version: number; 1012 | } 1013 | 1014 | function ytdl(link: string, options?: ytdl.downloadOptions): Readable; 1015 | 1016 | export = ytdl; 1017 | } 1018 | --------------------------------------------------------------------------------