├── .codecov.yml ├── .commitlintrc.json ├── .deepsource.toml ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .gitattributes ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── config.yml └── workflows │ ├── docs.yml │ ├── publish.yml │ ├── test.yml │ └── wiki.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg ├── pre-commit └── pre-push ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── package.json ├── pnpm-lock.yaml ├── src ├── DisTube.ts ├── constant.ts ├── core │ ├── DisTubeBase.ts │ ├── DisTubeHandler.ts │ ├── DisTubeOptions.ts │ ├── DisTubeStream.ts │ ├── DisTubeVoice.ts │ ├── index.ts │ └── manager │ │ ├── BaseManager.ts │ │ ├── DisTubeVoiceManager.ts │ │ ├── FilterManager.ts │ │ ├── GuildIdManager.ts │ │ ├── QueueManager.ts │ │ └── index.ts ├── index.ts ├── struct │ ├── DisTubeError.ts │ ├── ExtractorPlugin.ts │ ├── InfoExtratorPlugin.ts │ ├── PlayableExtratorPlugin.ts │ ├── Playlist.ts │ ├── Plugin.ts │ ├── Queue.ts │ ├── Song.ts │ ├── TaskQueue.ts │ └── index.ts ├── type.ts └── util.ts ├── tests ├── core │ ├── DisTubeOptions.test.ts │ └── manager │ │ ├── DisTubeVoiceManager.test.ts │ │ └── FilterManager.test.ts ├── raw │ ├── discord.ts │ └── index.ts ├── tsconfig.json └── util.test.ts ├── tsconfig.eslint.json ├── tsconfig.json ├── tsup.config.ts ├── vitest.config.mts └── wiki ├── DisTube-Guide.md ├── Frequently-Asked-Questions.md ├── Handling-Discord.js-Events.md ├── Home.md ├── Major-Upgrade-Guide.md ├── Projects-Hub.md ├── YouTube-Cookies.md └── _Footer.md /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | informational: true 6 | target: auto 7 | threshold: 100% 8 | patch: 9 | default: 10 | informational: true 11 | target: auto 12 | threshold: 100% 13 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"], 3 | "rules": { 4 | "scope-case": [1, "always", "pascal-case"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | exclude_patterns = ["tests/**"] 4 | 5 | [[analyzers]] 6 | name = "test-coverage" 7 | 8 | [[analyzers]] 9 | name = "javascript" 10 | 11 | [analyzers.meta] 12 | environment = [ 13 | "nodejs", 14 | "jest" 15 | ] -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = false 12 | insert_final_newline = false -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | coverage/ 3 | .husky/ 4 | docs/ 5 | test/ -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "project": "./tsconfig.eslint.json" 4 | }, 5 | "rules": { 6 | "valid-jsdoc": "off", 7 | "jsdoc/check-tag-names": "off" 8 | }, 9 | "extends": "distube" 10 | } 11 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.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 to DisTube 2 | 3 | Thank you for your interest in contributing to DisTube! This document provides guidelines and instructions for contributing to the project. 4 | 5 | ## 📝 Issues 6 | 7 | - The issue tracker is for bug reports only. 8 | - For questions or feature suggestions, please use our [Discord Support Server](https://discord.gg/feaDd9h). 9 | - Before creating an issue, please check the [FAQ](https://github.com/skick1234/DisTube/wiki/Frequently-Asked-Questions) and existing issues (both open and closed). 10 | 11 | ## 🛠️ Pull Requests 12 | 13 | We welcome contributions via pull requests! Please follow these guidelines: 14 | 15 | 1. **Fork the repository** and create a new branch from `main` for your changes: 16 | 17 | ```bash 18 | git checkout -b feature/your-feature-name 19 | ``` 20 | 21 | 2. **Install dependencies:** 22 | 23 | ```bash 24 | npm ci 25 | ``` 26 | 27 | 3. **Make your changes**, ensuring they align with the project's goals and coding style. 28 | 29 | 4. **Run checks:** Before submitting, ensure your code passes the following checks: 30 | 31 | ```bash 32 | npm run prettier # Format code 33 | npm run lint # Check for linting errors 34 | npm run build:check # Ensure the project compiles 35 | npm run test # Run tests 36 | ``` 37 | 38 | 5. **Commit your changes** using the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) format: 39 | 40 | ```bash 41 | git commit -m "feat: add new feature" 42 | ``` 43 | 44 | 6. **Create a pull request:** Push your branch to your fork and open a pull request against the `main` branch of the DisTube repository. 45 | 46 | ### Pull Request Guidelines 47 | 48 | - Keep pull requests focused on a single issue or feature. 49 | - Include relevant tests for your changes. 50 | - Update documentation (if applicable). 51 | - Follow the project's existing code style. 52 | - Ensure all tests pass before submitting. 53 | - Link any related issues in the pull request description. 54 | 55 | ## ⚙️ Development Setup 56 | 57 | DisTube is built with TypeScript and uses the following tools: 58 | 59 | - [pnpm](https://pnpm.io/): Package manager (though npm is also supported) 60 | - [Prettier](https://prettier.io/): Code formatter 61 | - [ESLint](https://eslint.org/): Linter 62 | - [Vitest](https://vitest.dev/): Test runner 63 | - [TypeDoc](https://typedoc.org/): Documentation generator 64 | 65 | ## ⚖️ License 66 | 67 | By contributing to DisTube, you agree that your contributions will be licensed under the [MIT License](LICENSE). 68 | 69 | ## ❓ Support 70 | 71 | If you have any questions or need help, please join our [Discord Support Server](https://discord.gg/feaDd9h). 72 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: skick 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve (Check the FAQ and created issues first) 4 | title: "[BUG] Short description of the bug" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Play '....' 17 | 3. Run command '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Environment Information:** 27 | 28 | - DisTube version: (See line 3 of [package.json](https://github.com/skick1234/DisTube/blob/main/package.json)) 29 | - discord.js version: (See line 79 of [package.json](https://github.com/skick1234/DisTube/blob/main/package.json)) 30 | - Node.js version: (`node -v`) 31 | - Operating system: 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Frequently Asked Questions 4 | url: https://github.com/skick1234/DisTube/wiki/Frequently-Asked-Questions 5 | about: DisTube Frequently Asked Questions 6 | - name: Discord Support Server 7 | url: https://discord.gg/feaDd9h 8 | about: Suggest an idea for this project or ask a question here on Discord 9 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Documentation Generator 2 | on: 3 | push: 4 | branches: [main] 5 | paths: 6 | - src/** 7 | - tsconfig.json 8 | - .github/workflows/docs.yml 9 | jobs: 10 | docs: 11 | name: Documentation 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v4 16 | 17 | - name: Install pnpm 18 | uses: pnpm/action-setup@v3 19 | with: 20 | version: latest 21 | 22 | - name: Install Node.js 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: 20 26 | cache: "pnpm" 27 | 28 | - name: Install dependencies 29 | run: pnpm install --frozen-lockfile 30 | 31 | - name: Checkout docs branch 32 | uses: actions/checkout@v4 33 | with: 34 | repository: distubejs/distubejs.github.io 35 | ref: gh-pages 36 | path: gh-pages 37 | token: ${{ secrets.DISTUBE_TOKEN }} 38 | 39 | - name: Delete old docs 40 | run: find gh-pages -mindepth 1 ! -regex 'gh-pages/\.git.*' ! -name 'CNAME' -exec rm -rf {} + 41 | 42 | - name: Generate documentation 43 | run: pnpm run docs 44 | 45 | - name: Commit and push 46 | run: | 47 | rsync -av docs/ gh-pages/ 48 | cd gh-pages 49 | git config user.name github-actions[bot] 50 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 51 | git add . 52 | git commit -m "${{ github.repository }}@${{ github.sha }} 🚀" 53 | git push 54 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish distube 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | publish: 7 | name: Build & Publish 8 | runs-on: ubuntu-latest 9 | permissions: 10 | id-token: write 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v4 14 | 15 | - name: Install pnpm 16 | uses: pnpm/action-setup@v3 17 | with: 18 | version: latest 19 | 20 | - name: Install Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: 20 24 | cache: "pnpm" 25 | registry-url: "https://registry.npmjs.org" 26 | 27 | - name: Install dependencies 28 | run: pnpm install --ignore-scripts --frozen-lockfile 29 | 30 | - name: Publish 31 | run: | 32 | pnpm publish --access public --no-git-checks 33 | pnpm deprecate distube@"< ${{ github.event.release.tag_name }}" "This version is deprecated, please upgrade to the latest version." 34 | env: 35 | NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 36 | NPM_CONFIG_PROVENANCE: true 37 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - "main" 6 | pull_request: 7 | branches: 8 | - "main" 9 | jobs: 10 | test: 11 | name: Test on node v${{ matrix.node-version }} (${{ matrix.os }}) 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | node-version: [18, 20] 16 | os: [ubuntu-latest] 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | 21 | - name: Install pnpm 22 | uses: pnpm/action-setup@v3 23 | with: 24 | version: latest 25 | 26 | - name: Install Node.js ${{ matrix.node-version }} 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: ${{ matrix.node-version }} 30 | cache: "pnpm" 31 | 32 | - name: Install dependencies 33 | run: pnpm install --frozen-lockfile 34 | 35 | - name: Linting 36 | run: pnpm run lint 37 | 38 | - name: Run type check 39 | run: pnpm run type 40 | 41 | - name: Run tests 42 | run: pnpm run test 43 | 44 | - name: Upload Coverage 45 | uses: codecov/codecov-action@v3 46 | 47 | - name: Report results to DeepSource 48 | run: | 49 | curl https://deepsource.io/cli | sh 50 | ./bin/deepsource report --analyzer test-coverage --key javascript --value-file ./coverage/cobertura-coverage.xml 51 | env: 52 | DEEPSOURCE_DSN: ${{ secrets.DEEPSOURCE_DSN }} 53 | -------------------------------------------------------------------------------- /.github/workflows/wiki.yml: -------------------------------------------------------------------------------- 1 | name: Wiki 2 | on: 3 | push: 4 | branches: [main] 5 | paths: 6 | - wiki/** 7 | - .github/workflows/wiki.yml 8 | permissions: 9 | contents: write 10 | jobs: 11 | wiki: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: Andrew-Chen-Wang/github-wiki-action@v4 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Coverage directory 2 | coverage 3 | 4 | # Compiled folder 5 | dist 6 | 7 | # Dependency directories 8 | node_modules 9 | 10 | # Output of 'npm pack' 11 | *.tgz 12 | 13 | /docs -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | pnpm exec commitlint --edit -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm exec nano-staged -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | pnpm install --frozen-lockfile 2 | pnpm run lint 3 | pnpm run type 4 | pnpm run test -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | dist-docs/ 3 | docs/ 4 | coverage/ 5 | pnpm-lock.yaml -------------------------------------------------------------------------------- /.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) 2020 Skick 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 |

5 |

6 | GitHub Workflow Status 7 | node-current 8 | npm peer dependency version 9 | Codecov branch 10 |
11 | npm 12 | GitHub Repo stars 13 | Discord 14 |

15 |

16 | Buy Me a Coffee at ko-fi.com 17 |

18 |
19 | 20 | # DisTube 21 | 22 | DisTube is a comprehensive Discord music bot library built for Discord.js, offering simplified music commands, effortless playback from diverse sources, and integrated audio filters. 23 | 24 | ## 🌟 Features 25 | 26 | - **Easy Integration**: Built on top of [discord.js](https://discord.js.org) v14 and [@discordjs/voice](https://discord.js.org) 27 | - **Voice Management**: Robust handling of voice connections and queue management 28 | - **Audio Filters**: Built-in filters (bassboost, echo, karaoke, etc.) and custom filter support 29 | - **Plugin System**: Extensible architecture supporting various music sources through plugins 30 | - **Type Safety**: Written in TypeScript for better development experience 31 | - **Active Community**: Join our [Discord Support Server](https://discord.gg/feaDd9h) for help 32 | 33 | ## 📋 Requirements 34 | 35 | - Node.js 18.17.0 or higher 36 | - [discord.js](https://discord.js.org) v14 37 | - [@discordjs/voice](https://github.com/discordjs/voice) 38 | - [@discordjs/opus](https://github.com/discordjs/opus) 39 | - [FFmpeg](https://www.ffmpeg.org/download.html) 40 | 41 | ### 🔒 Encryption Libraries 42 | 43 | > [!NOTE] 44 | > You only need to install one of these libraries if your system does not support `aes-256-gcm` (verify by running `require('node:crypto').getCiphers().includes('aes-256-gcm')`). 45 | 46 | - [@noble/ciphers](https://www.npmjs.com/package/@noble/ciphers) 47 | - [sodium-native](https://www.npmjs.com/package/sodium-native) 48 | 49 | ## 🚀 Installation 50 | 51 | ```bash 52 | npm install distube @discordjs/voice @discordjs/opus 53 | ``` 54 | 55 | For FFmpeg installation: 56 | 57 | - [Windows Guide](http://blog.gregzaal.com/how-to-install-ffmpeg-on-windows/) 58 | - [Linux Guide](https://www.tecmint.com/install-ffmpeg-in-linux/) 59 | 60 | > [!NOTE] 61 | > Alternative FFmpeg builds available [here](https://github.com/BtbN/FFmpeg-Builds/releases) 62 | 63 | ## 📚 Documentation 64 | 65 | - [API Documentation](https://distube.js.org/) - Detailed API reference 66 | - [DisTube Guide](https://github.com/skick1234/DisTube/wiki) - Step-by-step guide for beginners 67 | - [Plugin List](https://github.com/skick1234/DisTube/wiki/Projects-Hub#plugins) - Available plugins for music sources 68 | 69 | ## 🤝 Contributing 70 | 71 | Contributions are welcome! Please read our [Contributing Guidelines](https://github.com/skick1234/DisTube/blob/main/.github/CONTRIBUTING.md) before submitting a pull request. 72 | 73 | ## 📄 License 74 | 75 | Licensed under [MIT License](https://github.com/skick1234/DisTube/blob/main/LICENSE) 76 | 77 | ## 💖 Support 78 | 79 | Support me on Ko-fi 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "distube", 3 | "version": "5.0.7", 4 | "description": "A powerful Discord.js module for simplifying music commands and effortless playback of various sources with integrated audio filters.", 5 | "main": "./dist/index.js", 6 | "module": "./dist/index.mjs", 7 | "types": "./dist/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "require": { 11 | "types": "./dist/index.d.ts", 12 | "default": "./dist/index.js" 13 | }, 14 | "import": { 15 | "types": "./dist/index.d.mts", 16 | "default": "./dist/index.mjs" 17 | } 18 | } 19 | }, 20 | "directories": { 21 | "lib": "src", 22 | "test": "tests" 23 | }, 24 | "files": [ 25 | "dist" 26 | ], 27 | "scripts": { 28 | "test": "vitest run", 29 | "docs": "typedoc", 30 | "lint": "prettier --check . && eslint .", 31 | "lint:fix": "eslint . --fix", 32 | "prettier": "prettier --write \"**/*.{ts,json,yml,yaml,md}\"", 33 | "build": "tsup", 34 | "type": "tsc --noEmit", 35 | "update": "pnpm up -L \"!eslint\"", 36 | "prepare": "husky", 37 | "prepublishOnly": "pnpm run lint && pnpm run test", 38 | "prepack": "pnpm run build" 39 | }, 40 | "repository": { 41 | "type": "git", 42 | "url": "git+https://github.com/skick1234/DisTube.git" 43 | }, 44 | "keywords": [ 45 | "youtube", 46 | "music", 47 | "discord", 48 | "discordjs", 49 | "bot", 50 | "distube", 51 | "queue", 52 | "musicbot", 53 | "discord-music-bot", 54 | "music-bot", 55 | "discord-js" 56 | ], 57 | "author": "Skick (https://github.com/skick1234)", 58 | "license": "MIT", 59 | "bugs": { 60 | "url": "https://github.com/skick1234/DisTube/issues" 61 | }, 62 | "funding": "https://github.com/skick1234/DisTube?sponsor", 63 | "homepage": "https://distube.js.org/", 64 | "dependencies": { 65 | "tiny-typed-emitter": "^2.1.0", 66 | "undici": "^7.7.0" 67 | }, 68 | "devDependencies": { 69 | "@commitlint/cli": "^19.8.0", 70 | "@commitlint/config-conventional": "^19.8.0", 71 | "@discordjs/opus": "^0.10.0", 72 | "@discordjs/voice": "^0.18.0", 73 | "@types/node": "^22.14.0", 74 | "@types/tough-cookie": "^4.0.5", 75 | "@typescript-eslint/eslint-plugin": "^7.18.0", 76 | "@typescript-eslint/parser": "^7.18.0", 77 | "@vitest/coverage-v8": "^3.1.1", 78 | "discord-api-types": "^0.37.119", 79 | "discord.js": "^14.18.0", 80 | "esbuild-plugin-version-injector": "^1.2.1", 81 | "eslint": "^8.57.1", 82 | "eslint-config-distube": "^1.7.1", 83 | "husky": "^9.1.7", 84 | "nano-staged": "^0.8.0", 85 | "prettier": "^3.5.3", 86 | "tsup": "^8.4.0", 87 | "typedoc": "^0.27.9", 88 | "typedoc-material-theme": "^1.3.0", 89 | "typedoc-plugin-extras": "^4.0.0", 90 | "typescript": "^5.8.3", 91 | "vite-tsconfig-paths": "^5.1.4", 92 | "vitest": "^3.1.1" 93 | }, 94 | "peerDependencies": { 95 | "@discordjs/voice": "*", 96 | "discord.js": "14" 97 | }, 98 | "nano-staged": { 99 | "*.ts": [ 100 | "prettier --write", 101 | "eslint" 102 | ], 103 | "*.{json,yml,yaml,md}": [ 104 | "prettier --write" 105 | ] 106 | }, 107 | "engines": { 108 | "node": ">=18.17" 109 | }, 110 | "pnpm": { 111 | "onlyBuiltDependencies": [ 112 | "esbuild" 113 | ] 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/DisTube.ts: -------------------------------------------------------------------------------- 1 | import { TypedEmitter } from "tiny-typed-emitter"; 2 | import { 3 | DisTubeError, 4 | DisTubeHandler, 5 | DisTubeVoiceManager, 6 | Events, 7 | Options, 8 | Playlist, 9 | QueueManager, 10 | Song, 11 | checkIntents, 12 | defaultFilters, 13 | isClientInstance, 14 | isMemberInstance, 15 | isMessageInstance, 16 | isNsfwChannel, 17 | isObject, 18 | isSupportedVoiceChannel, 19 | isTextChannelInstance, 20 | isURL, 21 | version, 22 | } from "."; 23 | import type { Client, VoiceBasedChannel } from "discord.js"; 24 | import type { 25 | Awaitable, 26 | CustomPlaylistOptions, 27 | DisTubeOptions, 28 | DisTubePlugin, 29 | Filters, 30 | GuildIdResolvable, 31 | PlayOptions, 32 | Queue, 33 | RepeatMode, 34 | TypedDisTubeEvents, 35 | } from "."; 36 | 37 | /** 38 | * DisTube class 39 | */ 40 | export class DisTube extends TypedEmitter { 41 | /** 42 | * @event 43 | * Emitted after DisTube add a new playlist to the playing {@link Queue}. 44 | * @param queue - The guild queue 45 | * @param playlist - Playlist info 46 | */ 47 | static readonly [Events.ADD_LIST]: (queue: Queue, playlist: Playlist) => Awaitable; 48 | /** 49 | * @event 50 | * Emitted after DisTube add a new song to the playing {@link Queue}. 51 | * @param queue - The guild queue 52 | * @param song - Added song 53 | */ 54 | static readonly [Events.ADD_SONG]: (queue: Queue, song: Song) => Awaitable; 55 | /** 56 | * @event 57 | * Emitted when a {@link Queue} is deleted with any reasons. 58 | * @param queue - The guild queue 59 | */ 60 | static readonly [Events.DELETE_QUEUE]: (queue: Queue) => Awaitable; 61 | /** 62 | * @event 63 | * Emitted when the bot is disconnected to a voice channel. 64 | * @param queue - The guild queue 65 | */ 66 | static readonly [Events.DISCONNECT]: (queue: Queue) => Awaitable; 67 | /** 68 | * @event 69 | * Emitted when DisTube encounters an error while playing songs. 70 | * @param error - error 71 | * @param queue - The queue encountered the error 72 | * @param song - The playing song when encountered the error 73 | */ 74 | static readonly [Events.ERROR]: (error: Error, queue: Queue, song?: Song) => Awaitable; 75 | /** 76 | * @event 77 | * Emitted for logging FFmpeg debug information. 78 | * @param debug - Debug message string. 79 | */ 80 | static readonly [Events.FFMPEG_DEBUG]: (debug: string) => Awaitable; 81 | /** 82 | * @event 83 | * Emitted to provide debug information from DisTube's operation. 84 | * Useful for troubleshooting or logging purposes. 85 | * 86 | * @param debug - Debug message string. 87 | */ 88 | static readonly [Events.DEBUG]: (debug: string) => Awaitable; 89 | /** 90 | * @event 91 | * Emitted when there is no more song in the queue and {@link Queue#autoplay} is `false`. 92 | * @param queue - The guild queue 93 | */ 94 | static readonly [Events.FINISH]: (queue: Queue) => Awaitable; 95 | /** 96 | * @event 97 | * Emitted when DisTube finished a song. 98 | * @param queue - The guild queue 99 | * @param song - Finished song 100 | */ 101 | static readonly [Events.FINISH_SONG]: (queue: Queue, song: Song) => Awaitable; 102 | /** 103 | * @event 104 | * Emitted when DisTube initialize a queue to change queue default properties. 105 | * @param queue - The guild queue 106 | */ 107 | static readonly [Events.INIT_QUEUE]: (queue: Queue) => Awaitable; 108 | /** 109 | * @event 110 | * Emitted when {@link Queue#autoplay} is `true`, {@link Queue#songs} is empty, and 111 | * DisTube cannot find related songs to play. 112 | * @param queue - The guild queue 113 | */ 114 | static readonly [Events.NO_RELATED]: (queue: Queue) => Awaitable; 115 | /** 116 | * @event 117 | * Emitted when DisTube play a song. 118 | * If {@link DisTubeOptions}.emitNewSongOnly is `true`, this event is not emitted 119 | * when looping a song or next song is the previous one. 120 | * @param queue - The guild queue 121 | * @param song - Playing song 122 | */ 123 | static readonly [Events.PLAY_SONG]: (queue: Queue, song: Song) => Awaitable; 124 | /** 125 | * DisTube internal handler 126 | */ 127 | readonly handler: DisTubeHandler; 128 | /** 129 | * DisTube options 130 | */ 131 | readonly options: Options; 132 | /** 133 | * Discord.js v14 client 134 | */ 135 | readonly client: Client; 136 | /** 137 | * Queues manager 138 | */ 139 | readonly queues: QueueManager; 140 | /** 141 | * DisTube voice connections manager 142 | */ 143 | readonly voices: DisTubeVoiceManager; 144 | /** 145 | * DisTube plugins 146 | */ 147 | readonly plugins: DisTubePlugin[]; 148 | /** 149 | * DisTube ffmpeg audio filters 150 | */ 151 | readonly filters: Filters; 152 | /** 153 | * Create a new DisTube class. 154 | * @throws {@link DisTubeError} 155 | * @param client - Discord.JS client 156 | * @param opts - Custom DisTube options 157 | */ 158 | constructor(client: Client, opts: DisTubeOptions = {}) { 159 | super(); 160 | this.setMaxListeners(1); 161 | if (!isClientInstance(client)) throw new DisTubeError("INVALID_TYPE", "Discord.Client", client, "client"); 162 | this.client = client; 163 | checkIntents(client.options); 164 | this.options = new Options(opts); 165 | this.voices = new DisTubeVoiceManager(this); 166 | this.handler = new DisTubeHandler(this); 167 | this.queues = new QueueManager(this); 168 | this.filters = { ...defaultFilters, ...this.options.customFilters }; 169 | this.plugins = [...this.options.plugins]; 170 | this.plugins.forEach(p => p.init(this)); 171 | } 172 | 173 | static get version() { 174 | return version; 175 | } 176 | 177 | /** 178 | * DisTube version 179 | */ 180 | get version() { 181 | return version; 182 | } 183 | 184 | /** 185 | * Play / add a song or playlist from url. 186 | * Search and play a song (with {@link ExtractorPlugin}) if it is not a valid url. 187 | * @throws {@link DisTubeError} 188 | * @param voiceChannel - The channel will be joined if the bot isn't in any channels, the bot will be 189 | * moved to this channel if {@link DisTubeOptions}.joinNewVoiceChannel is `true` 190 | * @param song - URL | Search string | {@link Song} | {@link Playlist} 191 | * @param options - Optional options 192 | */ 193 | async play( 194 | voiceChannel: VoiceBasedChannel, 195 | song: string | Song | Playlist, 196 | options: PlayOptions = {}, 197 | ): Promise { 198 | if (!isSupportedVoiceChannel(voiceChannel)) { 199 | throw new DisTubeError("INVALID_TYPE", "BaseGuildVoiceChannel", voiceChannel, "voiceChannel"); 200 | } 201 | if (!isObject(options)) throw new DisTubeError("INVALID_TYPE", "object", options, "options"); 202 | 203 | const { textChannel, member, skip, message, metadata } = { 204 | member: voiceChannel.guild.members.me ?? undefined, 205 | textChannel: options?.message?.channel, 206 | skip: false, 207 | ...options, 208 | }; 209 | const position = Number(options.position) || (skip ? 1 : 0); 210 | 211 | if (message && !isMessageInstance(message)) { 212 | throw new DisTubeError("INVALID_TYPE", ["Discord.Message", "a falsy value"], message, "options.message"); 213 | } 214 | if (textChannel && !isTextChannelInstance(textChannel)) { 215 | throw new DisTubeError("INVALID_TYPE", "Discord.GuildTextBasedChannel", textChannel, "options.textChannel"); 216 | } 217 | if (member && !isMemberInstance(member)) { 218 | throw new DisTubeError("INVALID_TYPE", "Discord.GuildMember", member, "options.member"); 219 | } 220 | 221 | const queue = this.getQueue(voiceChannel) || (await this.queues.create(voiceChannel, textChannel)); 222 | await queue._taskQueue.queuing(true); 223 | try { 224 | this.debug(`[${queue.id}] Playing input: ${song}`); 225 | const resolved = await this.handler.resolve(song, { member, metadata }); 226 | const isNsfw = isNsfwChannel(queue?.textChannel || textChannel); 227 | if (resolved instanceof Playlist) { 228 | if (!this.options.nsfw && !isNsfw) { 229 | resolved.songs = resolved.songs.filter(s => !s.ageRestricted); 230 | if (!resolved.songs.length) throw new DisTubeError("EMPTY_FILTERED_PLAYLIST"); 231 | } 232 | if (!resolved.songs.length) throw new DisTubeError("EMPTY_PLAYLIST"); 233 | this.debug(`[${queue.id}] Adding playlist to queue: ${resolved.songs.length} songs`); 234 | queue.addToQueue(resolved.songs, position); 235 | if (queue.playing || this.options.emitAddListWhenCreatingQueue) this.emit(Events.ADD_LIST, queue, resolved); 236 | } else { 237 | if (!this.options.nsfw && resolved.ageRestricted && !isNsfwChannel(queue?.textChannel || textChannel)) { 238 | throw new DisTubeError("NON_NSFW"); 239 | } 240 | this.debug(`[${queue.id}] Adding song to queue: ${resolved.name || resolved.url || resolved.id || resolved}`); 241 | queue.addToQueue(resolved, position); 242 | if (queue.playing || this.options.emitAddSongWhenCreatingQueue) this.emit(Events.ADD_SONG, queue, resolved); 243 | } 244 | 245 | if (!queue.playing) await queue.play(); 246 | else if (skip) await queue.skip(); 247 | } catch (e: any) { 248 | if (!(e instanceof DisTubeError)) { 249 | this.debug(`[${queue.id}] Unexpected error while playing song: ${e.stack || e.message}`); 250 | try { 251 | e.name = "PlayError"; 252 | e.message = `${typeof song === "string" ? song : song.url}\n${e.message}`; 253 | } catch { 254 | // Throw original error 255 | } 256 | } 257 | throw e; 258 | } finally { 259 | if (!queue.songs.length && !queue._taskQueue.hasPlayTask) queue.remove(); 260 | queue._taskQueue.resolve(); 261 | } 262 | } 263 | 264 | /** 265 | * Create a custom playlist 266 | * @param songs - Array of url or Song 267 | * @param options - Optional options 268 | */ 269 | async createCustomPlaylist( 270 | songs: (string | Song)[], 271 | { member, parallel, metadata, name, source, url, thumbnail }: CustomPlaylistOptions = {}, 272 | ): Promise { 273 | if (!Array.isArray(songs)) throw new DisTubeError("INVALID_TYPE", "Array", songs, "songs"); 274 | if (!songs.length) throw new DisTubeError("EMPTY_ARRAY", "songs"); 275 | const filteredSongs = songs.filter(song => song instanceof Song || isURL(song)); 276 | if (!filteredSongs.length) throw new DisTubeError("NO_VALID_SONG"); 277 | if (member && !isMemberInstance(member)) { 278 | throw new DisTubeError("INVALID_TYPE", "Discord.Member", member, "options.member"); 279 | } 280 | let resolvedSongs: Song[]; 281 | if (parallel !== false) { 282 | const promises = filteredSongs.map((song: string | Song) => 283 | this.handler.resolve(song, { member, metadata }).catch(() => undefined), 284 | ); 285 | resolvedSongs = (await Promise.all(promises)).filter((s): s is Song => s instanceof Song); 286 | } else { 287 | resolvedSongs = []; 288 | for (const song of filteredSongs) { 289 | const resolved = await this.handler.resolve(song, { member, metadata }).catch(() => undefined); 290 | if (resolved instanceof Song) resolvedSongs.push(resolved); 291 | } 292 | } 293 | return new Playlist( 294 | { 295 | source: source || "custom", 296 | name, 297 | url, 298 | thumbnail: thumbnail || resolvedSongs.find(s => s.thumbnail)?.thumbnail, 299 | songs: resolvedSongs, 300 | }, 301 | { member, metadata }, 302 | ); 303 | } 304 | 305 | /** 306 | * Get the guild queue 307 | * @param guild - The type can be resolved to give a {@link Queue} 308 | */ 309 | getQueue(guild: GuildIdResolvable): Queue | undefined { 310 | return this.queues.get(guild); 311 | } 312 | 313 | #getQueue(guild: GuildIdResolvable): Queue { 314 | const queue = this.getQueue(guild); 315 | if (!queue) throw new DisTubeError("NO_QUEUE"); 316 | return queue; 317 | } 318 | 319 | /** 320 | * Pause the guild stream 321 | * @param guild - The type can be resolved to give a {@link Queue} 322 | * @returns The guild queue 323 | */ 324 | pause(guild: GuildIdResolvable): Promise { 325 | return this.#getQueue(guild).pause(); 326 | } 327 | 328 | /** 329 | * Resume the guild stream 330 | * @param guild - The type can be resolved to give a {@link Queue} 331 | * @returns The guild queue 332 | */ 333 | resume(guild: GuildIdResolvable): Promise { 334 | return this.#getQueue(guild).resume(); 335 | } 336 | 337 | /** 338 | * Stop the guild stream 339 | * @param guild - The type can be resolved to give a {@link Queue} 340 | */ 341 | stop(guild: GuildIdResolvable): Promise { 342 | return this.#getQueue(guild).stop(); 343 | } 344 | 345 | /** 346 | * Set the guild stream's volume 347 | * @param guild - The type can be resolved to give a {@link Queue} 348 | * @param percent - The percentage of volume you want to set 349 | * @returns The guild queue 350 | */ 351 | setVolume(guild: GuildIdResolvable, percent: number): Queue { 352 | return this.#getQueue(guild).setVolume(percent); 353 | } 354 | 355 | /** 356 | * Skip the playing song if there is a next song in the queue. If {@link 357 | * Queue#autoplay} is `true` and there is no up next song, DisTube will add and 358 | * play a related song. 359 | * @param guild - The type can be resolved to give a {@link Queue} 360 | * @returns The new Song will be played 361 | */ 362 | skip(guild: GuildIdResolvable): Promise { 363 | return this.#getQueue(guild).skip(); 364 | } 365 | 366 | /** 367 | * Play the previous song 368 | * @param guild - The type can be resolved to give a {@link Queue} 369 | * @returns The new Song will be played 370 | */ 371 | previous(guild: GuildIdResolvable): Promise { 372 | return this.#getQueue(guild).previous(); 373 | } 374 | 375 | /** 376 | * Shuffle the guild queue songs 377 | * @param guild - The type can be resolved to give a {@link Queue} 378 | * @returns The guild queue 379 | */ 380 | shuffle(guild: GuildIdResolvable): Promise { 381 | return this.#getQueue(guild).shuffle(); 382 | } 383 | 384 | /** 385 | * Jump to the song number in the queue. The next one is 1, 2,... The previous one 386 | * is -1, -2,... 387 | * @param guild - The type can be resolved to give a {@link Queue} 388 | * @param num - The song number to play 389 | * @returns The new Song will be played 390 | */ 391 | jump(guild: GuildIdResolvable, num: number): Promise { 392 | return this.#getQueue(guild).jump(num); 393 | } 394 | 395 | /** 396 | * Set the repeat mode of the guild queue. 397 | * Toggle mode `(Disabled -> Song -> Queue -> Disabled ->...)` if `mode` is `undefined` 398 | * @param guild - The type can be resolved to give a {@link Queue} 399 | * @param mode - The repeat modes (toggle if `undefined`) 400 | * @returns The new repeat mode 401 | */ 402 | setRepeatMode(guild: GuildIdResolvable, mode?: RepeatMode): RepeatMode { 403 | return this.#getQueue(guild).setRepeatMode(mode); 404 | } 405 | 406 | /** 407 | * Toggle autoplay mode 408 | * @param guild - The type can be resolved to give a {@link Queue} 409 | * @returns Autoplay mode state 410 | */ 411 | toggleAutoplay(guild: GuildIdResolvable): boolean { 412 | const queue = this.#getQueue(guild); 413 | queue.autoplay = !queue.autoplay; 414 | return queue.autoplay; 415 | } 416 | 417 | /** 418 | * Add related song to the queue 419 | * @param guild - The type can be resolved to give a {@link Queue} 420 | * @returns The guild queue 421 | */ 422 | addRelatedSong(guild: GuildIdResolvable): Promise { 423 | return this.#getQueue(guild).addRelatedSong(); 424 | } 425 | 426 | /** 427 | * Set the playing time to another position 428 | * @param guild - The type can be resolved to give a {@link Queue} 429 | * @param time - Time in seconds 430 | * @returns Seeked queue 431 | */ 432 | seek(guild: GuildIdResolvable, time: number): Queue { 433 | return this.#getQueue(guild).seek(time); 434 | } 435 | 436 | /** 437 | * Emit error event 438 | * @param error - error 439 | * @param queue - The queue encountered the error 440 | * @param song - The playing song when encountered the error 441 | */ 442 | emitError(error: Error, queue: Queue, song?: Song): void { 443 | this.emit(Events.ERROR, error, queue, song); 444 | } 445 | 446 | /** 447 | * Emit debug event 448 | * @param message - debug message 449 | */ 450 | debug(message: string) { 451 | this.emit(Events.DEBUG, message); 452 | } 453 | } 454 | 455 | export default DisTube; 456 | -------------------------------------------------------------------------------- /src/constant.ts: -------------------------------------------------------------------------------- 1 | import type { DisTubeOptions, Filters } from "."; 2 | 3 | /** 4 | * Default DisTube audio filters. 5 | */ 6 | export const defaultFilters: Filters = { 7 | "3d": "apulsator=hz=0.125", 8 | bassboost: "bass=g=10", 9 | echo: "aecho=0.8:0.9:1000:0.3", 10 | flanger: "flanger", 11 | gate: "agate", 12 | haas: "haas", 13 | karaoke: "stereotools=mlev=0.1", 14 | nightcore: "asetrate=48000*1.25,aresample=48000,bass=g=5", 15 | reverse: "areverse", 16 | vaporwave: "asetrate=48000*0.8,aresample=48000,atempo=1.1", 17 | mcompand: "mcompand", 18 | phaser: "aphaser", 19 | tremolo: "tremolo", 20 | surround: "surround", 21 | earwax: "earwax", 22 | }; 23 | 24 | export const defaultOptions = { 25 | plugins: [], 26 | emitNewSongOnly: false, 27 | savePreviousSongs: true, 28 | nsfw: false, 29 | emitAddSongWhenCreatingQueue: true, 30 | emitAddListWhenCreatingQueue: true, 31 | joinNewVoiceChannel: true, 32 | } satisfies DisTubeOptions; 33 | -------------------------------------------------------------------------------- /src/core/DisTubeBase.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from "discord.js"; 2 | import type { 3 | DisTube, 4 | DisTubeEvents, 5 | DisTubeHandler, 6 | DisTubePlugin, 7 | DisTubeVoiceManager, 8 | Options, 9 | Queue, 10 | QueueManager, 11 | Song, 12 | } from ".."; 13 | 14 | export abstract class DisTubeBase { 15 | distube: DisTube; 16 | constructor(distube: DisTube) { 17 | /** 18 | * DisTube 19 | */ 20 | this.distube = distube; 21 | } 22 | /** 23 | * Emit the {@link DisTube} of this base 24 | * @param eventName - Event name 25 | * @param args - arguments 26 | */ 27 | emit(eventName: keyof DisTubeEvents, ...args: any): boolean { 28 | return this.distube.emit(eventName, ...args); 29 | } 30 | /** 31 | * Emit error event 32 | * @param error - error 33 | * @param queue - The queue encountered the error 34 | * @param song - The playing song when encountered the error 35 | */ 36 | emitError(error: Error, queue: Queue, song?: Song) { 37 | this.distube.emitError(error, queue, song); 38 | } 39 | /** 40 | * Emit debug event 41 | * @param message - debug message 42 | */ 43 | debug(message: string) { 44 | this.distube.debug(message); 45 | } 46 | /** 47 | * The queue manager 48 | */ 49 | get queues(): QueueManager { 50 | return this.distube.queues; 51 | } 52 | /** 53 | * The voice manager 54 | */ 55 | get voices(): DisTubeVoiceManager { 56 | return this.distube.voices; 57 | } 58 | /** 59 | * Discord.js client 60 | */ 61 | get client(): Client { 62 | return this.distube.client; 63 | } 64 | /** 65 | * DisTube options 66 | */ 67 | get options(): Options { 68 | return this.distube.options; 69 | } 70 | /** 71 | * DisTube handler 72 | */ 73 | get handler(): DisTubeHandler { 74 | return this.distube.handler; 75 | } 76 | /** 77 | * DisTube plugins 78 | */ 79 | get plugins(): DisTubePlugin[] { 80 | return this.distube.plugins; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/core/DisTubeHandler.ts: -------------------------------------------------------------------------------- 1 | import { DisTubeBase } from "."; 2 | import { request } from "undici"; 3 | import { DisTubeError, Playlist, PluginType, Song, isURL } from ".."; 4 | import type { DisTubePlugin, ResolveOptions } from ".."; 5 | 6 | const REDIRECT_CODES = new Set([301, 302, 303, 307, 308]); 7 | 8 | /** 9 | * DisTube's Handler 10 | */ 11 | export class DisTubeHandler extends DisTubeBase { 12 | resolve(song: Song, options?: Omit): Promise>; 13 | resolve(song: Playlist, options?: Omit): Promise>; 14 | resolve(song: string, options?: ResolveOptions): Promise | Playlist>; 15 | resolve(song: Song, options: ResolveOptions): Promise>; 16 | resolve(song: Playlist, options: ResolveOptions): Promise>; 17 | resolve(song: string | Song | Playlist, options?: ResolveOptions): Promise; 18 | /** 19 | * Resolve a url or a supported object to a {@link Song} or {@link Playlist} 20 | * @throws {@link DisTubeError} 21 | * @param input - Resolvable input 22 | * @param options - Optional options 23 | * @returns Resolved 24 | */ 25 | async resolve(input: string | Song | Playlist, options: ResolveOptions = {}): Promise { 26 | if (input instanceof Song || input instanceof Playlist) { 27 | if ("metadata" in options) input.metadata = options.metadata; 28 | if ("member" in options) input.member = options.member; 29 | return input; 30 | } 31 | if (typeof input === "string") { 32 | if (isURL(input)) { 33 | const plugin = 34 | (await this._getPluginFromURL(input)) || 35 | (await this._getPluginFromURL((input = await this.followRedirectLink(input)))); 36 | if (!plugin) throw new DisTubeError("NOT_SUPPORTED_URL"); 37 | this.debug(`[${plugin.constructor.name}] Resolving from url: ${input}`); 38 | return plugin.resolve(input, options); 39 | } 40 | try { 41 | const song = await this.#searchSong(input, options); 42 | if (song) return song; 43 | } catch { 44 | throw new DisTubeError("NO_RESULT", input); 45 | } 46 | } 47 | throw new DisTubeError("CANNOT_RESOLVE_SONG", input); 48 | } 49 | 50 | async _getPluginFromURL(url: string): Promise { 51 | for (const plugin of this.plugins) if (await plugin.validate(url)) return plugin; 52 | return null; 53 | } 54 | 55 | _getPluginFromSong(song: Song): Promise; 56 | _getPluginFromSong( 57 | song: Song, 58 | types: T[], 59 | validate?: boolean, 60 | ): Promise<(DisTubePlugin & { type: T }) | null>; 61 | async _getPluginFromSong( 62 | song: Song, 63 | types?: T[], 64 | validate = true, 65 | ): Promise<(DisTubePlugin & { type: T }) | null> { 66 | if (!types || types.includes(song.plugin?.type)) return song.plugin as DisTubePlugin & { type: T }; 67 | if (!song.url) return null; 68 | for (const plugin of this.plugins) { 69 | if ((!types || types.includes(plugin?.type)) && (!validate || (await plugin.validate(song.url)))) { 70 | return plugin as DisTubePlugin & { type: T }; 71 | } 72 | } 73 | return null; 74 | } 75 | 76 | async #searchSong(query: string, options: ResolveOptions = {}, getStreamURL = false): Promise { 77 | const plugins = this.plugins.filter(p => p.type === PluginType.EXTRACTOR); 78 | if (!plugins.length) throw new DisTubeError("NO_EXTRACTOR_PLUGIN"); 79 | for (const plugin of plugins) { 80 | this.debug(`[${plugin.constructor.name}] Searching for song: ${query}`); 81 | const result = await plugin.searchSong(query, options); 82 | if (result) { 83 | if (getStreamURL && result.stream.playFromSource) result.stream.url = await plugin.getStreamURL(result); 84 | return result; 85 | } 86 | } 87 | return null; 88 | } 89 | 90 | /** 91 | * Get {@link Song}'s stream info and attach it to the song. 92 | * @param song - A Song 93 | */ 94 | async attachStreamInfo(song: Song) { 95 | if (song.stream.playFromSource) { 96 | if (song.stream.url) return; 97 | this.debug(`[DisTubeHandler] Getting stream info: ${song}`); 98 | const plugin = await this._getPluginFromSong(song, [PluginType.EXTRACTOR, PluginType.PLAYABLE_EXTRACTOR]); 99 | if (!plugin) throw new DisTubeError("NOT_SUPPORTED_SONG", song.toString()); 100 | this.debug(`[${plugin.constructor.name}] Getting stream URL: ${song}`); 101 | song.stream.url = await plugin.getStreamURL(song); 102 | if (!song.stream.url) throw new DisTubeError("CANNOT_GET_STREAM_URL", song.toString()); 103 | } else { 104 | if (song.stream.song?.stream?.playFromSource && song.stream.song.stream.url) return; 105 | this.debug(`[DisTubeHandler] Getting stream info: ${song}`); 106 | const plugin = await this._getPluginFromSong(song, [PluginType.INFO_EXTRACTOR]); 107 | if (!plugin) throw new DisTubeError("NOT_SUPPORTED_SONG", song.toString()); 108 | this.debug(`[${plugin.constructor.name}] Creating search query for: ${song}`); 109 | const query = await plugin.createSearchQuery(song); 110 | if (!query) throw new DisTubeError("CANNOT_GET_SEARCH_QUERY", song.toString()); 111 | const altSong = await this.#searchSong(query, { metadata: song.metadata, member: song.member }, true); 112 | if (!altSong || !altSong.stream.playFromSource) throw new DisTubeError("NO_RESULT", query || song.toString()); 113 | song.stream.song = altSong; 114 | } 115 | } 116 | 117 | async followRedirectLink(url: string, maxRedirect = 5): Promise { 118 | if (maxRedirect === 0) return url; 119 | 120 | const res = await request(url, { 121 | method: "HEAD", 122 | headers: { 123 | "user-agent": 124 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " + 125 | "Chrome/129.0.0.0 Safari/537.3", 126 | }, 127 | }); 128 | 129 | if (REDIRECT_CODES.has(res.statusCode ?? 200)) { 130 | let location = res.headers.location; 131 | if (typeof location !== "string") location = location?.[0] ?? url; 132 | return this.followRedirectLink(location, --maxRedirect); 133 | } 134 | 135 | return url; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/core/DisTubeOptions.ts: -------------------------------------------------------------------------------- 1 | import { DisTubeError, checkInvalidKey, defaultOptions } from ".."; 2 | import type { DisTubeOptions, DisTubePlugin, FFmpegArgs, FFmpegOptions, Filters } from ".."; 3 | 4 | export class Options { 5 | plugins: DisTubePlugin[]; 6 | emitNewSongOnly: boolean; 7 | savePreviousSongs: boolean; 8 | customFilters?: Filters; 9 | nsfw: boolean; 10 | emitAddSongWhenCreatingQueue: boolean; 11 | emitAddListWhenCreatingQueue: boolean; 12 | joinNewVoiceChannel: boolean; 13 | ffmpeg: FFmpegOptions; 14 | constructor(options: DisTubeOptions) { 15 | if (typeof options !== "object" || Array.isArray(options)) { 16 | throw new DisTubeError("INVALID_TYPE", "object", options, "DisTubeOptions"); 17 | } 18 | const opts = { ...defaultOptions, ...options }; 19 | this.plugins = opts.plugins; 20 | this.emitNewSongOnly = opts.emitNewSongOnly; 21 | this.savePreviousSongs = opts.savePreviousSongs; 22 | this.customFilters = opts.customFilters; 23 | this.nsfw = opts.nsfw; 24 | this.emitAddSongWhenCreatingQueue = opts.emitAddSongWhenCreatingQueue; 25 | this.emitAddListWhenCreatingQueue = opts.emitAddListWhenCreatingQueue; 26 | this.joinNewVoiceChannel = opts.joinNewVoiceChannel; 27 | this.ffmpeg = this.#ffmpegOption(options); 28 | checkInvalidKey(opts, this, "DisTubeOptions"); 29 | this.#validateOptions(); 30 | } 31 | 32 | #validateOptions(options = this) { 33 | const booleanOptions = new Set([ 34 | "emitNewSongOnly", 35 | "savePreviousSongs", 36 | "joinNewVoiceChannel", 37 | "nsfw", 38 | "emitAddSongWhenCreatingQueue", 39 | "emitAddListWhenCreatingQueue", 40 | ]); 41 | const numberOptions = new Set(); 42 | const stringOptions = new Set(); 43 | const objectOptions = new Set(["customFilters", "ffmpeg"]); 44 | const optionalOptions = new Set(["customFilters"]); 45 | 46 | for (const [key, value] of Object.entries(options)) { 47 | if (value === undefined && optionalOptions.has(key)) continue; 48 | if (key === "plugins" && !Array.isArray(value)) { 49 | throw new DisTubeError("INVALID_TYPE", "Array", value, `DisTubeOptions.${key}`); 50 | } else if (booleanOptions.has(key)) { 51 | if (typeof value !== "boolean") { 52 | throw new DisTubeError("INVALID_TYPE", "boolean", value, `DisTubeOptions.${key}`); 53 | } 54 | } else if (numberOptions.has(key)) { 55 | if (typeof value !== "number" || isNaN(value)) { 56 | throw new DisTubeError("INVALID_TYPE", "number", value, `DisTubeOptions.${key}`); 57 | } 58 | } else if (stringOptions.has(key)) { 59 | if (typeof value !== "string") { 60 | throw new DisTubeError("INVALID_TYPE", "string", value, `DisTubeOptions.${key}`); 61 | } 62 | } else if (objectOptions.has(key)) { 63 | if (typeof value !== "object" || Array.isArray(value)) { 64 | throw new DisTubeError("INVALID_TYPE", "object", value, `DisTubeOptions.${key}`); 65 | } 66 | } 67 | } 68 | } 69 | 70 | #ffmpegOption(opts: DisTubeOptions) { 71 | const args: FFmpegArgs = { global: {}, input: {}, output: {} }; 72 | if (opts.ffmpeg?.args) { 73 | if (opts.ffmpeg.args.global) args.global = opts.ffmpeg.args.global; 74 | if (opts.ffmpeg.args.input) args.input = opts.ffmpeg.args.input; 75 | if (opts.ffmpeg.args.output) args.output = opts.ffmpeg.args.output; 76 | } 77 | const path = opts.ffmpeg?.path ?? "ffmpeg"; 78 | if (typeof path !== "string") { 79 | throw new DisTubeError("INVALID_TYPE", "string", path, "DisTubeOptions.ffmpeg.path"); 80 | } 81 | for (const [key, value] of Object.entries(args)) { 82 | if (typeof value !== "object" || Array.isArray(value)) { 83 | throw new DisTubeError("INVALID_TYPE", "object", value, `DisTubeOptions.ffmpeg.${key}`); 84 | } 85 | for (const [k, v] of Object.entries(value)) { 86 | if ( 87 | typeof v !== "string" && 88 | typeof v !== "number" && 89 | typeof v !== "boolean" && 90 | !Array.isArray(v) && 91 | v !== null && 92 | v !== undefined 93 | ) { 94 | throw new DisTubeError( 95 | "INVALID_TYPE", 96 | ["string", "number", "boolean", "Array", "null", "undefined"], 97 | v, 98 | `DisTubeOptions.ffmpeg.${key}.${k}`, 99 | ); 100 | } 101 | } 102 | } 103 | return { path, args }; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/core/DisTubeStream.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from "stream"; 2 | import { DisTubeError, Events } from ".."; 3 | import { spawn, spawnSync } from "child_process"; 4 | import { TypedEmitter } from "tiny-typed-emitter"; 5 | import { StreamType, createAudioResource } from "@discordjs/voice"; 6 | import type { TransformCallback } from "stream"; 7 | import type { ChildProcess } from "child_process"; 8 | import type { AudioResource } from "@discordjs/voice"; 9 | import type { Awaitable, DisTube, FFmpegArg, FFmpegOptions } from ".."; 10 | 11 | /** 12 | * Options for {@link DisTubeStream} 13 | */ 14 | export interface StreamOptions { 15 | /** 16 | * FFmpeg options 17 | */ 18 | ffmpeg: FFmpegOptions; 19 | /** 20 | * Seek time (in seconds). 21 | * @default 0 22 | */ 23 | seek?: number; 24 | } 25 | 26 | let checked = process.env.NODE_ENV === "test"; 27 | export const checkFFmpeg = (distube: DisTube) => { 28 | if (checked) return; 29 | const path = distube.options.ffmpeg.path; 30 | const debug = (str: string) => distube.emit(Events.FFMPEG_DEBUG, str); 31 | try { 32 | debug(`[test] spawn ffmpeg at '${path}' path`); 33 | const process = spawnSync(path, ["-h"], { windowsHide: true, shell: true, encoding: "utf-8" }); 34 | if (process.error) throw process.error; 35 | if (process.stderr && !process.stdout) throw new Error(process.stderr); 36 | 37 | const result = process.output.join("\n"); 38 | const version = /ffmpeg version (\S+)/iu.exec(result)?.[1]; 39 | if (!version) throw new Error("Invalid FFmpeg version"); 40 | debug(`[test] ffmpeg version: ${version}`); 41 | } catch (e: any) { 42 | debug(`[test] failed to spawn ffmpeg at '${path}': ${e?.stack ?? e}`); 43 | throw new DisTubeError("FFMPEG_NOT_INSTALLED", path); 44 | } 45 | checked = true; 46 | }; 47 | 48 | /** 49 | * Create a stream to play with {@link DisTubeVoice} 50 | */ 51 | export class DisTubeStream extends TypedEmitter<{ 52 | debug: (debug: string) => Awaitable; 53 | error: (error: Error) => Awaitable; 54 | }> { 55 | #ffmpegPath: string; 56 | #opts: string[]; 57 | process?: ChildProcess; 58 | stream: VolumeTransformer; 59 | audioResource: AudioResource; 60 | /** 61 | * Create a DisTubeStream to play with {@link DisTubeVoice} 62 | * @param url - Stream URL 63 | * @param options - Stream options 64 | */ 65 | constructor(url: string, options: StreamOptions) { 66 | super(); 67 | const { ffmpeg, seek } = options; 68 | const opts: FFmpegArg = { 69 | reconnect: 1, 70 | reconnect_streamed: 1, 71 | reconnect_delay_max: 5, 72 | analyzeduration: 0, 73 | hide_banner: true, 74 | ...ffmpeg.args.global, 75 | ...ffmpeg.args.input, 76 | i: url, 77 | ar: 48000, 78 | ac: 2, 79 | ...ffmpeg.args.output, 80 | f: "s16le", 81 | }; 82 | 83 | if (typeof seek === "number" && seek > 0) opts.ss = seek.toString(); 84 | 85 | const fileUrl = new URL(url); 86 | if (fileUrl.protocol === "file:") { 87 | opts.reconnect = null; 88 | opts.reconnect_streamed = null; 89 | opts.reconnect_delay_max = null; 90 | opts.i = fileUrl.hostname + fileUrl.pathname; 91 | } 92 | 93 | this.#ffmpegPath = ffmpeg.path; 94 | this.#opts = [ 95 | ...Object.entries(opts) 96 | .flatMap(([key, value]) => 97 | Array.isArray(value) 98 | ? value.filter(Boolean).map(v => [`-${key}`, String(v)]) 99 | : value == null || value === false 100 | ? [] 101 | : [value === true ? `-${key}` : [`-${key}`, String(value)]], 102 | ) 103 | .flat(), 104 | "pipe:1", 105 | ]; 106 | 107 | this.stream = new VolumeTransformer(); 108 | this.stream 109 | .on("close", () => this.kill()) 110 | .on("error", err => { 111 | this.debug(`[stream] error: ${err.message}`); 112 | this.emit("error", err); 113 | }) 114 | .on("finish", () => this.debug("[stream] log: stream finished")); 115 | 116 | this.audioResource = createAudioResource(this.stream, { inputType: StreamType.Raw, inlineVolume: false }); 117 | } 118 | 119 | spawn() { 120 | this.debug(`[process] spawn: ${this.#ffmpegPath} ${this.#opts.join(" ")}`); 121 | this.process = spawn(this.#ffmpegPath, this.#opts, { 122 | stdio: ["ignore", "pipe", "pipe"], 123 | shell: false, 124 | windowsHide: true, 125 | }) 126 | .on("error", err => { 127 | this.debug(`[process] error: ${err.message}`); 128 | this.emit("error", err); 129 | }) 130 | .on("exit", (code, signal) => { 131 | this.debug(`[process] exit: code=${code ?? "unknown"} signal=${signal ?? "unknown"}`); 132 | if (!code || [0, 255].includes(code)) return; 133 | this.debug(`[process] error: ffmpeg exited with code ${code}`); 134 | this.emit("error", new DisTubeError("FFMPEG_EXITED", code)); 135 | }); 136 | 137 | if (!this.process.stdout || !this.process.stderr) { 138 | this.kill(); 139 | throw new Error("Failed to create ffmpeg process"); 140 | } 141 | 142 | this.process.stdout.pipe(this.stream); 143 | this.process.stderr.setEncoding("utf8")?.on("data", (data: string) => { 144 | const lines = data.split(/\r\n|\r|\n/u); 145 | for (const line of lines) { 146 | if (/^\s*$/.test(line)) continue; 147 | this.debug(`[ffmpeg] log: ${line}`); 148 | } 149 | }); 150 | } 151 | 152 | private debug(debug: string) { 153 | this.emit("debug", debug); 154 | } 155 | 156 | setVolume(volume: number) { 157 | this.stream.vol = volume; 158 | } 159 | 160 | kill() { 161 | if (!this.stream.destroyed) this.stream.destroy(); 162 | if (this.process && !this.process.killed) this.process.kill("SIGKILL"); 163 | } 164 | } 165 | 166 | // Based on prism-media 167 | class VolumeTransformer extends Transform { 168 | private buffer = Buffer.allocUnsafe(0); 169 | private readonly extrema = [-Math.pow(2, 16 - 1), Math.pow(2, 16 - 1) - 1]; 170 | vol = 1; 171 | 172 | override _transform(newChunk: Buffer, _encoding: BufferEncoding, done: TransformCallback): void { 173 | const { vol } = this; 174 | if (vol === 1) { 175 | this.push(newChunk); 176 | done(); 177 | return; 178 | } 179 | 180 | const bytes = 2; 181 | const chunk = Buffer.concat([this.buffer, newChunk]); 182 | const readableLength = Math.floor(chunk.length / bytes) * bytes; 183 | 184 | for (let i = 0; i < readableLength; i += bytes) { 185 | const value = chunk.readInt16LE(i); 186 | const clampedValue = Math.min(this.extrema[1], Math.max(this.extrema[0], value * vol)); 187 | chunk.writeInt16LE(clampedValue, i); 188 | } 189 | 190 | this.buffer = chunk.subarray(readableLength); 191 | this.push(chunk.subarray(0, readableLength)); 192 | done(); 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/core/DisTubeVoice.ts: -------------------------------------------------------------------------------- 1 | import { Constants } from "discord.js"; 2 | import { TypedEmitter } from "tiny-typed-emitter"; 3 | import { DisTubeError, checkEncryptionLibraries, isSupportedVoiceChannel } from ".."; 4 | import { 5 | AudioPlayerStatus, 6 | VoiceConnectionDisconnectReason, 7 | VoiceConnectionStatus, 8 | createAudioPlayer, 9 | entersState, 10 | joinVoiceChannel, 11 | } from "@discordjs/voice"; 12 | import type { AudioPlayer, VoiceConnection } from "@discordjs/voice"; 13 | import type { Snowflake, VoiceBasedChannel, VoiceState } from "discord.js"; 14 | import type { DisTubeStream, DisTubeVoiceEvents, DisTubeVoiceManager } from ".."; 15 | 16 | /** 17 | * Create a voice connection to the voice channel 18 | */ 19 | export class DisTubeVoice extends TypedEmitter { 20 | readonly id: Snowflake; 21 | readonly voices: DisTubeVoiceManager; 22 | readonly audioPlayer: AudioPlayer; 23 | connection!: VoiceConnection; 24 | emittedError!: boolean; 25 | isDisconnected = false; 26 | stream?: DisTubeStream; 27 | pausingStream?: DisTubeStream; 28 | #channel!: VoiceBasedChannel; 29 | #volume = 100; 30 | constructor(voiceManager: DisTubeVoiceManager, channel: VoiceBasedChannel) { 31 | super(); 32 | /** 33 | * The voice manager that instantiated this connection 34 | */ 35 | this.voices = voiceManager; 36 | this.id = channel.guildId; 37 | this.channel = channel; 38 | this.voices.add(this.id, this); 39 | this.audioPlayer = createAudioPlayer() 40 | .on(AudioPlayerStatus.Idle, oldState => { 41 | if (oldState.status !== AudioPlayerStatus.Idle) this.emit("finish"); 42 | }) 43 | .on("error", (error: NodeJS.ErrnoException) => { 44 | if (this.emittedError) return; 45 | this.emittedError = true; 46 | this.emit("error", error); 47 | }); 48 | this.connection 49 | .on(VoiceConnectionStatus.Disconnected, (_, newState) => { 50 | if (newState.reason === VoiceConnectionDisconnectReason.Manual) { 51 | // User disconnect 52 | this.leave(); 53 | } else if (newState.reason === VoiceConnectionDisconnectReason.WebSocketClose && newState.closeCode === 4014) { 54 | // Move to other channel 55 | entersState(this.connection, VoiceConnectionStatus.Connecting, 5e3).catch(() => { 56 | if ( 57 | ![VoiceConnectionStatus.Ready, VoiceConnectionStatus.Connecting].includes(this.connection.state.status) 58 | ) { 59 | this.leave(); 60 | } 61 | }); 62 | } else if (this.connection.rejoinAttempts < 5) { 63 | // Try to rejoin 64 | setTimeout( 65 | () => { 66 | this.connection.rejoin(); 67 | }, 68 | (this.connection.rejoinAttempts + 1) * 5e3, 69 | ).unref(); 70 | } else if (this.connection.state.status !== VoiceConnectionStatus.Destroyed) { 71 | // Leave after 5 attempts 72 | this.leave(new DisTubeError("VOICE_RECONNECT_FAILED")); 73 | } 74 | }) 75 | .on(VoiceConnectionStatus.Destroyed, () => { 76 | this.leave(); 77 | }) 78 | .on("error", () => undefined); 79 | this.connection.subscribe(this.audioPlayer); 80 | } 81 | /** 82 | * The voice channel id the bot is in 83 | */ 84 | get channelId() { 85 | return this.connection?.joinConfig?.channelId ?? undefined; 86 | } 87 | get channel() { 88 | if (!this.channelId) return this.#channel; 89 | if (this.#channel?.id === this.channelId) return this.#channel; 90 | const channel = this.voices.client.channels.cache.get(this.channelId); 91 | if (!channel) return this.#channel; 92 | for (const type of Constants.VoiceBasedChannelTypes) { 93 | if (channel.type === type) { 94 | this.#channel = channel; 95 | return channel; 96 | } 97 | } 98 | return this.#channel; 99 | } 100 | set channel(channel: VoiceBasedChannel) { 101 | if (!isSupportedVoiceChannel(channel)) { 102 | throw new DisTubeError("INVALID_TYPE", "BaseGuildVoiceChannel", channel, "DisTubeVoice#channel"); 103 | } 104 | if (channel.guildId !== this.id) throw new DisTubeError("VOICE_DIFFERENT_GUILD"); 105 | if (channel.client.user?.id !== this.voices.client.user?.id) throw new DisTubeError("VOICE_DIFFERENT_CLIENT"); 106 | if (channel.id === this.channelId) return; 107 | if (!channel.joinable) { 108 | if (channel.full) throw new DisTubeError("VOICE_FULL"); 109 | else throw new DisTubeError("VOICE_MISSING_PERMS"); 110 | } 111 | this.connection = this.#join(channel); 112 | this.#channel = channel; 113 | } 114 | #join(channel: VoiceBasedChannel) { 115 | return joinVoiceChannel({ 116 | channelId: channel.id, 117 | guildId: this.id, 118 | adapterCreator: channel.guild.voiceAdapterCreator, 119 | group: channel.client.user?.id, 120 | }); 121 | } 122 | /** 123 | * Join a voice channel with this connection 124 | * @param channel - A voice channel 125 | */ 126 | async join(channel?: VoiceBasedChannel): Promise { 127 | const TIMEOUT = 30e3; 128 | if (channel) this.channel = channel; 129 | try { 130 | await entersState(this.connection, VoiceConnectionStatus.Ready, TIMEOUT); 131 | } catch { 132 | if (this.connection.state.status === VoiceConnectionStatus.Ready) return this; 133 | if (this.connection.state.status !== VoiceConnectionStatus.Destroyed) this.connection.destroy(); 134 | this.voices.remove(this.id); 135 | throw new DisTubeError("VOICE_CONNECT_FAILED", TIMEOUT / 1e3); 136 | } 137 | return this; 138 | } 139 | /** 140 | * Leave the voice channel of this connection 141 | * @param error - Optional, an error to emit with 'error' event. 142 | */ 143 | leave(error?: Error) { 144 | this.stop(true); 145 | if (!this.isDisconnected) { 146 | this.emit("disconnect", error); 147 | this.isDisconnected = true; 148 | } 149 | if (this.connection.state.status !== VoiceConnectionStatus.Destroyed) this.connection.destroy(); 150 | this.voices.remove(this.id); 151 | } 152 | /** 153 | * Stop the playing stream 154 | * @param force - If true, will force the {@link DisTubeVoice#audioPlayer} to enter the Idle state even 155 | * if the {@link DisTubeStream#audioResource} has silence padding frames. 156 | */ 157 | stop(force = false) { 158 | this.audioPlayer.stop(force); 159 | } 160 | /** 161 | * Play a {@link DisTubeStream} 162 | * @param dtStream - DisTubeStream 163 | */ 164 | async play(dtStream: DisTubeStream) { 165 | if (!(await checkEncryptionLibraries())) { 166 | dtStream.kill(); 167 | throw new DisTubeError("ENCRYPTION_LIBRARIES_MISSING"); 168 | } 169 | this.emittedError = false; 170 | dtStream.on("error", (error: NodeJS.ErrnoException) => { 171 | if (this.emittedError || error.code === "ERR_STREAM_PREMATURE_CLOSE") return; 172 | this.emittedError = true; 173 | this.emit("error", error); 174 | }); 175 | if (this.audioPlayer.state.status !== AudioPlayerStatus.Paused) { 176 | this.audioPlayer.play(dtStream.audioResource); 177 | this.stream?.kill(); 178 | dtStream.spawn(); 179 | } else if (!this.pausingStream) { 180 | this.pausingStream = this.stream; 181 | } 182 | this.stream = dtStream; 183 | this.volume = this.#volume; 184 | } 185 | set volume(volume: number) { 186 | if (typeof volume !== "number" || isNaN(volume)) { 187 | throw new DisTubeError("INVALID_TYPE", "number", volume, "volume"); 188 | } 189 | if (volume < 0) { 190 | throw new DisTubeError("NUMBER_COMPARE", "Volume", "bigger or equal to", 0); 191 | } 192 | this.#volume = volume; 193 | this.stream?.setVolume(Math.pow(this.#volume / 100, 0.5 / Math.log10(2))); 194 | } 195 | /** 196 | * Get or set the volume percentage 197 | */ 198 | get volume() { 199 | return this.#volume; 200 | } 201 | /** 202 | * Playback duration of the audio resource in seconds 203 | */ 204 | get playbackDuration() { 205 | return (this.stream?.audioResource?.playbackDuration ?? 0) / 1000; 206 | } 207 | pause() { 208 | this.audioPlayer.pause(); 209 | } 210 | unpause() { 211 | const state = this.audioPlayer.state; 212 | if (state.status !== AudioPlayerStatus.Paused) return; 213 | if (this.stream?.audioResource && state.resource !== this.stream.audioResource) { 214 | this.audioPlayer.play(this.stream.audioResource); 215 | this.stream.spawn(); 216 | this.pausingStream?.kill(); 217 | delete this.pausingStream; 218 | } else { 219 | this.audioPlayer.unpause(); 220 | } 221 | } 222 | /** 223 | * Whether the bot is self-deafened 224 | */ 225 | get selfDeaf(): boolean { 226 | return this.connection.joinConfig.selfDeaf; 227 | } 228 | /** 229 | * Whether the bot is self-muted 230 | */ 231 | get selfMute(): boolean { 232 | return this.connection.joinConfig.selfMute; 233 | } 234 | /** 235 | * Self-deafens/undeafens the bot. 236 | * @param selfDeaf - Whether or not the bot should be self-deafened 237 | * @returns true if the voice state was successfully updated, otherwise false 238 | */ 239 | setSelfDeaf(selfDeaf: boolean): boolean { 240 | if (typeof selfDeaf !== "boolean") { 241 | throw new DisTubeError("INVALID_TYPE", "boolean", selfDeaf, "selfDeaf"); 242 | } 243 | return this.connection.rejoin({ 244 | ...this.connection.joinConfig, 245 | selfDeaf, 246 | }); 247 | } 248 | /** 249 | * Self-mutes/unmutes the bot. 250 | * @param selfMute - Whether or not the bot should be self-muted 251 | * @returns true if the voice state was successfully updated, otherwise false 252 | */ 253 | setSelfMute(selfMute: boolean): boolean { 254 | if (typeof selfMute !== "boolean") { 255 | throw new DisTubeError("INVALID_TYPE", "boolean", selfMute, "selfMute"); 256 | } 257 | return this.connection.rejoin({ 258 | ...this.connection.joinConfig, 259 | selfMute, 260 | }); 261 | } 262 | /** 263 | * The voice state of this connection 264 | */ 265 | get voiceState(): VoiceState | undefined { 266 | return this.channel?.guild?.members?.me?.voice; 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./DisTubeBase"; 2 | export * from "./DisTubeVoice"; 3 | export * from "./DisTubeStream"; 4 | export * from "./DisTubeHandler"; 5 | export * from "./DisTubeOptions"; 6 | export * from "./manager"; 7 | -------------------------------------------------------------------------------- /src/core/manager/BaseManager.ts: -------------------------------------------------------------------------------- 1 | import { DisTubeBase } from ".."; 2 | import { Collection } from "discord.js"; 3 | 4 | /** 5 | * Manages the collection of a data model. 6 | */ 7 | export abstract class BaseManager extends DisTubeBase { 8 | /** 9 | * The collection of items for this manager. 10 | */ 11 | collection = new Collection(); 12 | /** 13 | * The size of the collection. 14 | */ 15 | get size() { 16 | return this.collection.size; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/core/manager/DisTubeVoiceManager.ts: -------------------------------------------------------------------------------- 1 | import { GuildIdManager } from "."; 2 | import { DisTubeVoice } from "../DisTubeVoice"; 3 | import { DisTubeError, resolveGuildId } from "../.."; 4 | import { VoiceConnectionStatus, getVoiceConnection } from "@discordjs/voice"; 5 | import type { GuildIdResolvable } from "../.."; 6 | import type { VoiceBasedChannel } from "discord.js"; 7 | 8 | /** 9 | * Manages voice connections 10 | */ 11 | export class DisTubeVoiceManager extends GuildIdManager { 12 | /** 13 | * Create a {@link DisTubeVoice} instance 14 | * @param channel - A voice channel to join 15 | */ 16 | create(channel: VoiceBasedChannel): DisTubeVoice { 17 | const existing = this.get(channel.guildId); 18 | if (existing) { 19 | existing.channel = channel; 20 | return existing; 21 | } 22 | if ( 23 | getVoiceConnection(resolveGuildId(channel), this.client.user?.id) || 24 | getVoiceConnection(resolveGuildId(channel)) 25 | ) { 26 | throw new DisTubeError("VOICE_ALREADY_CREATED"); 27 | } 28 | return new DisTubeVoice(this, channel); 29 | } 30 | /** 31 | * Join a voice channel and wait until the connection is ready 32 | * @param channel - A voice channel to join 33 | */ 34 | join(channel: VoiceBasedChannel): Promise { 35 | const existing = this.get(channel.guildId); 36 | if (existing) return existing.join(channel); 37 | return this.create(channel).join(); 38 | } 39 | /** 40 | * Leave the connected voice channel in a guild 41 | * @param guild - Queue Resolvable 42 | */ 43 | leave(guild: GuildIdResolvable) { 44 | const voice = this.get(guild); 45 | if (voice) { 46 | voice.leave(); 47 | } else { 48 | const connection = 49 | getVoiceConnection(resolveGuildId(guild), this.client.user?.id) ?? getVoiceConnection(resolveGuildId(guild)); 50 | if (connection && connection.state.status !== VoiceConnectionStatus.Destroyed) { 51 | connection.destroy(); 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/core/manager/FilterManager.ts: -------------------------------------------------------------------------------- 1 | import { BaseManager } from "."; 2 | import { DisTubeError } from "../.."; 3 | import type { FFmpegArg as FFmpegArgsValue, Filter, FilterResolvable, Queue } from "../.."; 4 | 5 | /** 6 | * Manage filters of a playing {@link Queue} 7 | */ 8 | export class FilterManager extends BaseManager { 9 | /** 10 | * The queue to manage 11 | */ 12 | queue: Queue; 13 | constructor(queue: Queue) { 14 | super(queue.distube); 15 | this.queue = queue; 16 | } 17 | 18 | #resolve(filter: FilterResolvable): Filter { 19 | if (typeof filter === "object" && typeof filter.name === "string" && typeof filter.value === "string") { 20 | return filter; 21 | } 22 | if (typeof filter === "string" && Object.prototype.hasOwnProperty.call(this.distube.filters, filter)) { 23 | return { 24 | name: filter, 25 | value: this.distube.filters[filter], 26 | }; 27 | } 28 | throw new DisTubeError("INVALID_TYPE", "FilterResolvable", filter, "filter"); 29 | } 30 | 31 | #apply() { 32 | this.queue._beginTime = this.queue.currentTime; 33 | this.queue.play(false); 34 | } 35 | 36 | /** 37 | * Enable a filter or multiple filters to the manager 38 | * @param filterOrFilters - The filter or filters to enable 39 | * @param override - Wether or not override the applied filter with new filter value 40 | */ 41 | add(filterOrFilters: FilterResolvable | FilterResolvable[], override = false) { 42 | if (Array.isArray(filterOrFilters)) { 43 | for (const filter of filterOrFilters) { 44 | const ft = this.#resolve(filter); 45 | if (override || !this.has(ft)) this.collection.set(ft.name, ft); 46 | } 47 | } else { 48 | const ft = this.#resolve(filterOrFilters); 49 | if (override || !this.has(ft)) this.collection.set(ft.name, ft); 50 | } 51 | this.#apply(); 52 | return this; 53 | } 54 | 55 | /** 56 | * Clear enabled filters of the manager 57 | */ 58 | clear() { 59 | return this.set([]); 60 | } 61 | 62 | /** 63 | * Set the filters applied to the manager 64 | * @param filters - The filters to apply 65 | */ 66 | set(filters: FilterResolvable[]) { 67 | if (!Array.isArray(filters)) throw new DisTubeError("INVALID_TYPE", "Array", filters, "filters"); 68 | this.collection.clear(); 69 | for (const f of filters) { 70 | const filter = this.#resolve(f); 71 | this.collection.set(filter.name, filter); 72 | } 73 | this.#apply(); 74 | return this; 75 | } 76 | 77 | #removeFn(f: FilterResolvable) { 78 | return this.collection.delete(this.#resolve(f).name); 79 | } 80 | 81 | /** 82 | * Disable a filter or multiple filters 83 | * @param filterOrFilters - The filter or filters to disable 84 | */ 85 | remove(filterOrFilters: FilterResolvable | FilterResolvable[]) { 86 | if (Array.isArray(filterOrFilters)) filterOrFilters.forEach(f => this.#removeFn(f)); 87 | else this.#removeFn(filterOrFilters); 88 | this.#apply(); 89 | return this; 90 | } 91 | 92 | /** 93 | * Check whether a filter enabled or not 94 | * @param filter - The filter to check 95 | */ 96 | has(filter: FilterResolvable) { 97 | return this.collection.has(typeof filter === "string" ? filter : this.#resolve(filter).name); 98 | } 99 | 100 | /** 101 | * Array of enabled filter names 102 | */ 103 | get names(): string[] { 104 | return [...this.collection.keys()]; 105 | } 106 | 107 | /** 108 | * Array of enabled filters 109 | */ 110 | get values(): Filter[] { 111 | return [...this.collection.values()]; 112 | } 113 | 114 | get ffmpegArgs(): FFmpegArgsValue { 115 | return this.size ? { af: this.values.map(f => f.value).join(",") } : {}; 116 | } 117 | 118 | override toString() { 119 | return this.names.toString(); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/core/manager/GuildIdManager.ts: -------------------------------------------------------------------------------- 1 | import { BaseManager } from "."; 2 | import { resolveGuildId } from "../.."; 3 | import type { GuildIdResolvable } from "../.."; 4 | 5 | /** 6 | * Manages the collection of a data model paired with a guild id. 7 | */ 8 | export abstract class GuildIdManager extends BaseManager { 9 | add(idOrInstance: GuildIdResolvable, data: V) { 10 | const id = resolveGuildId(idOrInstance); 11 | const existing = this.get(id); 12 | if (existing) return this; 13 | this.collection.set(id, data); 14 | return this; 15 | } 16 | get(idOrInstance: GuildIdResolvable): V | undefined { 17 | return this.collection.get(resolveGuildId(idOrInstance)); 18 | } 19 | remove(idOrInstance: GuildIdResolvable): boolean { 20 | return this.collection.delete(resolveGuildId(idOrInstance)); 21 | } 22 | has(idOrInstance: GuildIdResolvable): boolean { 23 | return this.collection.has(resolveGuildId(idOrInstance)); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/core/manager/QueueManager.ts: -------------------------------------------------------------------------------- 1 | import { GuildIdManager } from "."; 2 | import { DisTubeError, DisTubeStream, Events, Queue, RepeatMode, checkFFmpeg, objectKeys } from "../.."; 3 | import type { Song } from "../.."; 4 | import type { GuildTextBasedChannel, VoiceBasedChannel } from "discord.js"; 5 | 6 | /** 7 | * Queue manager 8 | */ 9 | export class QueueManager extends GuildIdManager { 10 | /** 11 | * Create a {@link Queue} 12 | * @param channel - A voice channel 13 | * @param textChannel - Default text channel 14 | * @returns Returns `true` if encounter an error 15 | */ 16 | async create(channel: VoiceBasedChannel, textChannel?: GuildTextBasedChannel): Promise { 17 | if (this.has(channel.guildId)) throw new DisTubeError("QUEUE_EXIST"); 18 | this.debug(`[QueueManager] Creating queue for guild: ${channel.guildId}`); 19 | const voice = this.voices.create(channel); 20 | const queue = new Queue(this.distube, voice, textChannel); 21 | await queue._taskQueue.queuing(); 22 | try { 23 | checkFFmpeg(this.distube); 24 | this.debug(`[QueueManager] Joining voice channel: ${channel.id}`); 25 | await voice.join(); 26 | this.#voiceEventHandler(queue); 27 | this.add(queue.id, queue); 28 | this.emit(Events.INIT_QUEUE, queue); 29 | return queue; 30 | } finally { 31 | queue._taskQueue.resolve(); 32 | } 33 | } 34 | 35 | /** 36 | * Listen to DisTubeVoice events and handle the Queue 37 | * @param queue - Queue 38 | */ 39 | #voiceEventHandler(queue: Queue) { 40 | queue._listeners = { 41 | disconnect: error => { 42 | queue.remove(); 43 | this.emit(Events.DISCONNECT, queue); 44 | if (error) this.emitError(error, queue, queue.songs?.[0]); 45 | }, 46 | error: error => this.#handlePlayingError(queue, error), 47 | finish: () => this.#handleSongFinish(queue), 48 | }; 49 | for (const event of objectKeys(queue._listeners)) { 50 | queue.voice.on(event, queue._listeners[event]); 51 | } 52 | } 53 | 54 | /** 55 | * Whether or not emit playSong event 56 | * @param queue - Queue 57 | */ 58 | #emitPlaySong(queue: Queue): boolean { 59 | if (!this.options.emitNewSongOnly) return true; 60 | if (queue.repeatMode === RepeatMode.SONG) return queue._next || queue._prev; 61 | return queue.songs[0].id !== queue.songs[1].id; 62 | } 63 | 64 | /** 65 | * Handle the queue when a Song finish 66 | * @param queue - queue 67 | */ 68 | async #handleSongFinish(queue: Queue): Promise { 69 | this.debug(`[QueueManager] Handling song finish: ${queue.id}`); 70 | const song = queue.songs[0]; 71 | this.emit(Events.FINISH_SONG, queue, queue.songs[0]); 72 | await queue._taskQueue.queuing(); 73 | try { 74 | if (queue.stopped) return; 75 | if (queue.repeatMode === RepeatMode.QUEUE && !queue._prev) queue.songs.push(song); 76 | if (queue._prev) { 77 | if (queue.repeatMode === RepeatMode.QUEUE) queue.songs.unshift(queue.songs.pop() as Song); 78 | else queue.songs.unshift(queue.previousSongs.pop() as Song); 79 | } 80 | if (queue.songs.length <= 1 && (queue._next || queue.repeatMode === RepeatMode.DISABLED)) { 81 | if (queue.autoplay) { 82 | try { 83 | this.debug(`[QueueManager] Adding related song: ${queue.id}`); 84 | await queue.addRelatedSong(); 85 | } catch (e: any) { 86 | this.debug(`[${queue.id}] Add related song error: ${e.message}`); 87 | this.emit(Events.NO_RELATED, queue, e); 88 | } 89 | } 90 | if (queue.songs.length <= 1) { 91 | this.debug(`[${queue.id}] Queue is empty, stopping...`); 92 | if (!queue.autoplay) this.emit(Events.FINISH, queue); 93 | queue.remove(); 94 | return; 95 | } 96 | } 97 | const emitPlaySong = this.#emitPlaySong(queue); 98 | if (!queue._prev && (queue.repeatMode !== RepeatMode.SONG || queue._next)) { 99 | const prev = queue.songs.shift() as Song; 100 | if (this.options.savePreviousSongs) queue.previousSongs.push(prev); 101 | else queue.previousSongs.push({ id: prev.id } as Song); 102 | } 103 | queue._next = queue._prev = false; 104 | queue._beginTime = 0; 105 | if (song !== queue.songs[0]) { 106 | const playedSong = song.stream.playFromSource ? song : song.stream.song; 107 | if (playedSong?.stream.playFromSource) delete playedSong.stream.url; 108 | } 109 | await this.playSong(queue, emitPlaySong); 110 | } finally { 111 | queue._taskQueue.resolve(); 112 | } 113 | } 114 | 115 | /** 116 | * Handle error while playing 117 | * @param queue - queue 118 | * @param error - error 119 | */ 120 | #handlePlayingError(queue: Queue, error: Error) { 121 | const song = queue.songs.shift()!; 122 | try { 123 | error.name = "PlayingError"; 124 | } catch { 125 | // Emit original error 126 | } 127 | this.debug(`[${queue.id}] Error while playing: ${error.stack || error.message}`); 128 | this.emitError(error, queue, song); 129 | if (queue.songs.length > 0) { 130 | this.debug(`[${queue.id}] Playing next song: ${queue.songs[0]}`); 131 | queue._next = queue._prev = false; 132 | queue._beginTime = 0; 133 | this.playSong(queue); 134 | } else { 135 | this.debug(`[${queue.id}] Queue is empty, stopping...`); 136 | queue.stop(); 137 | } 138 | } 139 | 140 | /** 141 | * Play a song on voice connection with queue properties 142 | * @param queue - The guild queue to play 143 | * @param emitPlaySong - Whether or not emit {@link Events.PLAY_SONG} event 144 | */ 145 | async playSong(queue: Queue, emitPlaySong = true) { 146 | if (!queue) return; 147 | if (queue.stopped || !queue.songs.length) { 148 | queue.stop(); 149 | return; 150 | } 151 | try { 152 | const song = queue.songs[0]; 153 | this.debug(`[${queue.id}] Getting stream from: ${song}`); 154 | await this.handler.attachStreamInfo(song); 155 | const willPlaySong = song.stream.playFromSource ? song : song.stream.song; 156 | const stream = willPlaySong?.stream; 157 | if (!willPlaySong || !stream?.playFromSource || !stream.url) throw new DisTubeError("NO_STREAM_URL", `${song}`); 158 | this.debug(`[${queue.id}] Creating DisTubeStream for: ${willPlaySong}`); 159 | const streamOptions = { 160 | ffmpeg: { 161 | path: this.options.ffmpeg.path, 162 | args: { 163 | global: { ...queue.ffmpegArgs.global }, 164 | input: { ...queue.ffmpegArgs.input }, 165 | output: { ...queue.ffmpegArgs.output, ...queue.filters.ffmpegArgs }, 166 | }, 167 | }, 168 | seek: willPlaySong.duration ? queue._beginTime : undefined, 169 | }; 170 | const dtStream = new DisTubeStream(stream.url, streamOptions); 171 | dtStream.on("debug", data => this.emit(Events.FFMPEG_DEBUG, `[${queue.id}] ${data}`)); 172 | this.debug(`[${queue.id}] Started playing: ${willPlaySong}`); 173 | await queue.voice.play(dtStream); 174 | if (emitPlaySong) this.emit(Events.PLAY_SONG, queue, song); 175 | } catch (e: any) { 176 | this.#handlePlayingError(queue, e); 177 | } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/core/manager/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./BaseManager"; 2 | export * from "./GuildIdManager"; 3 | export * from "./DisTubeVoiceManager"; 4 | export * from "./FilterManager"; 5 | export * from "./QueueManager"; 6 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The current version that you are currently using. 3 | * 4 | * Note to developers: 5 | * This needs to explicitly be `string` so it is not typed as a "const string" that gets injected by esbuild 6 | */ 7 | // eslint-disable-next-line @typescript-eslint/no-inferrable-types 8 | export const version: string = "[VI]{{inject}}[/VI]"; 9 | 10 | export * from "./type"; 11 | export * from "./constant"; 12 | export * from "./struct"; 13 | export * from "./util"; 14 | export * from "./core"; 15 | export { DisTube, DisTube as default } from "./DisTube"; 16 | -------------------------------------------------------------------------------- /src/struct/DisTubeError.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from "node:util"; 2 | 3 | const ERROR_MESSAGES = { 4 | INVALID_TYPE: (expected: (number | string) | readonly (number | string)[], got: any, name?: string) => 5 | `Expected ${ 6 | Array.isArray(expected) ? expected.map(e => (typeof e === "number" ? e : `'${e}'`)).join(" or ") : `'${expected}'` 7 | }${name ? ` for '${name}'` : ""}, but got ${inspect(got)} (${typeof got})`, 8 | NUMBER_COMPARE: (name: string, expected: string, value: number) => `'${name}' must be ${expected} ${value}`, 9 | EMPTY_ARRAY: (name: string) => `'${name}' is an empty array`, 10 | EMPTY_FILTERED_ARRAY: (name: string, type: string) => `There is no valid '${type}' in the '${name}' array`, 11 | EMPTY_STRING: (name: string) => `'${name}' string must not be empty`, 12 | INVALID_KEY: (obj: string, key: string) => `'${key}' does not need to be provided in ${obj}`, 13 | MISSING_KEY: (obj: string, key: string) => `'${key}' needs to be provided in ${obj}`, 14 | MISSING_KEYS: (obj: string, key: string[], all: boolean) => 15 | `${key.map(k => `'${k}'`).join(all ? " and " : " or ")} need to be provided in ${obj}`, 16 | 17 | MISSING_INTENTS: (i: string) => `${i} intent must be provided for the Client`, 18 | DISABLED_OPTION: (o: string) => `DisTubeOptions.${o} is disabled`, 19 | ENABLED_OPTION: (o: string) => `DisTubeOptions.${o} is enabled`, 20 | 21 | NOT_IN_VOICE: "User is not in any voice channel", 22 | VOICE_FULL: "The voice channel is full", 23 | VOICE_ALREADY_CREATED: "This guild already has a voice connection which is not managed by DisTube", 24 | VOICE_CONNECT_FAILED: (s: number) => `Cannot connect to the voice channel after ${s} seconds`, 25 | VOICE_MISSING_PERMS: "I do not have permission to join this voice channel", 26 | VOICE_RECONNECT_FAILED: "Cannot reconnect to the voice channel", 27 | VOICE_DIFFERENT_GUILD: "Cannot join a voice channel in a different guild", 28 | VOICE_DIFFERENT_CLIENT: "Cannot join a voice channel created by a different client", 29 | 30 | FFMPEG_EXITED: (code: number) => `ffmpeg exited with code ${code}`, 31 | FFMPEG_NOT_INSTALLED: (path: string) => `ffmpeg is not installed at '${path}' path`, 32 | ENCRYPTION_LIBRARIES_MISSING: 33 | "Cannot play audio as no valid encryption package is installed and your node doesn't support aes-256-gcm.\n" + 34 | "Please install @noble/ciphers, @stablelib/xchacha20poly1305, sodium-native or libsodium-wrappers.", 35 | 36 | NO_QUEUE: "There is no playing queue in this guild", 37 | QUEUE_EXIST: "This guild has a Queue already", 38 | QUEUE_STOPPED: "The queue has been stopped already", 39 | PAUSED: "The queue has been paused already", 40 | RESUMED: "The queue has been playing already", 41 | NO_PREVIOUS: "There is no previous song in this queue", 42 | NO_UP_NEXT: "There is no up next song", 43 | NO_SONG_POSITION: "Does not have any song at this position", 44 | NO_PLAYING_SONG: "There is no playing song in the queue", 45 | NO_EXTRACTOR_PLUGIN: "There is no extractor plugin in the DisTubeOptions.plugins, please add one for searching songs", 46 | NO_RELATED: "Cannot find any related songs", 47 | CANNOT_PLAY_RELATED: "Cannot play the related song", 48 | UNAVAILABLE_VIDEO: "This video is unavailable", 49 | UNPLAYABLE_FORMATS: "No playable format found", 50 | NON_NSFW: "Cannot play age-restricted content in non-NSFW channel", 51 | NOT_SUPPORTED_URL: "This url is not supported", 52 | NOT_SUPPORTED_SONG: (song: string) => `There is no plugin supporting this song (${song})`, 53 | NO_VALID_SONG: "'songs' array does not have any valid Song or url", 54 | CANNOT_RESOLVE_SONG: (t: any) => `Cannot resolve ${inspect(t)} to a Song`, 55 | CANNOT_GET_STREAM_URL: (song: string) => `Cannot get stream url with this song (${song})`, 56 | CANNOT_GET_SEARCH_QUERY: (song: string) => `Cannot get search query with this song (${song})`, 57 | NO_RESULT: (query: string) => `Cannot find any song with this query (${query})`, 58 | NO_STREAM_URL: (song: string) => `No stream url attached (${song})`, 59 | 60 | EMPTY_FILTERED_PLAYLIST: 61 | "There is no valid video in the playlist\n" + 62 | "Maybe age-restricted contents is filtered because you are in non-NSFW channel", 63 | EMPTY_PLAYLIST: "There is no valid video in the playlist", 64 | }; 65 | 66 | type ErrorMessage = typeof ERROR_MESSAGES; 67 | type ErrorCode = keyof ErrorMessage; 68 | type StaticErrorCode = { [K in ErrorCode]-?: ErrorMessage[K] extends string ? K : never }[ErrorCode]; 69 | type TemplateErrorCode = Exclude; 70 | 71 | const haveCode = (code: string): code is ErrorCode => Object.keys(ERROR_MESSAGES).includes(code); 72 | const parseMessage = (m: string | ((...x: any) => string), ...args: any) => (typeof m === "string" ? m : m(...args)); 73 | const getErrorMessage = (code: string, ...args: any): string => 74 | haveCode(code) ? parseMessage(ERROR_MESSAGES[code], ...args) : args[0]; 75 | export class DisTubeError extends Error { 76 | errorCode: string; 77 | constructor(code: T extends StaticErrorCode ? T : never); 78 | constructor(code: T extends TemplateErrorCode ? T : never, ...args: Parameters); 79 | constructor(code: TemplateErrorCode, _: never); 80 | constructor(code: T extends ErrorCode ? never : T, message: string); 81 | constructor(code: string, ...args: any) { 82 | super(getErrorMessage(code, ...args)); 83 | 84 | this.errorCode = code; 85 | if (Error.captureStackTrace) Error.captureStackTrace(this, DisTubeError); 86 | } 87 | 88 | override get name() { 89 | return `DisTubeError [${this.errorCode}]`; 90 | } 91 | 92 | get code() { 93 | return this.errorCode; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/struct/ExtractorPlugin.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "."; 2 | import { PluginType } from ".."; 3 | import type { Awaitable, Playlist, ResolveOptions, Song } from ".."; 4 | 5 | /** 6 | * This plugin can extract the info, search, and play a song directly from its source 7 | */ 8 | export abstract class ExtractorPlugin extends Plugin { 9 | readonly type = PluginType.EXTRACTOR; 10 | /** 11 | * Check if the url is working with this plugin 12 | * @param url - Input url 13 | */ 14 | abstract validate(url: string): Awaitable; 15 | /** 16 | * Resolve the validated url to a {@link Song} or a {@link Playlist}. 17 | * @param url - URL 18 | * @param options - Optional options 19 | */ 20 | abstract resolve(url: string, options: ResolveOptions): Awaitable | Playlist>; 21 | /** 22 | * Search for a Song which playable from this plugin's source 23 | * @param query - Search query 24 | * @param options - Optional options 25 | */ 26 | abstract searchSong(query: string, options: ResolveOptions): Awaitable | null>; 27 | /** 28 | * Get the stream url from {@link Song#url}. Returns {@link Song#url} by default. 29 | * Not needed if the plugin plays song from YouTube. 30 | * @param song - Input song 31 | */ 32 | abstract getStreamURL(song: Song): Awaitable; 33 | } 34 | -------------------------------------------------------------------------------- /src/struct/InfoExtratorPlugin.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "."; 2 | import { PluginType } from ".."; 3 | import type { Awaitable, Playlist, ResolveOptions, Song } from ".."; 4 | 5 | /** 6 | * This plugin only can extract the info from supported links, but not play song directly from its source 7 | */ 8 | export abstract class InfoExtractorPlugin extends Plugin { 9 | readonly type = PluginType.INFO_EXTRACTOR; 10 | /** 11 | * Check if the url is working with this plugin 12 | * @param url - Input url 13 | */ 14 | abstract validate(url: string): Awaitable; 15 | /** 16 | * Resolve the validated url to a {@link Song} or a {@link Playlist}. 17 | * @param url - URL 18 | * @param options - Optional options 19 | */ 20 | abstract resolve(url: string, options: ResolveOptions): Awaitable | Playlist>; 21 | 22 | /** 23 | * Create a search query to be used in {@link ExtractorPlugin#searchSong} 24 | * @param song - Input song 25 | */ 26 | abstract createSearchQuery(song: Song): Awaitable; 27 | } 28 | -------------------------------------------------------------------------------- /src/struct/PlayableExtratorPlugin.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "."; 2 | import { PluginType } from ".."; 3 | import type { Awaitable, Playlist, ResolveOptions, Song } from ".."; 4 | 5 | /** 6 | * This plugin can extract and play song from supported links, but cannot search for songs from its source 7 | */ 8 | export abstract class PlayableExtractorPlugin extends Plugin { 9 | readonly type = PluginType.PLAYABLE_EXTRACTOR; 10 | /** 11 | * Check if the url is working with this plugin 12 | * @param url - Input url 13 | */ 14 | abstract validate(url: string): Awaitable; 15 | /** 16 | * Resolve the validated url to a {@link Song} or a {@link Playlist}. 17 | * @param url - URL 18 | * @param options - Optional options 19 | */ 20 | abstract resolve(url: string, options: ResolveOptions): Awaitable | Playlist>; 21 | /** 22 | * Get the stream url from {@link Song#url}. Returns {@link Song#url} by default. 23 | * Not needed if the plugin plays song from YouTube. 24 | * @param song - Input song 25 | */ 26 | abstract getStreamURL(song: Song): Awaitable; 27 | } 28 | -------------------------------------------------------------------------------- /src/struct/Playlist.ts: -------------------------------------------------------------------------------- 1 | import { DisTubeError, formatDuration, isMemberInstance } from ".."; 2 | import type { GuildMember } from "discord.js"; 3 | import type { PlaylistInfo, ResolveOptions, Song } from ".."; 4 | 5 | /** 6 | * Class representing a playlist. 7 | */ 8 | export class Playlist implements PlaylistInfo { 9 | /** 10 | * Playlist source. 11 | */ 12 | source: string; 13 | /** 14 | * Songs in the playlist. 15 | */ 16 | songs: Song[]; 17 | /** 18 | * Playlist ID. 19 | */ 20 | id?: string; 21 | /** 22 | * Playlist name. 23 | */ 24 | name?: string; 25 | /** 26 | * Playlist URL. 27 | */ 28 | url?: string; 29 | /** 30 | * Playlist thumbnail. 31 | */ 32 | thumbnail?: string; 33 | #metadata!: T; 34 | #member?: GuildMember; 35 | /** 36 | * Create a Playlist 37 | * @param playlist - Raw playlist info 38 | * @param options - Optional data 39 | */ 40 | constructor(playlist: PlaylistInfo, { member, metadata }: ResolveOptions = {}) { 41 | if (!Array.isArray(playlist.songs) || !playlist.songs.length) throw new DisTubeError("EMPTY_PLAYLIST"); 42 | 43 | this.source = playlist.source.toLowerCase(); 44 | this.songs = playlist.songs; 45 | this.name = playlist.name; 46 | this.id = playlist.id; 47 | this.url = playlist.url; 48 | this.thumbnail = playlist.thumbnail; 49 | this.member = member; 50 | this.songs.forEach(s => (s.playlist = this)); 51 | this.metadata = metadata as T; 52 | } 53 | 54 | /** 55 | * Playlist duration in second. 56 | */ 57 | get duration() { 58 | return this.songs.reduce((prev, next) => prev + next.duration, 0); 59 | } 60 | 61 | /** 62 | * Formatted duration string `hh:mm:ss`. 63 | */ 64 | get formattedDuration() { 65 | return formatDuration(this.duration); 66 | } 67 | 68 | /** 69 | * User requested. 70 | */ 71 | get member() { 72 | return this.#member; 73 | } 74 | 75 | set member(member: GuildMember | undefined) { 76 | if (!isMemberInstance(member)) return; 77 | this.#member = member; 78 | this.songs.forEach(s => (s.member = this.member)); 79 | } 80 | 81 | /** 82 | * User requested. 83 | */ 84 | get user() { 85 | return this.member?.user; 86 | } 87 | 88 | /** 89 | * Optional metadata that can be used to identify the playlist. 90 | */ 91 | get metadata() { 92 | return this.#metadata; 93 | } 94 | 95 | set metadata(metadata: T) { 96 | this.#metadata = metadata; 97 | this.songs.forEach(s => (s.metadata = metadata)); 98 | } 99 | 100 | toString() { 101 | return `${this.name} (${this.songs.length} songs)`; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/struct/Plugin.ts: -------------------------------------------------------------------------------- 1 | import type { Awaitable, DisTube, PluginType, Song } from ".."; 2 | 3 | /** 4 | * DisTube Plugin 5 | */ 6 | export abstract class Plugin { 7 | /** 8 | * Type of the plugin 9 | */ 10 | abstract readonly type: PluginType; 11 | /** 12 | * DisTube 13 | */ 14 | distube!: DisTube; 15 | init(distube: DisTube) { 16 | this.distube = distube; 17 | } 18 | /** 19 | * Get related songs from a supported url. 20 | * @param song - Input song 21 | */ 22 | abstract getRelatedSongs(song: Song): Awaitable; 23 | } 24 | -------------------------------------------------------------------------------- /src/struct/Queue.ts: -------------------------------------------------------------------------------- 1 | import { DisTubeBase, FilterManager } from "../core"; 2 | import { DisTubeError, Events, RepeatMode, TaskQueue, formatDuration, objectKeys } from ".."; 3 | import type { GuildTextBasedChannel, Snowflake } from "discord.js"; 4 | import type { DisTube, DisTubeVoice, DisTubeVoiceEvents, FFmpegArgs, Song } from ".."; 5 | 6 | /** 7 | * Represents a queue. 8 | */ 9 | export class Queue extends DisTubeBase { 10 | /** 11 | * Queue id (Guild id) 12 | */ 13 | readonly id: Snowflake; 14 | /** 15 | * Voice connection of this queue. 16 | */ 17 | voice: DisTubeVoice; 18 | /** 19 | * List of songs in the queue (The first one is the playing song) 20 | */ 21 | songs: Song[]; 22 | /** 23 | * List of the previous songs. 24 | */ 25 | previousSongs: Song[]; 26 | /** 27 | * Whether stream is currently stopped. 28 | */ 29 | stopped: boolean; 30 | /** 31 | * Whether or not the stream is currently playing. 32 | */ 33 | playing: boolean; 34 | /** 35 | * Whether or not the stream is currently paused. 36 | */ 37 | paused: boolean; 38 | /** 39 | * Type of repeat mode (`0` is disabled, `1` is repeating a song, `2` is repeating 40 | * all the queue). Default value: `0` (disabled) 41 | */ 42 | repeatMode: RepeatMode; 43 | /** 44 | * Whether or not the autoplay mode is enabled. Default value: `false` 45 | */ 46 | autoplay: boolean; 47 | /** 48 | * FFmpeg arguments for the current queue. Default value is defined with {@link DisTubeOptions}.ffmpeg.args. 49 | * `af` output argument will be replaced with {@link Queue#filters} manager 50 | */ 51 | ffmpegArgs: FFmpegArgs; 52 | /** 53 | * The text channel of the Queue. (Default: where the first command is called). 54 | */ 55 | textChannel?: GuildTextBasedChannel; 56 | #filters: FilterManager; 57 | /** 58 | * What time in the song to begin (in seconds). 59 | */ 60 | _beginTime: number; 61 | /** 62 | * Whether or not the last song was skipped to next song. 63 | */ 64 | _next: boolean; 65 | /** 66 | * Whether or not the last song was skipped to previous song. 67 | */ 68 | _prev: boolean; 69 | /** 70 | * Task queuing system 71 | */ 72 | _taskQueue: TaskQueue; 73 | /** 74 | * {@link DisTubeVoice} listener 75 | */ 76 | _listeners?: DisTubeVoiceEvents; 77 | /** 78 | * Create a queue for the guild 79 | * @param distube - DisTube 80 | * @param voice - Voice connection 81 | * @param textChannel - Default text channel 82 | */ 83 | constructor(distube: DisTube, voice: DisTubeVoice, textChannel?: GuildTextBasedChannel) { 84 | super(distube); 85 | this.voice = voice; 86 | this.id = voice.id; 87 | this.volume = 50; 88 | this.songs = []; 89 | this.previousSongs = []; 90 | this.stopped = false; 91 | this._next = false; 92 | this._prev = false; 93 | this.playing = false; 94 | this.paused = false; 95 | this.repeatMode = RepeatMode.DISABLED; 96 | this.autoplay = false; 97 | this.#filters = new FilterManager(this); 98 | this._beginTime = 0; 99 | this.textChannel = textChannel; 100 | this._taskQueue = new TaskQueue(); 101 | this._listeners = undefined; 102 | this.ffmpegArgs = { 103 | global: { ...this.options.ffmpeg.args.global }, 104 | input: { ...this.options.ffmpeg.args.input }, 105 | output: { ...this.options.ffmpeg.args.output }, 106 | }; 107 | } 108 | /** 109 | * The client user as a `GuildMember` of this queue's guild 110 | */ 111 | get clientMember() { 112 | return this.voice.channel.guild.members.me ?? undefined; 113 | } 114 | /** 115 | * The filter manager of the queue 116 | */ 117 | get filters() { 118 | return this.#filters; 119 | } 120 | /** 121 | * Formatted duration string. 122 | */ 123 | get formattedDuration() { 124 | return formatDuration(this.duration); 125 | } 126 | /** 127 | * Queue's duration. 128 | */ 129 | get duration() { 130 | return this.songs.length ? this.songs.reduce((prev, next) => prev + next.duration, 0) : 0; 131 | } 132 | /** 133 | * What time in the song is playing (in seconds). 134 | */ 135 | get currentTime() { 136 | return this.voice.playbackDuration + this._beginTime; 137 | } 138 | /** 139 | * Formatted {@link Queue#currentTime} string. 140 | */ 141 | get formattedCurrentTime() { 142 | return formatDuration(this.currentTime); 143 | } 144 | /** 145 | * The voice channel playing in. 146 | */ 147 | get voiceChannel() { 148 | return this.clientMember?.voice?.channel ?? null; 149 | } 150 | /** 151 | * Get or set the stream volume. Default value: `50`. 152 | */ 153 | get volume() { 154 | return this.voice.volume; 155 | } 156 | set volume(value: number) { 157 | this.voice.volume = value; 158 | } 159 | /** 160 | * @throws {DisTubeError} 161 | * @param song - Song to add 162 | * @param position - Position to add, \<= 0 to add to the end of the queue 163 | * @returns The guild queue 164 | */ 165 | addToQueue(song: Song | Song[], position = 0): Queue { 166 | if (this.stopped) throw new DisTubeError("QUEUE_STOPPED"); 167 | if (!song || (Array.isArray(song) && !song.length)) { 168 | throw new DisTubeError("INVALID_TYPE", ["Song", "Array"], song, "song"); 169 | } 170 | if (typeof position !== "number" || !Number.isInteger(position)) { 171 | throw new DisTubeError("INVALID_TYPE", "integer", position, "position"); 172 | } 173 | if (position <= 0) { 174 | if (Array.isArray(song)) this.songs.push(...song); 175 | else this.songs.push(song); 176 | } else if (Array.isArray(song)) { 177 | this.songs.splice(position, 0, ...song); 178 | } else { 179 | this.songs.splice(position, 0, song); 180 | } 181 | return this; 182 | } 183 | /** 184 | * @returns `true` if the queue is playing 185 | */ 186 | isPlaying(): boolean { 187 | return this.playing; 188 | } 189 | /** 190 | * @returns `true` if the queue is paused 191 | */ 192 | isPaused(): boolean { 193 | return this.paused; 194 | } 195 | /** 196 | * Pause the guild stream 197 | * @returns The guild queue 198 | */ 199 | async pause(): Promise { 200 | await this._taskQueue.queuing(); 201 | try { 202 | if (this.paused) throw new DisTubeError("PAUSED"); 203 | this.paused = true; 204 | this.voice.pause(); 205 | return this; 206 | } finally { 207 | this._taskQueue.resolve(); 208 | } 209 | } 210 | /** 211 | * Resume the guild stream 212 | * @returns The guild queue 213 | */ 214 | async resume(): Promise { 215 | await this._taskQueue.queuing(); 216 | try { 217 | if (!this.paused) throw new DisTubeError("RESUMED"); 218 | this.paused = false; 219 | this.voice.unpause(); 220 | return this; 221 | } finally { 222 | this._taskQueue.resolve(); 223 | } 224 | } 225 | /** 226 | * Set the guild stream's volume 227 | * @param percent - The percentage of volume you want to set 228 | * @returns The guild queue 229 | */ 230 | setVolume(percent: number): Queue { 231 | this.volume = percent; 232 | return this; 233 | } 234 | 235 | /** 236 | * Skip the playing song if there is a next song in the queue. If {@link 237 | * Queue#autoplay} is `true` and there is no up next song, DisTube will add and 238 | * play a related song. 239 | * @returns The song will skip to 240 | */ 241 | async skip(): Promise { 242 | await this._taskQueue.queuing(); 243 | try { 244 | if (this.songs.length <= 1) { 245 | if (this.autoplay) await this.addRelatedSong(); 246 | else throw new DisTubeError("NO_UP_NEXT"); 247 | } 248 | const song = this.songs[1]; 249 | this._next = true; 250 | this.voice.stop(); 251 | return song; 252 | } finally { 253 | this._taskQueue.resolve(); 254 | } 255 | } 256 | 257 | /** 258 | * Play the previous song if exists 259 | * @returns The guild queue 260 | */ 261 | async previous(): Promise { 262 | await this._taskQueue.queuing(); 263 | try { 264 | if (!this.options.savePreviousSongs) throw new DisTubeError("DISABLED_OPTION", "savePreviousSongs"); 265 | if (this.previousSongs?.length === 0 && this.repeatMode !== RepeatMode.QUEUE) { 266 | throw new DisTubeError("NO_PREVIOUS"); 267 | } 268 | const song = 269 | this.repeatMode === 2 ? this.songs[this.songs.length - 1] : this.previousSongs[this.previousSongs.length - 1]; 270 | this._prev = true; 271 | this.voice.stop(); 272 | return song; 273 | } finally { 274 | this._taskQueue.resolve(); 275 | } 276 | } 277 | /** 278 | * Shuffle the queue's songs 279 | * @returns The guild queue 280 | */ 281 | async shuffle(): Promise { 282 | await this._taskQueue.queuing(); 283 | try { 284 | const playing = this.songs.shift(); 285 | if (playing === undefined) return this; 286 | for (let i = this.songs.length - 1; i > 0; i--) { 287 | const j = Math.floor(Math.random() * (i + 1)); 288 | [this.songs[i], this.songs[j]] = [this.songs[j], this.songs[i]]; 289 | } 290 | this.songs.unshift(playing); 291 | return this; 292 | } finally { 293 | this._taskQueue.resolve(); 294 | } 295 | } 296 | /** 297 | * Jump to the song position in the queue. The next one is 1, 2,... The previous 298 | * one is -1, -2,... 299 | * if `num` is invalid number 300 | * @param position - The song position to play 301 | * @returns The new Song will be played 302 | */ 303 | async jump(position: number): Promise { 304 | await this._taskQueue.queuing(); 305 | try { 306 | if (typeof position !== "number") throw new DisTubeError("INVALID_TYPE", "number", position, "position"); 307 | if (!position || position > this.songs.length || -position > this.previousSongs.length) { 308 | throw new DisTubeError("NO_SONG_POSITION"); 309 | } 310 | let nextSong: Song; 311 | if (position > 0) { 312 | const nextSongs = this.songs.splice(position - 1); 313 | if (this.options.savePreviousSongs) { 314 | this.previousSongs.push(...this.songs); 315 | } else { 316 | this.previousSongs.push(...this.songs.map(s => ({ id: s.id }) as Song)); 317 | } 318 | this.songs = nextSongs; 319 | this._next = true; 320 | nextSong = nextSongs[1]; 321 | } else if (!this.options.savePreviousSongs) { 322 | throw new DisTubeError("DISABLED_OPTION", "savePreviousSongs"); 323 | } else { 324 | this._prev = true; 325 | if (position !== -1) this.songs.unshift(...this.previousSongs.splice(position + 1)); 326 | nextSong = this.previousSongs[this.previousSongs.length - 1]; 327 | } 328 | this.voice.stop(); 329 | return nextSong; 330 | } finally { 331 | this._taskQueue.resolve(); 332 | } 333 | } 334 | /** 335 | * Set the repeat mode of the guild queue. 336 | * Toggle mode `(Disabled -> Song -> Queue -> Disabled ->...)` if `mode` is `undefined` 337 | * @param mode - The repeat modes (toggle if `undefined`) 338 | * @returns The new repeat mode 339 | */ 340 | setRepeatMode(mode?: RepeatMode): RepeatMode { 341 | if (mode !== undefined && !Object.values(RepeatMode).includes(mode)) { 342 | throw new DisTubeError("INVALID_TYPE", ["RepeatMode", "undefined"], mode, "mode"); 343 | } 344 | if (mode === undefined) this.repeatMode = (this.repeatMode + 1) % 3; 345 | else if (this.repeatMode === mode) this.repeatMode = RepeatMode.DISABLED; 346 | else this.repeatMode = mode; 347 | return this.repeatMode; 348 | } 349 | /** 350 | * Set the playing time to another position 351 | * @param time - Time in seconds 352 | * @returns The guild queue 353 | */ 354 | seek(time: number): Queue { 355 | if (typeof time !== "number") throw new DisTubeError("INVALID_TYPE", "number", time, "time"); 356 | if (isNaN(time) || time < 0) throw new DisTubeError("NUMBER_COMPARE", "time", "bigger or equal to", 0); 357 | this._beginTime = time; 358 | this.play(false); 359 | return this; 360 | } 361 | async #getRelatedSong(current: Song): Promise { 362 | const plugin = await this.handler._getPluginFromSong(current); 363 | if (plugin) return plugin.getRelatedSongs(current); 364 | return []; 365 | } 366 | /** 367 | * Add a related song of the playing song to the queue 368 | * @returns The added song 369 | */ 370 | async addRelatedSong(): Promise { 371 | const current = this.songs?.[0]; 372 | if (!current) throw new DisTubeError("NO_PLAYING_SONG"); 373 | const prevIds = this.previousSongs.map(p => p.id); 374 | const relatedSongs = (await this.#getRelatedSong(current)).filter(s => !prevIds.includes(s.id)); 375 | this.debug(`[${this.id}] Getting related songs from: ${current}`); 376 | if (!relatedSongs.length && !current.stream.playFromSource) { 377 | const altSong = current.stream.song; 378 | if (altSong) relatedSongs.push(...(await this.#getRelatedSong(altSong)).filter(s => !prevIds.includes(s.id))); 379 | this.debug(`[${this.id}] Getting related songs from streamed song: ${altSong}`); 380 | } 381 | const song = relatedSongs[0]; 382 | if (!song) throw new DisTubeError("NO_RELATED"); 383 | song.metadata = current.metadata; 384 | song.member = this.clientMember; 385 | this.addToQueue(song); 386 | return song; 387 | } 388 | /** 389 | * Stop the guild stream and delete the queue 390 | */ 391 | async stop() { 392 | await this._taskQueue.queuing(); 393 | try { 394 | this.voice.stop(); 395 | this.remove(); 396 | } finally { 397 | this._taskQueue.resolve(); 398 | } 399 | } 400 | /** 401 | * Remove the queue from the manager 402 | */ 403 | remove() { 404 | this.playing = false; 405 | this.paused = false; 406 | this.stopped = true; 407 | this.songs = []; 408 | this.previousSongs = []; 409 | if (this._listeners) for (const event of objectKeys(this._listeners)) this.voice.off(event, this._listeners[event]); 410 | this.queues.remove(this.id); 411 | this.emit(Events.DELETE_QUEUE, this); 412 | } 413 | /** 414 | * Toggle autoplay mode 415 | * @returns Autoplay mode state 416 | */ 417 | toggleAutoplay(): boolean { 418 | this.autoplay = !this.autoplay; 419 | return this.autoplay; 420 | } 421 | /** 422 | * Play the first song in the queue 423 | * @param emitPlaySong - Whether or not emit {@link Events.PLAY_SONG} event 424 | */ 425 | play(emitPlaySong = true) { 426 | if (this.stopped) throw new DisTubeError("QUEUE_STOPPED"); 427 | this.playing = true; 428 | return this.queues.playSong(this, emitPlaySong); 429 | } 430 | } 431 | -------------------------------------------------------------------------------- /src/struct/Song.ts: -------------------------------------------------------------------------------- 1 | import { Playlist } from "."; 2 | import { DisTubeError, formatDuration, isMemberInstance } from ".."; 3 | import type { GuildMember } from "discord.js"; 4 | import type { DisTubePlugin, ResolveOptions, SongInfo } from ".."; 5 | 6 | /** 7 | * Class representing a song. 8 | */ 9 | export class Song { 10 | /** 11 | * The source of this song info 12 | */ 13 | source: string; 14 | /** 15 | * Song ID. 16 | */ 17 | id: string; 18 | /** 19 | * Song name. 20 | */ 21 | name?: string; 22 | /** 23 | * Indicates if the song is an active live. 24 | */ 25 | isLive?: boolean; 26 | /** 27 | * Song duration. 28 | */ 29 | duration: number; 30 | /** 31 | * Formatted duration string (`hh:mm:ss`, `mm:ss` or `Live`). 32 | */ 33 | formattedDuration: string; 34 | /** 35 | * Song URL. 36 | */ 37 | url?: string; 38 | /** 39 | * Song thumbnail. 40 | */ 41 | thumbnail?: string; 42 | /** 43 | * Song view count 44 | */ 45 | views?: number; 46 | /** 47 | * Song like count 48 | */ 49 | likes?: number; 50 | /** 51 | * Song dislike count 52 | */ 53 | dislikes?: number; 54 | /** 55 | * Song repost (share) count 56 | */ 57 | reposts?: number; 58 | /** 59 | * Song uploader 60 | */ 61 | uploader: { 62 | name?: string; 63 | url?: string; 64 | }; 65 | /** 66 | * Whether or not an age-restricted content 67 | */ 68 | ageRestricted?: boolean; 69 | /** 70 | * Stream info 71 | */ 72 | stream: 73 | | { 74 | /** 75 | * The stream of this song will be played from source 76 | */ 77 | playFromSource: true; 78 | /** 79 | * Stream URL of this song 80 | */ 81 | url?: string; 82 | } 83 | | { 84 | /** 85 | * The stream of this song will be played from another song 86 | */ 87 | playFromSource: false; 88 | /** 89 | * The song that this song will be played from 90 | */ 91 | song?: Song; 92 | }; 93 | /** 94 | * The plugin that created this song 95 | */ 96 | plugin: DisTubePlugin | null; 97 | #metadata!: T; 98 | #member?: GuildMember; 99 | #playlist?: Playlist; 100 | /** 101 | * Create a Song 102 | * 103 | * @param info - Raw song info 104 | * @param options - Optional data 105 | */ 106 | constructor(info: SongInfo, { member, metadata }: ResolveOptions = {}) { 107 | this.source = info.source.toLowerCase(); 108 | this.metadata = metadata; 109 | this.member = member; 110 | this.id = info.id; 111 | this.name = info.name; 112 | this.isLive = info.isLive; 113 | this.duration = this.isLive || !info.duration ? 0 : info.duration; 114 | this.formattedDuration = this.isLive ? "Live" : formatDuration(this.duration); 115 | this.url = info.url; 116 | this.thumbnail = info.thumbnail; 117 | this.views = info.views; 118 | this.likes = info.likes; 119 | this.dislikes = info.dislikes; 120 | this.reposts = info.reposts; 121 | this.uploader = { 122 | name: info.uploader?.name, 123 | url: info.uploader?.url, 124 | }; 125 | this.ageRestricted = info.ageRestricted; 126 | this.stream = { playFromSource: info.playFromSource }; 127 | this.plugin = info.plugin; 128 | } 129 | 130 | /** 131 | * The playlist this song belongs to 132 | */ 133 | get playlist() { 134 | return this.#playlist; 135 | } 136 | 137 | set playlist(playlist: Playlist | undefined) { 138 | if (!(playlist instanceof Playlist)) throw new DisTubeError("INVALID_TYPE", "Playlist", playlist, "Song#playlist"); 139 | this.#playlist = playlist; 140 | this.member = playlist.member; 141 | } 142 | 143 | /** 144 | * User requested to play this song. 145 | */ 146 | get member() { 147 | return this.#member; 148 | } 149 | 150 | set member(member: GuildMember | undefined) { 151 | if (isMemberInstance(member)) this.#member = member; 152 | } 153 | 154 | /** 155 | * User requested to play this song. 156 | */ 157 | get user() { 158 | return this.member?.user; 159 | } 160 | 161 | /** 162 | * Optional metadata that can be used to identify the song. This is attached by the 163 | * {@link DisTube#play} method. 164 | */ 165 | get metadata() { 166 | return this.#metadata; 167 | } 168 | 169 | set metadata(metadata: T) { 170 | this.#metadata = metadata; 171 | } 172 | 173 | toString() { 174 | return this.name || this.url || this.id || "Unknown"; 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/struct/TaskQueue.ts: -------------------------------------------------------------------------------- 1 | class Task { 2 | resolve!: () => void; 3 | promise: Promise; 4 | isPlay: boolean; 5 | constructor(isPlay: boolean) { 6 | this.isPlay = isPlay; 7 | this.promise = new Promise(res => { 8 | this.resolve = res; 9 | }); 10 | } 11 | } 12 | 13 | /** 14 | * Task queuing system 15 | */ 16 | export class TaskQueue { 17 | /** 18 | * The task array 19 | */ 20 | #tasks: Task[] = []; 21 | 22 | /** 23 | * Waits for last task finished and queues a new task 24 | */ 25 | queuing(isPlay = false): Promise { 26 | const next = this.remaining ? this.#tasks[this.#tasks.length - 1].promise : Promise.resolve(); 27 | this.#tasks.push(new Task(isPlay)); 28 | return next; 29 | } 30 | 31 | /** 32 | * Removes the finished task and processes the next task 33 | */ 34 | resolve(): void { 35 | this.#tasks.shift()?.resolve(); 36 | } 37 | 38 | /** 39 | * The remaining number of tasks 40 | */ 41 | get remaining(): number { 42 | return this.#tasks.length; 43 | } 44 | 45 | /** 46 | * Whether or not having a play task 47 | */ 48 | get hasPlayTask(): boolean { 49 | return this.#tasks.some(t => t.isPlay); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/struct/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./DisTubeError"; 2 | export * from "./TaskQueue"; 3 | export * from "./Playlist"; 4 | export * from "./Song"; 5 | export * from "./Queue"; 6 | export * from "./Plugin"; 7 | export * from "./ExtractorPlugin"; 8 | export * from "./InfoExtratorPlugin"; 9 | export * from "./PlayableExtratorPlugin"; 10 | -------------------------------------------------------------------------------- /src/type.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | DisTubeError, 3 | DisTubeVoice, 4 | ExtractorPlugin, 5 | InfoExtractorPlugin, 6 | PlayableExtractorPlugin, 7 | Playlist, 8 | Queue, 9 | Song, 10 | } from "."; 11 | import type { 12 | Guild, 13 | GuildMember, 14 | GuildTextBasedChannel, 15 | Interaction, 16 | Message, 17 | Snowflake, 18 | VoiceBasedChannel, 19 | VoiceState, 20 | } from "discord.js"; 21 | 22 | export type Awaitable = T | PromiseLike; 23 | 24 | export enum Events { 25 | ERROR = "error", 26 | ADD_LIST = "addList", 27 | ADD_SONG = "addSong", 28 | PLAY_SONG = "playSong", 29 | FINISH_SONG = "finishSong", 30 | EMPTY = "empty", 31 | FINISH = "finish", 32 | INIT_QUEUE = "initQueue", 33 | NO_RELATED = "noRelated", 34 | DISCONNECT = "disconnect", 35 | DELETE_QUEUE = "deleteQueue", 36 | FFMPEG_DEBUG = "ffmpegDebug", 37 | DEBUG = "debug", 38 | } 39 | 40 | export type DisTubeEvents = { 41 | [Events.ADD_LIST]: [queue: Queue, playlist: Playlist]; 42 | [Events.ADD_SONG]: [queue: Queue, song: Song]; 43 | [Events.DELETE_QUEUE]: [queue: Queue]; 44 | [Events.DISCONNECT]: [queue: Queue]; 45 | [Events.ERROR]: [error: Error, queue: Queue, song: Song | undefined]; 46 | [Events.FFMPEG_DEBUG]: [debug: string]; 47 | [Events.DEBUG]: [debug: string]; 48 | [Events.FINISH]: [queue: Queue]; 49 | [Events.FINISH_SONG]: [queue: Queue, song: Song]; 50 | [Events.INIT_QUEUE]: [queue: Queue]; 51 | [Events.NO_RELATED]: [queue: Queue, error: DisTubeError]; 52 | [Events.PLAY_SONG]: [queue: Queue, song: Song]; 53 | }; 54 | 55 | export type TypedDisTubeEvents = { 56 | [K in keyof DisTubeEvents]: (...args: DisTubeEvents[K]) => Awaitable; 57 | }; 58 | 59 | export type DisTubeVoiceEvents = { 60 | disconnect: (error?: Error) => Awaitable; 61 | error: (error: Error) => Awaitable; 62 | finish: () => Awaitable; 63 | }; 64 | 65 | /** 66 | * An FFmpeg audio filter object 67 | * ```ts 68 | * { 69 | * name: "bassboost", 70 | * value: "bass=g=10" 71 | * } 72 | * ```ts 73 | */ 74 | export interface Filter { 75 | /** 76 | * Name of the filter 77 | */ 78 | name: string; 79 | /** 80 | * FFmpeg audio filter argument 81 | */ 82 | value: string; 83 | } 84 | 85 | /** 86 | * Data that resolves to give an FFmpeg audio filter. This can be: 87 | * - A name of a default filters or custom filters (`string`) 88 | * - A {@link Filter} object 89 | * @see {@link defaultFilters} 90 | * @see {@link DisTubeOptions|DisTubeOptions.customFilters} 91 | */ 92 | export type FilterResolvable = string | Filter; 93 | 94 | /** 95 | * FFmpeg Filters 96 | * ```ts 97 | * { 98 | * "Filter Name": "Filter Value", 99 | * "bassboost": "bass=g=10" 100 | * } 101 | * ``` 102 | * @see {@link defaultFilters} 103 | */ 104 | export type Filters = Record; 105 | 106 | /** 107 | * DisTube options 108 | */ 109 | export type DisTubeOptions = { 110 | /** 111 | * DisTube plugins. 112 | * The order of this effects the priority of the plugins when verifying the input. 113 | */ 114 | plugins?: DisTubePlugin[]; 115 | /** 116 | * Whether or not emitting {@link Events.PLAY_SONG} event when looping a song 117 | * or next song is the same as the previous one 118 | */ 119 | emitNewSongOnly?: boolean; 120 | /** 121 | * Whether or not saving the previous songs of the queue and enable {@link 122 | * DisTube#previous} method. Disable it may help to reduce the memory usage 123 | */ 124 | savePreviousSongs?: boolean; 125 | /** 126 | * Override {@link defaultFilters} or add more ffmpeg filters 127 | */ 128 | customFilters?: Filters; 129 | /** 130 | * Whether or not playing age-restricted content and disabling safe search in 131 | * non-NSFW channel 132 | */ 133 | nsfw?: boolean; 134 | /** 135 | * Whether or not emitting `addSong` event when creating a new Queue 136 | */ 137 | emitAddSongWhenCreatingQueue?: boolean; 138 | /** 139 | * Whether or not emitting `addList` event when creating a new Queue 140 | */ 141 | emitAddListWhenCreatingQueue?: boolean; 142 | /** 143 | * Whether or not joining the new voice channel when using {@link DisTube#play} 144 | * method 145 | */ 146 | joinNewVoiceChannel?: boolean; 147 | /** 148 | * FFmpeg options 149 | */ 150 | ffmpeg?: { 151 | /** 152 | * FFmpeg path 153 | */ 154 | path?: string; 155 | /** 156 | * FFmpeg default arguments 157 | */ 158 | args?: Partial; 159 | }; 160 | }; 161 | 162 | /** 163 | * Data that can be resolved to give a guild id string. This can be: 164 | * - A guild id string | a guild {@link https://discord.js.org/#/docs/main/stable/class/Snowflake|Snowflake} 165 | * - A {@link https://discord.js.org/#/docs/main/stable/class/Guild | Guild} 166 | * - A {@link https://discord.js.org/#/docs/main/stable/class/Message | Message} 167 | * - A {@link https://discord.js.org/#/docs/main/stable/class/BaseGuildVoiceChannel 168 | * | BaseGuildVoiceChannel} 169 | * - A {@link https://discord.js.org/#/docs/main/stable/class/BaseGuildTextChannel 170 | * | BaseGuildTextChannel} 171 | * - A {@link https://discord.js.org/#/docs/main/stable/class/VoiceState | 172 | * VoiceState} 173 | * - A {@link https://discord.js.org/#/docs/main/stable/class/GuildMember | 174 | * GuildMember} 175 | * - A {@link https://discord.js.org/#/docs/main/stable/class/Interaction | 176 | * Interaction} 177 | * - A {@link DisTubeVoice} 178 | * - A {@link Queue} 179 | */ 180 | export type GuildIdResolvable = 181 | | Queue 182 | | DisTubeVoice 183 | | Snowflake 184 | | Message 185 | | GuildTextBasedChannel 186 | | VoiceBasedChannel 187 | | VoiceState 188 | | Guild 189 | | GuildMember 190 | | Interaction 191 | | string; 192 | 193 | export interface SongInfo { 194 | plugin: DisTubePlugin | null; 195 | source: string; 196 | playFromSource: boolean; 197 | id: string; 198 | name?: string; 199 | isLive?: boolean; 200 | duration?: number; 201 | url?: string; 202 | thumbnail?: string; 203 | views?: number; 204 | likes?: number; 205 | dislikes?: number; 206 | reposts?: number; 207 | uploader?: { 208 | name?: string; 209 | url?: string; 210 | }; 211 | ageRestricted?: boolean; 212 | } 213 | 214 | export interface PlaylistInfo { 215 | source: string; 216 | songs: Song[]; 217 | id?: string; 218 | name?: string; 219 | url?: string; 220 | thumbnail?: string; 221 | } 222 | 223 | export type RelatedSong = Omit; 224 | 225 | export type PlayHandlerOptions = { 226 | /** 227 | * [Default: false] Skip the playing song (if exists) and play the added playlist 228 | * instantly 229 | */ 230 | skip?: boolean; 231 | /** 232 | * [Default: 0] Position of the song/playlist to add to the queue, \<= 0 to add to 233 | * the end of the queue 234 | */ 235 | position?: number; 236 | /** 237 | * The default text channel of the queue 238 | */ 239 | textChannel?: GuildTextBasedChannel; 240 | }; 241 | 242 | export interface PlayOptions extends PlayHandlerOptions, ResolveOptions { 243 | /** 244 | * Called message (For built-in search events. If this is a {@link 245 | * https://developer.mozilla.org/en-US/docs/Glossary/Falsy | falsy value}, it will 246 | * play the first result instead) 247 | */ 248 | message?: Message; 249 | } 250 | 251 | export interface ResolveOptions { 252 | /** 253 | * Requested user 254 | */ 255 | member?: GuildMember; 256 | /** 257 | * Metadata 258 | */ 259 | metadata?: T; 260 | } 261 | 262 | export interface ResolvePlaylistOptions extends ResolveOptions { 263 | /** 264 | * Source of the playlist 265 | */ 266 | source?: string; 267 | } 268 | 269 | export interface CustomPlaylistOptions { 270 | /** 271 | * A guild member creating the playlist 272 | */ 273 | member?: GuildMember; 274 | /** 275 | * Whether or not fetch the songs in parallel 276 | */ 277 | parallel?: boolean; 278 | /** 279 | * Metadata 280 | */ 281 | metadata?: any; 282 | /** 283 | * Playlist name 284 | */ 285 | name?: string; 286 | /** 287 | * Playlist source 288 | */ 289 | source?: string; 290 | /** 291 | * Playlist url 292 | */ 293 | url?: string; 294 | /** 295 | * Playlist thumbnail 296 | */ 297 | thumbnail?: string; 298 | } 299 | 300 | /** 301 | * The repeat mode of a {@link Queue} 302 | * - `DISABLED` = 0 303 | * - `SONG` = 1 304 | * - `QUEUE` = 2 305 | */ 306 | export enum RepeatMode { 307 | DISABLED, 308 | SONG, 309 | QUEUE, 310 | } 311 | 312 | /** 313 | * All available plugin types: 314 | * - `EXTRACTOR` = `"extractor"`: {@link ExtractorPlugin} 315 | * - `INFO_EXTRACTOR` = `"info-extractor"`: {@link InfoExtractorPlugin} 316 | * - `PLAYABLE_EXTRACTOR` = `"playable-extractor"`: {@link PlayableExtractorPlugin} 317 | */ 318 | export enum PluginType { 319 | EXTRACTOR = "extractor", 320 | INFO_EXTRACTOR = "info-extractor", 321 | PLAYABLE_EXTRACTOR = "playable-extractor", 322 | } 323 | 324 | export type DisTubePlugin = ExtractorPlugin | InfoExtractorPlugin | PlayableExtractorPlugin; 325 | 326 | export type FFmpegArg = Record | null | undefined>; 327 | 328 | /** 329 | * FFmpeg arguments for different use cases 330 | */ 331 | export type FFmpegArgs = { 332 | global: FFmpegArg; 333 | input: FFmpegArg; 334 | output: FFmpegArg; 335 | }; 336 | 337 | /** 338 | * FFmpeg options 339 | */ 340 | export type FFmpegOptions = { 341 | /** 342 | * Path to the ffmpeg executable 343 | */ 344 | path: string; 345 | /** 346 | * Arguments 347 | */ 348 | args: FFmpegArgs; 349 | }; 350 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { URL } from "url"; 2 | import { DisTubeError, DisTubeVoice, Queue } from "."; 3 | import { Constants, GatewayIntentBits, IntentsBitField, SnowflakeUtil } from "discord.js"; 4 | import type { GuildIdResolvable } from "."; 5 | import type { 6 | Client, 7 | ClientOptions, 8 | Guild, 9 | GuildMember, 10 | GuildTextBasedChannel, 11 | Message, 12 | Snowflake, 13 | VoiceBasedChannel, 14 | VoiceState, 15 | } from "discord.js"; 16 | 17 | const formatInt = (int: number) => (int < 10 ? `0${int}` : int); 18 | 19 | /** 20 | * Format duration to string 21 | * @param sec - Duration in seconds 22 | */ 23 | export function formatDuration(sec: number): string { 24 | if (!sec || !Number(sec)) return "00:00"; 25 | const seconds = Math.floor(sec % 60); 26 | const minutes = Math.floor((sec % 3600) / 60); 27 | const hours = Math.floor(sec / 3600); 28 | if (hours > 0) return `${formatInt(hours)}:${formatInt(minutes)}:${formatInt(seconds)}`; 29 | if (minutes > 0) return `${formatInt(minutes)}:${formatInt(seconds)}`; 30 | return `00:${formatInt(seconds)}`; 31 | } 32 | const SUPPORTED_PROTOCOL = ["https:", "http:", "file:"] as const; 33 | /** 34 | * Check if the string is an URL 35 | * @param input - input 36 | */ 37 | export function isURL(input: any): input is `${(typeof SUPPORTED_PROTOCOL)[number]}//${string}` { 38 | if (typeof input !== "string" || input.includes(" ")) return false; 39 | try { 40 | const url = new URL(input); 41 | if (!SUPPORTED_PROTOCOL.some(p => p === url.protocol)) return false; 42 | } catch { 43 | return false; 44 | } 45 | return true; 46 | } 47 | /** 48 | * Check if the Client has enough intents to using DisTube 49 | * @param options - options 50 | */ 51 | export function checkIntents(options: ClientOptions): void { 52 | const intents = options.intents instanceof IntentsBitField ? options.intents : new IntentsBitField(options.intents); 53 | if (!intents.has(GatewayIntentBits.GuildVoiceStates)) throw new DisTubeError("MISSING_INTENTS", "GuildVoiceStates"); 54 | } 55 | 56 | /** 57 | * Check if the voice channel is empty 58 | * @param voiceState - voiceState 59 | */ 60 | export function isVoiceChannelEmpty(voiceState: VoiceState): boolean { 61 | const guild = voiceState.guild; 62 | const clientId = voiceState.client.user?.id; 63 | if (!guild || !clientId) return false; 64 | const voiceChannel = guild.members.me?.voice?.channel; 65 | if (!voiceChannel) return false; 66 | const members = voiceChannel.members.filter(m => !m.user.bot); 67 | return !members.size; 68 | } 69 | 70 | export function isSnowflake(id: any): id is Snowflake { 71 | try { 72 | return SnowflakeUtil.deconstruct(id).timestamp > SnowflakeUtil.epoch; 73 | } catch { 74 | return false; 75 | } 76 | } 77 | 78 | export function isMemberInstance(member: any): member is GuildMember { 79 | return ( 80 | Boolean(member) && 81 | isSnowflake(member.id) && 82 | isSnowflake(member.guild?.id) && 83 | isSnowflake(member.user?.id) && 84 | member.id === member.user.id 85 | ); 86 | } 87 | 88 | export function isTextChannelInstance(channel: any): channel is GuildTextBasedChannel { 89 | return ( 90 | Boolean(channel) && 91 | isSnowflake(channel.id) && 92 | isSnowflake(channel.guildId || channel.guild?.id) && 93 | Constants.TextBasedChannelTypes.includes(channel.type) && 94 | typeof channel.send === "function" && 95 | (typeof channel.nsfw === "boolean" || typeof channel.parent?.nsfw === "boolean") 96 | ); 97 | } 98 | 99 | export function isMessageInstance(message: any): message is Message { 100 | // Simple check for using distube normally 101 | return ( 102 | Boolean(message) && 103 | isSnowflake(message.id) && 104 | isSnowflake(message.guildId || message.guild?.id) && 105 | isMemberInstance(message.member) && 106 | isTextChannelInstance(message.channel) && 107 | Constants.NonSystemMessageTypes.includes(message.type) && 108 | message.member.id === message.author?.id 109 | ); 110 | } 111 | 112 | export function isSupportedVoiceChannel(channel: any): channel is VoiceBasedChannel { 113 | return ( 114 | Boolean(channel) && 115 | isSnowflake(channel.id) && 116 | isSnowflake(channel.guildId || channel.guild?.id) && 117 | Constants.VoiceBasedChannelTypes.includes(channel.type) 118 | ); 119 | } 120 | 121 | export function isGuildInstance(guild: any): guild is Guild { 122 | return Boolean(guild) && isSnowflake(guild.id) && isSnowflake(guild.ownerId) && typeof guild.name === "string"; 123 | } 124 | 125 | export function resolveGuildId(resolvable: GuildIdResolvable): Snowflake { 126 | let guildId: string | undefined; 127 | if (typeof resolvable === "string") { 128 | guildId = resolvable; 129 | } else if (isObject(resolvable)) { 130 | if ("guildId" in resolvable && resolvable.guildId) { 131 | guildId = resolvable.guildId; 132 | } else if (resolvable instanceof Queue || resolvable instanceof DisTubeVoice || isGuildInstance(resolvable)) { 133 | guildId = resolvable.id; 134 | } else if ("guild" in resolvable && isGuildInstance(resolvable.guild)) { 135 | guildId = resolvable.guild.id; 136 | } 137 | } 138 | if (!isSnowflake(guildId)) throw new DisTubeError("INVALID_TYPE", "GuildIdResolvable", resolvable); 139 | return guildId; 140 | } 141 | 142 | export function isClientInstance(client: any): client is Client { 143 | return Boolean(client) && typeof client.login === "function"; 144 | } 145 | 146 | export function checkInvalidKey( 147 | target: Record, 148 | source: Record | string[], 149 | sourceName: string, 150 | ) { 151 | if (!isObject(target)) throw new DisTubeError("INVALID_TYPE", "object", target, sourceName); 152 | const sourceKeys = Array.isArray(source) ? source : objectKeys(source); 153 | const invalidKey = objectKeys(target).find(key => !sourceKeys.includes(key)); 154 | if (invalidKey) throw new DisTubeError("INVALID_KEY", sourceName, invalidKey); 155 | } 156 | 157 | export function isObject(obj: any): obj is object { 158 | return typeof obj === "object" && obj !== null && !Array.isArray(obj); 159 | } 160 | 161 | export type KeyOf = T extends object ? (keyof T)[] : []; 162 | export function objectKeys(obj: T): KeyOf { 163 | if (!isObject(obj)) return [] as KeyOf; 164 | return Object.keys(obj) as KeyOf; 165 | } 166 | 167 | export function isNsfwChannel(channel?: GuildTextBasedChannel): boolean { 168 | if (!isTextChannelInstance(channel)) return false; 169 | if (channel.isThread()) return channel.parent?.nsfw ?? false; 170 | return channel.nsfw; 171 | } 172 | 173 | export type Falsy = undefined | null | false | 0 | ""; 174 | export const isTruthy = (x: T | Falsy): x is T => Boolean(x); 175 | 176 | export const checkEncryptionLibraries = async () => { 177 | if (await import("node:crypto").then(m => m.getCiphers().includes("aes-256-gcm"))) return true; 178 | for (const lib of [ 179 | "@noble/ciphers", 180 | "@stablelib/xchacha20poly1305", 181 | "sodium-native", 182 | "sodium", 183 | "libsodium-wrappers", 184 | "tweetnacl", 185 | ]) { 186 | try { 187 | await import(lib); 188 | return true; 189 | } catch {} 190 | } 191 | return false; 192 | }; 193 | -------------------------------------------------------------------------------- /tests/core/DisTubeOptions.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { Options, defaultOptions } from "@"; 3 | 4 | test("Default DisTubeOptions", () => { 5 | expect(new Options({})).toEqual({ 6 | emitAddListWhenCreatingQueue: true, 7 | emitAddSongWhenCreatingQueue: true, 8 | emitNewSongOnly: false, 9 | ffmpeg: { 10 | args: { 11 | global: {}, 12 | input: {}, 13 | output: {}, 14 | }, 15 | path: "ffmpeg", 16 | }, 17 | joinNewVoiceChannel: true, 18 | nsfw: false, 19 | plugins: [], 20 | savePreviousSongs: true, 21 | }); 22 | }); 23 | 24 | const typeOfOption = (option: string) => { 25 | switch (option) { 26 | case "plugins": 27 | return "Array"; 28 | default: 29 | return typeof defaultOptions[option]; 30 | } 31 | }; 32 | 33 | test("Validate DisTubeOptions", () => { 34 | expect(() => { 35 | new Options(NaN); 36 | }).toThrow("Expected 'object' for 'DisTubeOptions', but got NaN"); 37 | for (const option of Object.keys(defaultOptions)) { 38 | const options = {}; 39 | options[option] = NaN; 40 | expect(() => { 41 | new Options(options); 42 | }).toThrow(`Expected '${typeOfOption(option)}' for 'DisTubeOptions.${option}', but got NaN`); 43 | } 44 | expect(() => { 45 | new Options({ invalidKey: "an invalid key" } as any); 46 | }).toThrow("'invalidKey' does not need to be provided in DisTubeOptions"); 47 | }); 48 | -------------------------------------------------------------------------------- /tests/core/manager/DisTubeVoiceManager.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, expect, test, vi } from "vitest"; 2 | import { DisTubeVoiceManager, DisTubeVoice as _DTV } from "@"; 3 | import { VoiceConnectionStatus, getVoiceConnection } from "@discordjs/voice"; 4 | import type { Mock, Mocked } from "vitest"; 5 | 6 | vi.mock("@/core/DisTubeVoice"); 7 | vi.mock( 8 | "@discordjs/voice", 9 | async importOriginal => 10 | { 11 | ...(await importOriginal()), 12 | getVoiceConnection: vi.fn(), 13 | }, 14 | ); 15 | vi.mock( 16 | "@/util", 17 | async importOriginal => 18 | { 19 | ...(await importOriginal()), 20 | isSupportedVoiceChannel: () => true, 21 | }, 22 | ); 23 | 24 | const DisTubeVoice: Mocked = _DTV; 25 | 26 | beforeEach(() => { 27 | vi.clearAllMocks(); 28 | }); 29 | 30 | function createFakeDisTube() { 31 | return { 32 | client: { user: { id: "123" } }, 33 | }; 34 | } 35 | 36 | const distube = createFakeDisTube(); 37 | const manager = new DisTubeVoiceManager(distube as any); 38 | const channel1: any = { 39 | guildId: "123456789123456789", 40 | }; 41 | const channel2: any = { 42 | guildId: "123456789012345678", 43 | }; 44 | 45 | test("DisTubeVoiceManager#create()", () => { 46 | const voice = manager.create(channel1); 47 | manager.add(channel1.guildId, voice); 48 | expect(DisTubeVoice).toHaveBeenCalledTimes(1); 49 | expect(manager.get(channel1)).toBe(voice); 50 | const setter = vi.spyOn(voice, "channel", "set").mockImplementation(() => null); 51 | const existing = manager.create(channel1); 52 | expect(existing).toBe(voice); 53 | expect(DisTubeVoice).toHaveBeenCalledTimes(1); 54 | expect(manager.get(channel1)).toBe(voice); 55 | expect(setter).toHaveBeenCalledTimes(1); 56 | }); 57 | 58 | test("DisTubeVoiceManager#join()", () => { 59 | manager.join(channel1); 60 | expect(manager.get(channel1).join).toHaveBeenCalledTimes(1); 61 | expect(manager.get(channel1).join).toHaveBeenCalledWith(channel1); 62 | manager.create = vi.fn(); 63 | const fVoice = { join: vi.fn() }; 64 | (manager.create).mockReturnValueOnce(fVoice); 65 | manager.join(channel2); 66 | expect(manager.create).toHaveBeenCalledWith(channel2); 67 | expect(fVoice.join).toHaveBeenCalledTimes(1); 68 | }); 69 | 70 | test("DisTubeVoiceManager#leave()", () => { 71 | manager.leave(channel1); 72 | expect(manager.get(channel1).leave).toHaveBeenCalledTimes(1); 73 | const fConnection = { 74 | destroy: vi.fn(), 75 | state: { 76 | status: VoiceConnectionStatus.Destroyed, 77 | }, 78 | }; 79 | (getVoiceConnection).mockReturnValue(fConnection); 80 | manager.leave(channel2); 81 | expect(getVoiceConnection).toHaveBeenNthCalledWith(1, channel2.guildId, distube.client.user.id); 82 | expect(fConnection.destroy).not.toHaveBeenCalled(); 83 | fConnection.state.status = VoiceConnectionStatus.Ready; 84 | manager.leave(channel2); 85 | expect(getVoiceConnection).toHaveBeenNthCalledWith(1, channel2.guildId, distube.client.user.id); 86 | expect(fConnection.destroy).toHaveBeenCalledTimes(1); 87 | }); 88 | -------------------------------------------------------------------------------- /tests/core/manager/FilterManager.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, test, vi } from "vitest"; 2 | import { DisTubeError, FilterManager, defaultFilters } from "@"; 3 | import type { FilterResolvable, Queue } from "@"; 4 | 5 | const play = vi.fn(); 6 | const queue = ({ distube: { filters: defaultFilters }, play }); 7 | 8 | beforeEach(() => { 9 | vi.clearAllMocks(); 10 | }); 11 | 12 | const fNames = Object.keys(defaultFilters); 13 | const fValues = Object.values(defaultFilters); 14 | 15 | describe("FilterManager#add()", () => { 16 | const filters = new FilterManager(queue); 17 | test("Add a filter", () => { 18 | filters.add("3d"); 19 | expect(filters.collection.size).toBe(1); 20 | expect(filters.has("3d")).toBe(true); 21 | expect(filters.collection.get("3d")).toEqual({ name: "3d", value: defaultFilters["3d"] }); 22 | expect(filters.names).toEqual(["3d"]); 23 | expect(filters.values).toEqual([{ name: "3d", value: defaultFilters["3d"] }]); 24 | expect(filters.ffmpegArgs).toEqual({ af: defaultFilters["3d"] }); 25 | expect(play).toHaveBeenCalledTimes(1); 26 | }); 27 | test("Add a filter without override", () => { 28 | const oldFilter = filters.collection.get("3d"); 29 | filters.add("3d", false); 30 | expect(filters.collection.size).toBe(1); 31 | expect(filters.has("3d")).toBe(true); 32 | expect(filters.collection.get("3d")).toBe(oldFilter); 33 | expect(filters.names).toEqual(["3d"]); 34 | expect(filters.values).toEqual([oldFilter]); 35 | expect(filters.ffmpegArgs).toEqual({ af: oldFilter.value }); 36 | expect(play).toHaveBeenCalledTimes(1); 37 | }); 38 | test("Add a filter with override", () => { 39 | const oldFilter = filters.collection.get("3d"); 40 | filters.add("3d", true); 41 | expect(filters.collection.size).toBe(1); 42 | expect(filters.has("3d")).toBe(true); 43 | expect(filters.collection.get("3d")).not.toBe(oldFilter); 44 | expect(filters.collection.get("3d")).toEqual(oldFilter); 45 | expect(filters.names).toEqual(["3d"]); 46 | expect(filters.values).toEqual([oldFilter]); 47 | expect(filters.ffmpegArgs).toEqual({ af: oldFilter.value }); 48 | expect(play).toHaveBeenCalledTimes(1); 49 | }); 50 | test("Add multiple filters without override", () => { 51 | const oldFilter = filters.collection.get("3d"); 52 | filters.add(fNames); 53 | expect(filters.collection.size).toBe(fNames.length); 54 | for (const filter of fNames) { 55 | expect(filters.has(filter)).toBe(true); 56 | expect(filters.collection.get(filter)).toEqual({ name: filter, value: defaultFilters[filter] }); 57 | } 58 | expect(filters.collection.get("3d")).toBe(oldFilter); 59 | expect(filters.names).toEqual(fNames); 60 | expect(filters.values).toEqual(fNames.map(f => ({ name: f, value: defaultFilters[f] }))); 61 | expect(filters.ffmpegArgs).toEqual({ af: fValues.join(",") }); 62 | expect(play).toHaveBeenCalledTimes(1); 63 | }); 64 | test("Add multiple filters with override", () => { 65 | const oldFilters = filters.collection.clone(); 66 | filters.add(fNames, true); 67 | expect(filters.collection.size).toBe(fNames.length); 68 | for (const filter of fNames) { 69 | expect(filters.has(filter)).toBe(true); 70 | expect(filters.collection.get(filter)).not.toBe(oldFilters.get(filter)); 71 | expect(filters.collection.get(filter)).toEqual({ name: filter, value: defaultFilters[filter] }); 72 | } 73 | expect(filters.names).toEqual(fNames); 74 | expect(filters.values).toEqual(fNames.map(f => ({ name: f, value: defaultFilters[f] }))); 75 | expect(filters.ffmpegArgs).toEqual({ af: fValues.join(",") }); 76 | expect(play).toHaveBeenCalledTimes(1); 77 | }); 78 | test("Add a custom filter", () => { 79 | const customFilter = { name: "custom", value: "customValue" }; 80 | const oldFilters = filters.collection.clone(); 81 | filters.add(customFilter); 82 | expect(filters.collection.size).toBe(fNames.length + 1); 83 | expect(filters.has(customFilter.name)).toBe(true); 84 | expect(filters.collection.get(customFilter.name)).toEqual(customFilter); 85 | for (const filter of fNames) { 86 | expect(filters.has(filter)).toBe(true); 87 | expect(filters.collection.get(filter)).toBe(oldFilters.get(filter)); 88 | } 89 | expect(filters.names).toEqual([...fNames, customFilter.name]); 90 | expect(filters.values).toEqual([...fNames.map(f => ({ name: f, value: defaultFilters[f] })), customFilter]); 91 | expect(filters.ffmpegArgs).toEqual({ af: [...fValues, customFilter.value].join(",") }); 92 | expect(play).toHaveBeenCalledTimes(1); 93 | }); 94 | test("Add an invalid filter", () => { 95 | expect(() => filters.add(["invalid"])).toThrow( 96 | new DisTubeError("INVALID_TYPE", "FilterResolvable", "invalid", "filter"), 97 | ); 98 | expect(() => filters.add({ name: "invalid", value: 1 } as any)).toThrow( 99 | new DisTubeError("INVALID_TYPE", "FilterResolvable", { name: "invalid", value: 1 }, "filter"), 100 | ); 101 | expect(play).toHaveBeenCalledTimes(0); 102 | }); 103 | }); 104 | 105 | describe("FilterManager#remove()", () => { 106 | const filters = new FilterManager(queue); 107 | filters.add(fNames); 108 | test("Remove a filter", () => { 109 | filters.remove("3d"); 110 | expect(filters.collection.size).toBe(fNames.length - 1); 111 | expect(filters.has("3d")).toBe(false); 112 | for (const filter of fNames) { 113 | if (filter === "3d") continue; 114 | expect(filters.has(filter)).toBe(true); 115 | } 116 | expect(filters.names).toEqual(fNames.filter(f => f !== "3d")); 117 | expect(filters.values).toEqual(fNames.filter(f => f !== "3d").map(f => ({ name: f, value: defaultFilters[f] }))); 118 | expect(filters.ffmpegArgs).toEqual({ af: fValues.filter(f => f !== defaultFilters["3d"]).join(",") }); 119 | expect(play).toHaveBeenCalledTimes(1); 120 | }); 121 | test("Remove multiple filters", () => { 122 | // Remove some filters, "3d" filter is already removed 123 | const removedFilters = ["3d", "bassboost", "echo", "flanger"]; 124 | const restFilters = fNames.filter(f => !removedFilters.includes(f)); 125 | filters.remove(removedFilters); 126 | expect(filters.collection.size).toBe(restFilters.length); 127 | for (const filter of fNames) { 128 | expect(filters.has(filter)).toBe(!removedFilters.includes(filter)); 129 | } 130 | expect(filters.names).toEqual(restFilters); 131 | expect(filters.values).toEqual(restFilters.map(f => ({ name: f, value: defaultFilters[f] }))); 132 | expect(filters.ffmpegArgs).toEqual({ af: restFilters.map(f => defaultFilters[f]).join(",") }); 133 | expect(play).toHaveBeenCalledTimes(1); 134 | }); 135 | }); 136 | 137 | describe("FilterManager#set()", () => { 138 | const filters = new FilterManager(queue); 139 | test("Set a filter", () => { 140 | filters.set(["3d"]); 141 | expect(filters.collection.size).toBe(1); 142 | expect(filters.has("3d")).toBe(true); 143 | expect(filters.collection.get("3d")).toEqual({ name: "3d", value: defaultFilters["3d"] }); 144 | expect(filters.names).toEqual(["3d"]); 145 | expect(filters.values).toEqual([{ name: "3d", value: defaultFilters["3d"] }]); 146 | expect(filters.ffmpegArgs).toEqual({ af: defaultFilters["3d"] }); 147 | expect(play).toHaveBeenCalledTimes(1); 148 | }); 149 | test("Set multiple filters", () => { 150 | const oldFilter = filters.collection.get("3d"); 151 | const newFilters: FilterResolvable[] = fNames; 152 | newFilters.push({ name: "custom", value: "customValue" }); 153 | filters.set(newFilters); 154 | expect(filters.collection.size).toBe(newFilters.length); 155 | for (const filter of newFilters) { 156 | if (typeof filter === "string") { 157 | expect(filters.has(filter)).toBe(true); 158 | expect(filters.collection.get(filter)).toEqual({ name: filter, value: defaultFilters[filter] }); 159 | } else { 160 | expect(filters.has(filter.name)).toBe(true); 161 | expect(filters.collection.get(filter.name)).toBe(filter); 162 | } 163 | } 164 | expect(filters.collection.get("3d")).not.toBe(oldFilter); 165 | expect(filters.names).toEqual(newFilters.map(f => (typeof f === "string" ? f : f.name))); 166 | expect(filters.toString()).toEqual(newFilters.map(f => (typeof f === "string" ? f : f.name)).toString()); 167 | expect(filters.values).toEqual( 168 | newFilters.map(f => (typeof f === "string" ? { name: f, value: defaultFilters[f] } : f)), 169 | ); 170 | expect(filters.ffmpegArgs).toEqual({ 171 | af: newFilters.map(f => (typeof f === "string" ? defaultFilters[f] : f.value)).join(","), 172 | }); 173 | expect(play).toHaveBeenCalledTimes(1); 174 | }); 175 | test("Set with invalid arguments", () => { 176 | expect(() => filters.set({ name: "invalid", value: 1 })).toThrow( 177 | new DisTubeError("INVALID_TYPE", "Array", { name: "invalid", value: 1 }, "filters"), 178 | ); 179 | expect(() => filters.set(0)).toThrow( 180 | new DisTubeError("INVALID_TYPE", "Array", 0, "filters"), 181 | ); 182 | expect(play).toHaveBeenCalledTimes(0); 183 | }); 184 | test("FilterManager#clear()", () => { 185 | filters.clear(); 186 | expect(filters.collection.size).toBe(0); 187 | expect(filters.names).toEqual([]); 188 | expect(filters.values).toEqual([]); 189 | expect(filters.ffmpegArgs).toEqual({}); 190 | expect(play).toHaveBeenCalledTimes(1); 191 | }); 192 | }); 193 | -------------------------------------------------------------------------------- /tests/raw/discord.ts: -------------------------------------------------------------------------------- 1 | export const rawClientUser = { 2 | verified: true, 3 | username: "Test", 4 | mfa_enabled: true, 5 | id: "732441066992566304", 6 | flags: 0, 7 | discriminator: "0000", 8 | bot: true, 9 | avatar: "", 10 | }; 11 | 12 | export const rawGuild = { 13 | member_count: 15, 14 | preferred_locale: "en-US", 15 | stage_instances: [], 16 | stickers: [], 17 | voice_states: [], 18 | icon: "", 19 | application_command_count: 89, 20 | afk_channel_id: null, 21 | mfa_level: 0, 22 | unavailable: false, 23 | large: false, 24 | system_channel_flags: 0, 25 | vanity_url_code: null, 26 | verification_level: 1, 27 | public_updates_channel_id: "835876744317108254", 28 | max_video_channel_users: 25, 29 | name: "Test", 30 | rules_channel_id: "835876743847739454", 31 | channels: [ 32 | { 33 | type: 0, 34 | topic: null, 35 | rate_limit_per_user: 0, 36 | position: 3, 37 | permission_overwrites: [], 38 | parent_id: "737499503384461323", 39 | nsfw: false, 40 | name: "text", 41 | last_message_id: "863623849421963284", 42 | id: "737499503384461325", 43 | }, 44 | { 45 | user_limit: 10000, 46 | type: 13, 47 | topic: null, 48 | rtc_region: null, 49 | position: 0, 50 | permission_overwrites: [], 51 | parent_id: "737499503384461324", 52 | nsfw: false, 53 | name: "Stage", 54 | id: "835876864458489857", 55 | bitrate: 64000, 56 | }, 57 | { 58 | user_limit: 0, 59 | type: 2, 60 | rtc_region: null, 61 | position: 2, 62 | permission_overwrites: [], 63 | parent_id: "737499503384461324", 64 | nsfw: true, 65 | name: "Voice", 66 | id: "853225781604646933", 67 | bitrate: 64000, 68 | }, 69 | { 70 | type: 11, 71 | total_message_sent: 1, 72 | thread_metadata: { 73 | locked: false, 74 | create_timestamp: "2023-04-20T09:38:55.799000+00:00", 75 | auto_archive_duration: 4320, 76 | archived: false, 77 | archive_timestamp: "2023-04-20T09:38:55.799000+00:00", 78 | }, 79 | rate_limit_per_user: 0, 80 | parent_id: "737499503384461324", 81 | owner_id: "653848088280301571", 82 | name: "test", 83 | message_count: 1, 84 | member_count: 1, 85 | last_message_id: "1098543458756612146", 86 | id: "1098543313134563338", 87 | guild_id: "737499502763704370", 88 | flags: 0, 89 | }, 90 | { 91 | id: "737499503384461324", 92 | type: 15, 93 | last_message_id: "1086790132935442432", 94 | flags: 0, 95 | guild_id: "737499502763704370", 96 | name: "nsfw-test-forum", 97 | parent_id: null, 98 | rate_limit_per_user: 0, 99 | topic: null, 100 | position: 0, 101 | permission_overwrites: [], 102 | nsfw: true, 103 | available_tags: [], 104 | default_reaction_emoji: null, 105 | default_sort_order: null, 106 | default_forum_layout: 0, 107 | icon_emoji: null, 108 | theme_color: null, 109 | template: "", 110 | }, 111 | ], 112 | presences: [], 113 | banner: null, 114 | afk_timeout: 300, 115 | explicit_content_filter: 2, 116 | lazy: true, 117 | features: ["COMMUNITY", "NEWS"], 118 | premium_subscription_count: 0, 119 | description: null, 120 | region: "singapore", 121 | nsfw: false, 122 | splash: null, 123 | premium_tier: 0, 124 | system_channel_id: "737499503384461325", 125 | nsfw_level: 0, 126 | default_message_notifications: 0, 127 | emojis: [], 128 | joined_at: "2021-02-05T10:41:10.307000+00:00", 129 | discovery_splash: null, 130 | members: [ 131 | { 132 | user: { 133 | username: "Skick", 134 | public_flags: 131136, 135 | id: "653848088280301571", 136 | discriminator: "4724", 137 | avatar: "", 138 | }, 139 | roles: [], 140 | mute: false, 141 | joined_at: "2020-07-28T02:39:43.588000+00:00", 142 | hoisted_role: null, 143 | deaf: false, 144 | }, 145 | { 146 | user: { 147 | username: "Test", 148 | id: "732441066992566304", 149 | discriminator: "0000", 150 | bot: true, 151 | avatar: "", 152 | }, 153 | roles: [], 154 | mute: false, 155 | joined_at: "2021-02-05T10:41:10.307000+00:00", 156 | hoisted_role: null, 157 | deaf: true, 158 | }, 159 | ], 160 | max_members: 100000, 161 | threads: [], 162 | id: "737499502763704370", 163 | roles: [], 164 | guild_hashes: { 165 | version: 1, 166 | roles: { omitted: false, hash: "OnVCeDuVZdU" }, 167 | metadata: { omitted: false, hash: "5nPQTrBMzgo" }, 168 | channels: { omitted: false, hash: "WCSDpgoi6A8" }, 169 | }, 170 | owner_id: "653848088280301571", 171 | application_id: null, 172 | }; 173 | 174 | export const rawMessage = { 175 | type: 0, 176 | tts: false, 177 | timestamp: "2222-02-22T22:22:22.000000+00:00", 178 | referenced_message: null, 179 | pinned: false, 180 | mentions: [], 181 | mention_roles: [], 182 | mention_everyone: false, 183 | member: { 184 | roles: ["807199132669444117"], 185 | mute: false, 186 | joined_at: "2021-02-05T10:41:10.307000+00:00", 187 | hoisted_role: null, 188 | deaf: true, 189 | }, 190 | id: "863630752813285376", 191 | flags: 0, 192 | embeds: [], 193 | edited_timestamp: null, 194 | content: "", 195 | components: [], 196 | channel_id: "737499503384461325", 197 | author: { 198 | public_flags: 0, 199 | username: "Test", 200 | id: "732441066992566304", 201 | discriminator: "0000", 202 | bot: true, 203 | avatar: "", 204 | }, 205 | attachments: [], 206 | guild_id: "737499502763704370", 207 | }; 208 | 209 | export const rawUserVoiceState = { 210 | member: { 211 | user: { 212 | username: "Skick", 213 | public_flags: 131136, 214 | id: "653848088280301571", 215 | discriminator: "4724", 216 | avatar: "", 217 | }, 218 | roles: [], 219 | mute: false, 220 | joined_at: "2020-07-28T02:39:43.588000+00:00", 221 | hoisted_role: null, 222 | deaf: false, 223 | }, 224 | user_id: "653848088280301571", 225 | suppress: false, 226 | session_id: "bac0662e110d208565d5cbd35ee2a200", 227 | self_video: false, 228 | self_mute: false, 229 | self_deaf: false, 230 | request_to_speak_timestamp: null, 231 | mute: false, 232 | guild_id: "737499502763704370", 233 | deaf: false, 234 | channel_id: "853225781604646933", 235 | }; 236 | 237 | export const rawBotVoiceState = { 238 | member: { 239 | user: { 240 | username: "Test", 241 | id: "732441066992566304", 242 | discriminator: "0000", 243 | bot: true, 244 | avatar: "", 245 | }, 246 | roles: [], 247 | mute: false, 248 | joined_at: "2021-02-05T10:41:10.307000+00:00", 249 | hoisted_role: null, 250 | deaf: true, 251 | }, 252 | user_id: "732441066992566304", 253 | suppress: false, 254 | session_id: "b09816ae9e49bb26028d3ba8e383857b", 255 | self_video: false, 256 | self_mute: false, 257 | self_deaf: true, 258 | request_to_speak_timestamp: null, 259 | mute: false, 260 | guild_id: "737499502763704370", 261 | deaf: true, 262 | channel_id: "853225781604646933", 263 | }; 264 | -------------------------------------------------------------------------------- /tests/raw/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./discord"; 2 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "paths": { 5 | "@": ["../src"], 6 | "@/*": ["../src/*"] 7 | }, 8 | "strict": false 9 | }, 10 | "include": ["**/*.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /tests/util.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import { expect, test, vi } from "vitest"; 3 | import { Client, ClientUser, Guild, Message, VoiceState } from "discord.js"; 4 | import { rawBotVoiceState, rawClientUser, rawGuild, rawMessage, rawUserVoiceState } from "./raw"; 5 | import { 6 | DisTubeError, 7 | DisTubeVoice as _Voice, 8 | checkIntents, 9 | checkInvalidKey, 10 | formatDuration, 11 | isClientInstance, 12 | isMemberInstance, 13 | isMessageInstance, 14 | isNsfwChannel, 15 | isSupportedVoiceChannel, 16 | isTextChannelInstance, 17 | isURL, 18 | isVoiceChannelEmpty, 19 | resolveGuildId, 20 | } from "@"; 21 | import type { Mocked } from "vitest"; 22 | 23 | vi.mock("@/core/DisTubeVoice"); 24 | 25 | const Voice: Mocked = _Voice; 26 | 27 | const client = new Client({ intents: [] }); 28 | // @ts-expect-error 29 | client.user = new ClientUser(client, rawClientUser); 30 | // @ts-expect-error 31 | const guild = new Guild(client, rawGuild); 32 | const textChannel = guild.channels.cache.get("737499503384461325"); 33 | const voiceChannel = guild.channels.cache.get("853225781604646933"); 34 | const stageChannel = guild.channels.cache.get("835876864458489857"); 35 | const threadChannel = guild.channels.cache.get("1098543313134563338"); 36 | const forumChannel = guild.channels.cache.get("737499503384461324"); 37 | Object.defineProperty(voiceChannel, "joinable", { value: true, writable: false }); 38 | Object.defineProperty(stageChannel, "joinable", { value: false, writable: false }); 39 | // @ts-expect-error 40 | const botVoiceState = new VoiceState(guild, rawBotVoiceState); 41 | // @ts-expect-error 42 | const userVoiceState = new VoiceState(guild, rawUserVoiceState); 43 | // @ts-expect-error 44 | const message = new Message(client, rawMessage); 45 | const clientMember = guild.members.resolve(guild.client.user.id); 46 | 47 | test("isSupportedVoiceChannel()", () => { 48 | const testFn = isSupportedVoiceChannel; 49 | expect(testFn(voiceChannel)).toBe(true); 50 | expect(testFn(stageChannel)).toBe(true); 51 | expect(testFn(textChannel)).toBe(false); 52 | expect(testFn(threadChannel)).toBe(false); 53 | expect(testFn(forumChannel)).toBe(false); 54 | expect(testFn(message)).toBe(false); 55 | expect(testFn(guild)).toBe(false); 56 | expect(testFn(client)).toBe(false); 57 | expect(testFn(client.user)).toBe(false); 58 | expect(testFn(clientMember)).toBe(false); 59 | expect(testFn(botVoiceState)).toBe(false); 60 | expect(testFn(userVoiceState)).toBe(false); 61 | }); 62 | 63 | test("isMessageInstance()", () => { 64 | const testFn = isMessageInstance; 65 | expect(testFn(voiceChannel)).toBe(false); 66 | expect(testFn(stageChannel)).toBe(false); 67 | expect(testFn(textChannel)).toBe(false); 68 | expect(testFn(threadChannel)).toBe(false); 69 | expect(testFn(forumChannel)).toBe(false); 70 | expect(testFn(message)).toBe(true); 71 | expect(testFn(guild)).toBe(false); 72 | expect(testFn(client)).toBe(false); 73 | expect(testFn(client.user)).toBe(false); 74 | expect(testFn(clientMember)).toBe(false); 75 | expect(testFn(botVoiceState)).toBe(false); 76 | expect(testFn(userVoiceState)).toBe(false); 77 | }); 78 | 79 | test("isTextChannelInstance()", () => { 80 | const testFn = isTextChannelInstance; 81 | expect(testFn(voiceChannel)).toBe(true); 82 | expect(testFn(stageChannel)).toBe(true); 83 | expect(testFn(textChannel)).toBe(true); 84 | expect(testFn(threadChannel)).toBe(true); 85 | expect(testFn(forumChannel)).toBe(false); 86 | expect(testFn(message)).toBe(false); 87 | expect(testFn(guild)).toBe(false); 88 | expect(testFn(client)).toBe(false); 89 | expect(testFn(client.user)).toBe(false); 90 | expect(testFn(clientMember)).toBe(false); 91 | expect(testFn(botVoiceState)).toBe(false); 92 | expect(testFn(userVoiceState)).toBe(false); 93 | }); 94 | 95 | test("isMemberInstance()", () => { 96 | const testFn = isMemberInstance; 97 | expect(testFn(voiceChannel)).toBe(false); 98 | expect(testFn(stageChannel)).toBe(false); 99 | expect(testFn(textChannel)).toBe(false); 100 | expect(testFn(threadChannel)).toBe(false); 101 | expect(testFn(forumChannel)).toBe(false); 102 | expect(testFn(message)).toBe(false); 103 | expect(testFn(guild)).toBe(false); 104 | expect(testFn(client)).toBe(false); 105 | expect(testFn(client.user)).toBe(false); 106 | expect(testFn(clientMember)).toBe(true); 107 | expect(testFn(botVoiceState)).toBe(false); 108 | expect(testFn(userVoiceState)).toBe(false); 109 | }); 110 | 111 | test("resolveGuildID()", () => { 112 | const voice = new Voice({} as any, voiceChannel); 113 | const gId = "737499502763704370"; 114 | // @ts-expect-error 115 | voice.id = gId; 116 | const testFn = resolveGuildId; 117 | expect(testFn(voice)).toBe(gId); 118 | expect(testFn(voiceChannel)).toBe(gId); 119 | expect(testFn(stageChannel)).toBe(gId); 120 | expect(testFn(textChannel)).toBe(gId); 121 | expect(testFn(threadChannel)).toBe(gId); 122 | expect(testFn(forumChannel)).toBe(gId); 123 | expect(testFn(message)).toBe(gId); 124 | expect(testFn(guild)).toBe(gId); 125 | expect(testFn(clientMember)).toBe(gId); 126 | expect(testFn(botVoiceState)).toBe(gId); 127 | expect(testFn(userVoiceState)).toBe(gId); 128 | expect(testFn(gId)).toBe(gId); 129 | expect(() => testFn(client as any)).toThrow(new DisTubeError("INVALID_TYPE", "GuildIdResolvable", client)); 130 | expect(() => testFn(client.user as any)).toThrow(new DisTubeError("INVALID_TYPE", "GuildIdResolvable", client.user)); 131 | expect(() => testFn(1 as any)).toThrow(new DisTubeError("INVALID_TYPE", "GuildIdResolvable", 1)); 132 | }); 133 | 134 | test("isClientInstance()", () => { 135 | const testFn = isClientInstance; 136 | expect(testFn(voiceChannel)).toBe(false); 137 | expect(testFn(stageChannel)).toBe(false); 138 | expect(testFn(textChannel)).toBe(false); 139 | expect(testFn(threadChannel)).toBe(false); 140 | expect(testFn(forumChannel)).toBe(false); 141 | expect(testFn(message)).toBe(false); 142 | expect(testFn(guild)).toBe(false); 143 | expect(testFn(client)).toBe(true); 144 | expect(testFn(client.user)).toBe(false); 145 | expect(testFn(clientMember)).toBe(false); 146 | expect(testFn(botVoiceState)).toBe(false); 147 | expect(testFn(userVoiceState)).toBe(false); 148 | }); 149 | 150 | test("isNsfwChannel()", () => { 151 | const testFn = isNsfwChannel; 152 | expect(testFn(voiceChannel)).toBe(true); 153 | expect(testFn(stageChannel)).toBe(false); 154 | expect(testFn(textChannel)).toBe(false); 155 | expect(testFn(threadChannel)).toBe(true); 156 | expect(testFn(forumChannel)).toBe(false); // is not a text channel 157 | expect(testFn(message)).toBe(false); 158 | expect(testFn(guild)).toBe(false); 159 | expect(testFn(client)).toBe(false); 160 | expect(testFn(client.user)).toBe(false); 161 | expect(testFn(clientMember)).toBe(false); 162 | expect(testFn(botVoiceState)).toBe(false); 163 | expect(testFn(userVoiceState)).toBe(false); 164 | }); 165 | 166 | test("isVoiceChannelEmpty()", () => { 167 | const testFn = isVoiceChannelEmpty; 168 | expect(testFn({ client: {} } as any)).toBe(false); 169 | expect(testFn({ guild: true, client: {} } as any)).toBe(false); 170 | expect(testFn(voiceChannel as any)).toBe(false); 171 | expect(testFn(botVoiceState)).toBe(false); 172 | guild.voiceStates.cache.set(botVoiceState.id, botVoiceState); 173 | expect(testFn(botVoiceState)).toBe(true); 174 | guild.voiceStates.cache.set(userVoiceState.id, userVoiceState); 175 | expect(testFn(botVoiceState)).toBe(false); 176 | }); 177 | 178 | test("checkIntents()", () => { 179 | const intent = "GuildVoiceStates"; 180 | const client1 = new Client({ intents: [] }); 181 | const client2 = new Client({ intents: ["Guilds"] }); 182 | const client3 = new Client({ intents: [intent] }); 183 | expect(() => { 184 | checkIntents(client1.options); 185 | }).toThrow(new DisTubeError("MISSING_INTENTS", intent)); 186 | expect(() => { 187 | checkIntents(client2.options); 188 | }).toThrow(new DisTubeError("MISSING_INTENTS", intent)); 189 | expect(checkIntents(client3.options)).toBeUndefined(); 190 | }); 191 | 192 | test("isURL()", () => { 193 | expect(isURL(1)).toBe(false); 194 | expect(isURL("")).toBe(false); 195 | expect(isURL("not an url")).toBe(false); 196 | expect(isURL("https://")).toBe(false); 197 | expect(isURL("file://abc")).toBe(true); 198 | expect(isURL("sftp://abc")).toBe(false); 199 | expect(isURL("ftp://abc")).toBe(false); 200 | expect(isURL("ahihi://abc")).toBe(false); 201 | expect(isURL("http://localhost:1234")).toBe(true); 202 | expect(isURL("https://distube.js.org/")).toBe(true); 203 | expect(isURL("http://distube.js.org:433")).toBe(true); 204 | }); 205 | 206 | test("formatDuration()", () => { 207 | expect(formatDuration(undefined as any)).toBe("00:00"); 208 | expect(formatDuration(0)).toBe("00:00"); 209 | expect(formatDuration(1)).toBe("00:01"); 210 | expect(formatDuration(59.99)).toBe("00:59"); 211 | expect(formatDuration(60.99)).toBe("01:00"); 212 | expect(formatDuration(70.6)).toBe("01:10"); 213 | expect(formatDuration(3600.99)).toBe("01:00:00"); 214 | expect(formatDuration(5025)).toBe("01:23:45"); 215 | expect(formatDuration(7199.99)).toBe("01:59:59"); 216 | expect(formatDuration(91425)).toBe("25:23:45"); 217 | }); 218 | 219 | test("checkInvalidKey()", () => { 220 | const target = { 221 | a: 0, 222 | b: 1, 223 | }; 224 | const name = "target"; 225 | expect(() => checkInvalidKey(0 as any, [], name)).toThrow(new DisTubeError("INVALID_TYPE", "object", 0, name)); 226 | expect(() => checkInvalidKey(target, ["b"], name)).toThrow(`'a' does not need to be provided in ${name}`); 227 | expect(() => checkInvalidKey(target, { a: undefined }, name)).toThrow(`'b' does not need to be provided in ${name}`); 228 | expect(checkInvalidKey(target, { a: 0, b: 0, c: 0 }, name)).toBeUndefined(); 229 | expect(checkInvalidKey(target, ["a", "b"], name)).toBeUndefined(); 230 | }); 231 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true 5 | }, 6 | "include": [ 7 | "**/*.ts", 8 | "**/*.tsx", 9 | "**/*.mts", 10 | "**/*.js", 11 | "**/*.mjs", 12 | "**/*.jsx", 13 | "**/*.test.ts", 14 | "**/*.test.js", 15 | "**/*.test.mjs", 16 | "**/*.spec.ts", 17 | "**/*.spec.js", 18 | "**/*.spec.mjs" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*.ts"], 3 | "exclude": ["node_modules"], 4 | "compilerOptions": { 5 | "module": "ESNext", 6 | "moduleResolution": "Bundler", 7 | "lib": ["ESNext"], 8 | "target": "ESNext", 9 | "emitDecoratorMetadata": false, 10 | "experimentalDecorators": true, 11 | "useDefineForClassFields": true, 12 | "allowUnreachableCode": false, 13 | "allowUnusedLabels": false, 14 | "noFallthroughCasesInSwitch": true, 15 | "noImplicitOverride": true, 16 | "noImplicitReturns": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "strict": true, 20 | "declaration": true, 21 | "inlineSources": true, 22 | "newLine": "lf", 23 | "noEmitHelpers": true, 24 | "outDir": "dist", 25 | "preserveConstEnums": true, 26 | "removeComments": false, 27 | "sourceMap": true, 28 | "esModuleInterop": true, 29 | "forceConsistentCasingInFileNames": true, 30 | "downlevelIteration": true, 31 | "skipLibCheck": true, 32 | "isolatedModules": true 33 | }, 34 | "typedocOptions": { 35 | "name": "DisTube API Documentation", 36 | "plugin": ["typedoc-material-theme", "typedoc-plugin-extras"], 37 | "themeColor": "#ED4245", 38 | "entryPoints": ["src/index.ts"], 39 | "out": "docs", 40 | "sidebarLinks": { 41 | "GitHub": "https://github.com/skick1234/DisTube", 42 | "Discord": "https://discord.gg/feaDd9h", 43 | "Guide": "https://github.com/skick1234/DisTube/wiki" 44 | }, 45 | "hostedBaseUrl": "https://distube.js.org", 46 | "favicon": "https://raw.githubusercontent.com/skick1234/cdn/refs/heads/main/DisTube/img/favicon.png", 47 | "customDescription": "A powerful Discord.js module for simplifying music commands and effortless playback of various sources with integrated audio filters." 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | import { esbuildPluginVersionInjector } from "esbuild-plugin-version-injector"; 3 | 4 | export default defineConfig({ 5 | platform: "node", 6 | clean: true, 7 | dts: true, 8 | entry: ["src/index.ts"], 9 | format: ["cjs", "esm"], 10 | minify: false, 11 | keepNames: true, 12 | skipNodeModulesBundle: true, 13 | sourcemap: true, 14 | target: "es2022", 15 | shims: true, 16 | cjsInterop: true, 17 | splitting: false, 18 | treeshake: false, 19 | outDir: "dist", 20 | terserOptions: { 21 | mangle: false, 22 | keep_classnames: true, 23 | keep_fnames: true, 24 | }, 25 | esbuildPlugins: [esbuildPluginVersionInjector()], 26 | }); 27 | -------------------------------------------------------------------------------- /vitest.config.mts: -------------------------------------------------------------------------------- 1 | import tsconfigPaths from "vite-tsconfig-paths"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | plugins: [tsconfigPaths()], 6 | test: { 7 | exclude: ["**/node_modules", "**/dist", ".idea", ".git"], 8 | coverage: { 9 | enabled: true, 10 | all: true, 11 | reporter: ["text", "lcov", "cobertura"], 12 | provider: "v8", 13 | include: ["src"], 14 | exclude: [ 15 | "**/*.{interface,type,d}.ts", 16 | "**/{interfaces,types?}/*.ts", 17 | "**/{interface,type}.ts", 18 | "**/index.{js,ts}", 19 | ], 20 | }, 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /wiki/DisTube-Guide.md: -------------------------------------------------------------------------------- 1 | > [!WARNING] 2 | > This DisTube Guide is a work in progress. 3 | 4 | > [!NOTE] 5 | > This guide follows the [discordjs.guide notation](https://discordjs.guide/additional-info/notation.html). 6 | 7 | # Introduction 8 | 9 | Welcome to the DisTube guide! This tutorial will guide you through creating a Discord bot with music commands using DisTube, a comprehensive Discord music bot library built for Discord.js. DisTube simplifies music commands, offers effortless playback from diverse sources, and provides integrated audio filters. 10 | 11 | Let's bring your Discord bot to life with DisTube! 🤖🎵 12 | 13 | [![Support me on ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/skick) 14 | 15 | # Prerequisites 16 | 17 | - Solid understanding of JavaScript. While you can create a basic music bot with limited JS knowledge, a strong foundation will help you troubleshoot issues. 18 | - A basic Discord bot set up with Discord.js. Refer to [discordjs.guide](https://discordjs.guide/) if you haven't already. 19 | 20 | This guide assumes you're familiar with the command handling setup from [discordjs.guide](https://discordjs.guide/). Adjustments may be needed based on your command handler's structure. 21 | 22 | # Installation 23 | 24 | 1. Install DisTube and required dependencies in your bot project: 25 | 26 | ```sh 27 | npm install distube @discordjs/voice @discordjs/opus 28 | ``` 29 | 30 | 2. Install FFmpeg. See the guides for: 31 | 32 | - [Windows](http://blog.gregzaal.com/how-to-install-ffmpeg-on-windows/) 33 | - [Linux (Ubuntu, Mint,...)](https://www.tecmint.com/install-ffmpeg-in-linux/) 34 | 35 | If the links above are unavailable, download FFmpeg from [this repo](https://github.com/BtbN/FFmpeg-Builds/releases). 36 | 37 | > [!WARNING] 38 | > Avoid using `ffmpeg-static` due to potential stability issues across different machines. Also, do NOT install the `ffmpeg` npm package. Uninstall it if installed with `npm uninstall ffmpeg`. 39 | 40 | 3. Encryption Libraries 41 | 42 | > [!NOTE] 43 | > You only need to install one of these libraries if your system does not support `aes-256-gcm` (verify by running `require('node:crypto').getCiphers().includes('aes-256-gcm')`). 44 | 45 | - [@noble/ciphers](https://www.npmjs.com/package/@noble/ciphers) 46 | - [sodium-native](https://www.npmjs.com/package/sodium-native) 47 | -------------------------------------------------------------------------------- /wiki/Frequently-Asked-Questions.md: -------------------------------------------------------------------------------- 1 | # DisTube 2 | 3 | ## 1. Budget VPS Hosting 4 | 5 | Blazingly fast, reliable VMs in 16 global locations at an extremely affordable price! 6 | 7 | > Available locations: 🇺🇸 🇳🇱 🇸🇪 🇦🇹 🇳🇴 🇬🇧 🇨🇭 🇭🇰 🇸🇬 🇯🇵 🇦🇺\ 8 | > Premium DisTube Bots are hosted on this provider in Chicago 🇺🇸 9 | > ​ 10 | 11 | ### [Order Now!](https://skick.xyz/vps) 12 | 13 | All plans, even **4$** plan, use the below specification: 14 | 15 | - CPU: **AMD EPYC Milan** 16 | - SSD: **Samsung Enterprise NVMe Storage** 17 | - Public Network Port: **10Gbps | 40Gbps** (depends on location) 18 | 19 | | CPU
vCore | RAM
GB | Storage
GB | Price
US$ | 20 | | :-----------: | :--------: | :------------: | ------------: | 21 | | 1 | 2 | 10 | $4 | 22 | | 2 | 4 | 20 | $6 | 23 | | 2 | 8 | 35 | $9 | 24 | | 4 | 12 | 50 | $12 | 25 | | 6 | 24 | 100 | $22 | 26 | | 8 | 32 | 125 | $29 | 27 | | 8 | 48 | 150 | $39 | 28 | | 12 | 64 | 200 | $49 | 29 | | 16 | 96 | 250 | $69 | 30 | 31 | ## 2. FFMPEG_NOT_INSTALLED 32 | 33 | ### Reason 34 | 35 | - FFmpeg is not installed 36 | 37 | ### Solution 38 | 39 | - Install FFmpeg on: [Windows](http://blog.gregzaal.com/how-to-install-ffmpeg-on-windows/) - [Linux (Ubuntu, Mint,...)](https://www.tecmint.com/install-ffmpeg-in-linux/) 40 | > Download FFmpeg from [this repo](https://github.com/BtbN/FFmpeg-Builds/releases) if the download links are not available 41 | - If you want to run FFmpeg from a custom path, or `ffmpeg-static`.path, e.t.c., you can use [`ffmpeg.path`](https://distube.js.org/types/DisTubeOptions.html) option. 42 | 43 | ## 3.1 The song ends instantly without any errors
3.2 Error: write EPIPE 44 | 45 | ### Reason 46 | 47 | - This is due to FFmpeg error. 48 | 49 | ### Solution: 50 | 51 | - Check the FFmpeg log to check why it happens with [`ffmpegDebug`](https://distube.js.org/classes/DisTube.html#ffmpegDebug) event 52 | 53 | ```ts 54 | import { Events } from "distube"; 55 | distube.on(Events.FFMPEG_DEBUG, console.log); 56 | ``` 57 | 58 | ## 4.1 Error: Cannot find module '@discordjs/opus'
4.2 RangeError: Source is too large
4.3 RangeError: offset is out of bounds 59 | 60 | ### Reason 61 | 62 | - `@discordjs/opus` package is not installed, or you installed `node-opus` or `opusscript` package (which is not stable) 63 | 64 | ### Solution 65 | 66 | - Install `@discordjs/opus` package. Uninstall `node-opus`, `opusscript` if installed 67 | 68 | ```sh 69 | npm uninstall opusscript node-opus 70 | npm install @discordjs/opus 71 | ``` 72 | 73 | ## 5. Error: VOICE_CONNECTION_TIMEOUT 74 | 75 | ### Reason 76 | 77 | - It is due to your hosting/VPS network connection 78 | 79 | ### Solution 80 | 81 | 1. Try to join the voice channel with `.voices.join(voiceChannel)` before using `DisTube.play()`.\ 82 | You can retry if this function throws the above error. 83 | 84 | 2. Use a better network service (like the above VPS) 85 | 86 | ## 6.1 My bot plays a random song after finishing all the queue
6.2 How to turn off autoplay
6.3 How to change queue's default properties 87 | 88 | ### Reason 89 | 90 | - Autoplay is on by default. 91 | 92 | ### Solution 93 | 94 | - To turn it on/off by a command, use [toggleAutoplay()](https://distube.js.org/#/docs/DisTube/main/class/DisTube?scrollTo=toggleAutoplay). 95 | - To change the queue's default setting, use [initQueue](https://distube.js.org/#/docs/DisTube/main/class/DisTube?scrollTo=e-initQueue) event. 96 | 97 | # YouTubePlugin 98 | 99 | ## 1. Error: Status code: 429 100 | 101 | ### Reason 102 | 103 | - It is caused by requesting YouTube videos too fast. 104 | 105 | ### Solution 106 | 107 | - Use `cookies` option (Guide: [[YouTube-Cookies]]). 108 | > You have to sign in before getting the cookie. 109 | > You may have to wait for YouTube to unblock your IP after getting this error. 110 | 111 | ## 2.1 Sign in to confirm you're not a bot
2.2 Get the best YouTube experience 112 | 113 | ### Reason 114 | 115 | - You are literally a Discord bot 116 | 117 | ### Solution 118 | 119 | - Use `cookies` option (Guide: [[YouTube-Cookies]]). 120 | > You have to sign in before getting the cookie. 121 | 122 | ## 3. Sign in to confirm your age 123 | 124 | ### Reason 125 | 126 | - Playing YouTube age-restricted videos 127 | 128 | ### Solution 129 | 130 | - Use `cookies` option (Guide: [[YouTube-Cookies]]). 131 | > You have to sign in before getting the cookie. 132 | > Your account information must be at least 18 years old. 133 | 134 | ## 4. Error checking for updates: Status code 403 135 | 136 | ### Reason 137 | 138 | - You are requested to github to many times (maybe due to restarting your bot). YouTubePlugin deps check their versions with github api so it can be rate-limited 139 | 140 | ### Solution 141 | 142 | - Disable check for updates temporarily with their env variables 143 | 144 | ```ts 145 | process.env.YTSR_NO_UPDATE = 1; 146 | process.env.YTDL_NO_UPDATE = 1; 147 | ``` 148 | -------------------------------------------------------------------------------- /wiki/Handling-Discord.js-Events.md: -------------------------------------------------------------------------------- 1 | There are a few ways to handle discord.js events 2 | 3 | ## Leave the voice channel if there is no user in it 4 | 5 | ```ts 6 | import { isVoiceChannelEmpty } from "distube"; 7 | client.on("voiceStateUpdate", oldState => { 8 | if (!oldState?.channel) return; 9 | const voice = this.voices.get(oldState); 10 | if (voice && isVoiceChannelEmpty(oldState)) { 11 | voice.leave(); 12 | } 13 | }); 14 | ``` 15 | 16 | ## Pause the queue if there is no user in the voice channel and resume it if there is 17 | 18 | ```ts 19 | import { isVoiceChannelEmpty } from "distube"; 20 | client.on("voiceStateUpdate", oldState => { 21 | if (!oldState?.channel) return; 22 | const queue = this.queues.get(oldState); 23 | if (!queue) return; 24 | if (isVoiceChannelEmpty(oldState)) { 25 | queue.pause(); 26 | } else if (queue.paused) { 27 | queue.resume(); 28 | } 29 | }); 30 | ``` 31 | -------------------------------------------------------------------------------- /wiki/Home.md: -------------------------------------------------------------------------------- 1 | # Welcome to DisTube.js.org Wiki 2 | 3 | A collection of information about the [distube](https://www.npmjs.com/package/distube) package. 4 | 5 | ## Getting started with DisTube 6 | 7 | - [[DisTube Guide]] is the optimal starting point for mastering the usage of DisTube. 8 | - Explore the [DisTube Documentation](https://distube.js.org/) to acquaint yourself with the standard library. 9 | - Explore a range of open-source projects leveraging DisTube featured on the [[Projects Hub]]. 10 | - For upgrading from a lower version, refer to the [[Major Upgrade Guide]]. 11 | - Prior to seeking assistance, please review the [[Frequently Asked Questions]] section for potential resolutions to your issue. 12 | - If you still require assistance, feel free to visit our [Discord](https://discord.gg/feaDd9h) community for support. 13 | -------------------------------------------------------------------------------- /wiki/Major-Upgrade-Guide.md: -------------------------------------------------------------------------------- 1 | # Updating from v4 to v5 2 | 3 | ## DisTube 4 | 5 | DisTube doesn't support any music sources anymore. You have to use plugins to support them. 6 | 7 | ### DisTubeOptions 8 | 9 | Removed `streamType`, `youtubeCookie`, `ytdlOptions`, `searchSongs`, `directLink`, `leaveOnStop`, `leaveOnEmpty` and `leaveOnFinish` options. 10 | 11 | - `leaveOnEmpty`: Check [Handling Discord.js Events](https://github.com/skick1234/DisTube/wiki/Handling-Discord.js-Events) 12 | - `leaveOnStop`: Add `queue.voice.leave()` after `queue.stop()` line 13 | - `leaveOnFinish`: Add `queue.voice.leave()` in the `finish` event 14 | - `directLink`: Use `@distube/direct-link` plugin 15 | - `youtubeCookie` and `ytdlOptions`: Check the `@distube/youtube` plugin options 16 | 17 | ### DisTube#search 18 | 19 | DisTube#search was removed since it doesn't support YouTube anymore, you can use `YouTubePlugin#search` or `SoundCloudPlugin#search`,... instead.\ 20 | Please check the plugin docs for more information. 21 | 22 | ```ts 23 | import { YouTubePlugin } from "@distube/youtube" 24 | import { DisTube } from "distube" 25 | 26 | const ytPlugin = new YouTubePlugin(...); 27 | const distube = new DisTube({ 28 | plugins: [ytPlugin], 29 | ... 30 | }) 31 | 32 | ytPlugin.search(query, { type: "video", limit: 5, safeSearch: true }).then(console.log) 33 | ``` 34 | 35 | ### Error event 36 | 37 | - `error` event arguments changed. Check the API docs for details. 38 | 39 | ```diff 40 | - distube.on('error', (channel, e) => { 41 | - if (channel) channel.send(`An error encountered: ${e}`) 42 | - else console.error(e) 43 | -}) 44 | + distube.on('error', (e, queue, song) => { 45 | + queue.textChannel.send(`An error encountered: ${e}`); 46 | + }) 47 | ``` 48 | 49 | ## Song 50 | 51 | - Use `Song#stream` in favor of `Song#streamURL` 52 | - Rename `Song#age_restricted` to `Song#ageRestricted` 53 | - Remove `Song#chapters` 54 | 55 | ```diff 56 | - const ageRestricted = song.age_restricted 57 | + const ageRestricted = song.ageRestricted 58 | - const duration = song.duration 59 | + const duration = song.stream.playFromSource ? song.duration : song.stream.song.duration 60 | - const streamURL = song.streamURL 61 | + const streamURL = song.stream.playFromSource ? song.stream.url : song.stream.song.stream.url 62 | ``` 63 | 64 | On v5, `Song` info is not represented the `Song` will be streamed to the voice channel if `Song#playFromSource` is `false` 65 | 66 | Example: `s` is a Spotify `Song`, `s.source` is `spotify`, `s.playFromSource` is `false`. When the song plays, `s.stream.song` will be a stream-able `Song` searched with an `ExtractorPlugin`. And DisTube will play the `s.stream.song` instead of `s` 67 | 68 | Note: `Song#stream.url` or `Song#stream.song` is only available when the song is playing. 69 | 70 | # Updating from v3 to v4 71 | 72 | ## Before you start 73 | 74 | v4 requires discord.js v14 to use, so make sure you're up to date. To update your discord.js code, check [their guide](https://discordjs.guide/) before updating DisTube code. 75 | Also, update plugins if you're using them. 76 | 77 | ## DisTube 78 | 79 | ### DisTubeOptions 80 | 81 | The built-in `youtube-dl` plugin is removed for more convenient updating in the future. Now, you can use the new [`@distube/yt-dlp` plugin](https://www.npmjs.com/package/@distube/yt-dlp). 82 | 83 | ```diff 84 | - const distube = new DisTube({ youtubeDL: true, updateYouTubeDL: false }) 85 | + const { YtDlpPlugin } = require("@distube/yt-dlp") 86 | + const distube = new DisTube({ plugins: [new YtDlpPlugin({ update: false })] }) 87 | ``` 88 | 89 | ### DisTube#play 90 | 91 | - `DisTube#play` no longer supports `Message` as its parameter, requiring `BaseGuildVoiceChannel` instead (same as v3 `DisTube#playVoiceChannel`). This also has an `options` parameter for providing optional arguments. 92 | 93 | ```diff 94 | - distube.play(message, ...) 95 | + distube.play(message.member.voice.channel, ..., { message, member: message.member }) 96 | ``` 97 | 98 | - `options.position` has been added for customize added song/playlist position. That why `options.unshift` no longer exists on this version. 99 | 100 | ```diff 101 | - distube.play(..., { unshift: true }) 102 | + distube.play(..., { position: 1 }) 103 | ``` 104 | 105 | - Now this method throw an error if DisTube cannot play the input song instead of emitting to the `error` event. 106 | 107 | ```js 108 | distube.play().catch(err => { 109 | message.reply(err); 110 | }); 111 | // Or 112 | async function play() { 113 | try { 114 | await distube.play(); 115 | } catch (err) { 116 | message.reply(err); 117 | } 118 | } 119 | ``` 120 | 121 | ### DisTube#playVoiceChannel 122 | 123 | This method has been removed and replaced with `DisTube#play`. 124 | 125 | ```diff 126 | - distube.playVoiceChannel(...) 127 | + distube.play(...) 128 | ``` 129 | 130 | ### DisTube#playCustomPlaylist 131 | 132 | `DisTube#playCustomPlaylist` has been removed. You can use `DisTube#createCustomPlaylist` and `DisTube#play` instead. 133 | 134 | ```diff 135 | const songs = ["https://www.youtube.com/watch?v=xxx", "https://www.youtube.com/watch?v=yyy"]; 136 | - distube.playCustomPlaylist(message, songs, { name: "My playlist name" }); 137 | + const playlist = await distube.createCustomPlaylist(songs, { 138 | + member: message.member, 139 | + properties: { name: "My playlist name" }, 140 | + parallel: true 141 | + }); 142 | + distube.play(message.member.voice.channel, playlist); 143 | ``` 144 | 145 | ## Queue 146 | 147 | ### Queue#setFilter 148 | 149 | - `Queue#setFilter` has been removed. You can use `Queue#filters` instead. 150 | 151 | ### Queue#filters 152 | 153 | - `Queue#filters` is now `FilterManager`, which is more flexible and support custom filters. 154 | 155 | ```js 156 | queue.filters.add("a-filter"); 157 | // filters: ["a-filter"] 158 | queue.filters.add(["another-filter", "a-third-filter"]); 159 | // filters: ["a-filter", "another-filter", "a-third-filter"] 160 | queue.filters.add(["a-third-filter"]); 161 | // filters: ["a-filter", "another-filter", "a-third-filter"] 162 | queue.filters.remove(["a-filter", "a-third-filter"]); 163 | // filters: ["another-filter"] 164 | queue.filters.set(["1", "2", "3"]); 165 | // filters: ["1", "2", "3"] 166 | queue.filters.clear(); 167 | // filters: [] 168 | ``` 169 | 170 | # Updating from v2 to v3 171 | 172 | ## Requirement 173 | 174 | DisTube v3 is built on `@discordjs/voice`. That why we need to install [@discordjs/voice](https://github.com/discordjs/voice) and [sodium](https://www.npmjs.com/package/sodium) or [libsodium-wrappers](https://www.npmjs.com/package/libsodium-wrappers) 175 | 176 | ```sh 177 | npm i @discordjs/voice sodium 178 | ``` 179 | 180 | ## DisTube class 181 | 182 | ### Constructor 183 | 184 | v3 is written in TypeScript, so we need to change how to create the DisTube instance. 185 | 186 | ```diff 187 | const DisTube = require("distube") 188 | - const distube = new DisTube(options) 189 | + const distube = new DisTube.default(options) // or new DisTube.DisTube(options) 190 | ``` 191 | 192 | or 193 | 194 | ```diff 195 | - const DisTube = require("distube") 196 | + const { DisTube } = require("distube") 197 | const distube = new DisTube(options) 198 | ``` 199 | 200 | ## DisTube Events 201 | 202 | ### Changes 203 | 204 | v3 doesn't emit `Message` parameter anymore. 100% events are changed. You can check the documentation for details. 205 | 206 | ```diff 207 | - .on("playSong", (message, queue, song) => { 208 | - message.channel.send(...) 209 | - }) 210 | + .on("playSong", (queue, song) => { 211 | + queue.textChannel.send(...) 212 | + }) 213 | 214 | - .on("error", (message, err) => { 215 | - message.channel.send(...) 216 | - }) 217 | + .on("error", (channel, error) => { 218 | + channel.send(); 219 | + }) 220 | ``` 221 | 222 | #### playList event 223 | 224 | `playList` event was removed, you can use `playSong` instead. 225 | 226 | ```diff 227 | - .on("playList", ...) 228 | .on("playSong", (queue, song) => { 229 | - // Your code when playing a song 230 | + if (song.playlist) { 231 | + // If the playing song is in a playlist 232 | + } else { 233 | + // Your code when playing a song 234 | + } 235 | }) 236 | ``` 237 | 238 | According to the above example, you will think it's make your code longer. But no, if you use the same template with your `playList` and `playSong` event, this will help you reduce some duplication code. 239 | 240 | ```js 241 | distube.on("playSong", (queue, song) => { 242 | let msg = `Playing \`${song.name}\` - \`${song.formattedDuration}\``; 243 | if (song.playlist) msg = `Playlist: ${song.playlist.name}\n${msg}`; 244 | queue.textChannel.send(msg); 245 | }); 246 | ``` 247 | 248 | ### Addition 249 | 250 | New events on v3: 251 | 252 | - [deleteQueue](https://distube.js.org/#/docs/DisTube/v3/class/DisTube?scrollTo=e-deleteQueue) 253 | - [disconnect](https://distube.js.org/#/docs/DisTube/v3/class/DisTube?scrollTo=e-disconnect) 254 | - [finishSong](https://distube.js.org/#/docs/DisTube/v3/class/DisTube?scrollTo=e-finishSong) 255 | - [searchDone](https://distube.js.org/#/docs/DisTube/v3/class/DisTube?scrollTo=e-searchDone) 256 | - [searchInvalidAnswer](https://distube.js.org/#/docs/DisTube/v3/class/DisTube?scrollTo=e-searchInvalidAnswer) 257 | - [searchNoResult](https://distube.js.org/#/docs/DisTube/v3/class/DisTube?scrollTo=e-searchNoResult) 258 | 259 | Click above links and read the docs for more information. 260 | 261 | ## DisTube Methods 262 | 263 | ### Changes 264 | 265 | #### Removed methods 266 | 267 | - `DisTube#playSkip` is removed in favor of `DisTube#play` with skip parameter 268 | - `DisTube#runAutoplay` is removed in favor of `DisTube#addRelatedSong` 269 | - `DisTube#isPlaying`, `DisTube#isPaused` is removed 270 | 271 | #### DisTube#play 272 | 273 | `skip` parameter becomes a property in `options` parameter. Add `unshift` property to `options` 274 | 275 | ```diff 276 | - #playSkip(...) 277 | - #play(..., true) 278 | + #play(..., { skip: true }) 279 | ``` 280 | 281 | #### DisTube#playCustomPlaylist 282 | 283 | `skip` parameter becomes a property in `options` parameter. Add `parallel` and `unshift` property to `options` 284 | 285 | ```diff 286 | - #playCustomPlaylist(..., true) 287 | + #playCustomPlaylist(..., { skip: true }) 288 | ``` 289 | 290 | #### DisTube#setFilter 291 | 292 | `#setFilter` now supports applying multiple filters in a single Queue 293 | 294 | ```js 295 | // No filter applied 296 | distube.setFilter(message, "3d"); 297 | // Applied filters: 3d 298 | distube.setFilter(message, ["3d", "bassboost", "vaporwave"]); 299 | // Applied filters: bassboost, vaporwave 300 | distube.setFilter(message, ["3d", "bassboost", "vaporwave"], true); 301 | // Applied filters: 3d, bassboost, vaporwave 302 | distube.setFilter(message, false); 303 | // No filter applied 304 | ``` 305 | 306 | #### DisTube#search 307 | 308 | Add `options` parameter to change limit, type and restricted mode of the results 309 | 310 | ```js 311 | distube.search("A query", { 312 | limit: 10, 313 | type: "video", 314 | safeSearch: false, 315 | }); 316 | ``` 317 | 318 | ### Addition 319 | 320 | New methods on v3: 321 | 322 | - [playVoiceChannel](https://distube.js.org/#/docs/DisTube/v3/class/DisTube?scrollTo=playVoiceChannel) 323 | - [addRelatedSong](https://distube.js.org/#/docs/DisTube/v3/class/DisTube?scrollTo=addRelatedSong) 324 | - [previous](https://distube.js.org/#/docs/DisTube/v3/class/DisTube?scrollTo=previous) 325 | 326 | Click above links and read the docs for more information. 327 | 328 | ## Voice 329 | 330 | ### Voice Management 331 | 332 | Because v3 is built on `@discordjs/voice`, you **MUST NOT** use built-in voice system on discord.js v12, or DisTube cannot work as expected. On discord.js v13, you can use `@discordjs/voice` functions to manage your voice functions but I highly recommend using `DisTubeVoiceManager` instead. 333 | 334 | ```diff 335 | - #join() // djs v12 336 | - joinVoiceChannel(...) // @discordjs/voice 337 | + #join() 338 | 339 | - #leave() // djs v12 340 | - #destroy() // @discordjs/voice 341 | + #leave() 342 | ``` 343 | 344 | Example: 345 | 346 | ```diff 347 | - message.member.voice.channel.join() // djs v12 348 | - joinVoiceChannel(...) // @discordjs/voice 349 | + distube.voices.join(message.member.voice.channel) 350 | 351 | - message.member.voice.channel.leave() // djs v12 352 | - getVoiceConnection(...).destroy() // @discordjs/voice 353 | + distube.voices.leave(message) 354 | ``` 355 | 356 | ### DisTubeVoice 357 | 358 | `.join()` returns `DisTubeVoice` to manage the voice connection instead of discord.js' `VoiceConnection`. `DisTubeVoice` is created to make you manage your voice easier and not to use complicated `@discordjs/voice` stuff. 359 | 360 | ```diff 361 | - #leave() // djs v12 362 | - #destroy() // @discordjs/voice 363 | + #leave() 364 | 365 | - #setSelfMute(boolean) 366 | + #setSelfMute(boolean) 367 | 368 | - #setSelfDeaf(boolean) 369 | + #setSelfDeaf(boolean) 370 | ``` 371 | 372 | Example: 373 | 374 | ```diff 375 | - message.member.voice.channel.leave() // djs v12 376 | - getVoiceConnection(...).destroy() // @discordjs/voice 377 | + distube.voices.get(message).leave() 378 | 379 | - message.guild.me.voice.setSelfMute(true) // djs v12 380 | - joinVoiceChannel({..., selfMute: true}) // @discordjs/voice 381 | + distube.voices.get(message).setSelfMute(true) 382 | 383 | - message.guild.me.voice.setSelfDeaf(true) // djs v12 384 | - joinVoiceChannel({..., selfDeaf: true}) // @discordjs/voice 385 | + distube.voices.get(message).setSelfDeaf(true) 386 | ``` 387 | 388 | ## DisTubeOptions 389 | 390 | ### Changes 391 | 392 | #### DisTubeOptions#searchSongs 393 | 394 | `searchSongs` now require a `number` instead of `boolean`. `searchResults` event will emit the number of results based on this option. 395 | 396 | ```diff 397 | - new DisTube({ searchSongs: true }) 398 | + new DisTube({ searchSongs: 10 }) 399 | 400 | - new DisTube({ searchSongs: false }) 401 | + new DisTube({ searchSongs: 0 }) // or searchSongs: 1 402 | ``` 403 | 404 | ### Additions 405 | 406 | New options on v3: `#plugins`, `#savePreviousSongs`, `#ytdlOptions`, `#searchCooldown`, `#emptyCooldown`, `#nsfw`, `#emitAddSongWhenCreatingQueue`, and `#emitAddListWhenCreatingQueue`. 407 | 408 | You can check the feature of those options in the [DisTubeOptions](https://distube.js.org/#/docs/DisTube/v3/typedef/DisTubeOptions) documentation. 409 | -------------------------------------------------------------------------------- /wiki/Projects-Hub.md: -------------------------------------------------------------------------------- 1 | # Official Plugins 2 | 3 | ### ExtractorPlugin 4 | 5 | > This plugin can extract the info, search, and play songs directly from its source. 6 | 7 | - [@distube/youtube](https://www.npmjs.com/package/@distube/youtube): Support YouTube. 8 | - [@distube/soundcloud](https://www.npmjs.com/package/@distube/soundcloud): Support SoundCloud. 9 | 10 | ### InfoExtractorPlugin 11 | 12 | > This plugin only can extract the info from supported links, but not play songs directly from its source. 13 | 14 | - [@distube/spotify](https://www.npmjs.com/package/@distube/spotify): Support Spotify. 15 | - [@distube/deezer](https://www.npmjs.com/package/@distube/deezer): Support Deezer. 16 | 17 | ### PlayableExtractorPlugin 18 | 19 | > This plugin can extract and play songs from supported links, but cannot search for songs from its source. 20 | 21 | - [@distube/yt-dlp](https://www.npmjs.com/package/@distube/yt-dlp): Use `yt-dlp` for supporting 700+ sites. 22 | - [@distube/direct-link](https://www.npmjs.com/package/@distube/direct-link): Support direct audio links. 23 | - [@distube/file](https://www.npmjs.com/package/@distube/file): Support local files. 24 | - [@distube/bandlab](https://www.npmjs.com/package/@distube/bandlab): Support BandLab. 25 | 26 | # Unofficial Plugins 27 | 28 | ### InfoExtractorPlugin 29 | 30 | - [distube-apple-music](https://www.npmjs.com/package/distube-apple-music): Support Apple Music. 31 | - [distube-tidal](https://www.npmjs.com/package/distube-tidal): Support Tidal. 32 | - [distube-yandex-music-plugin](https://www.npmjs.com/package/distube-yandex-music-plugin): Support Yandex Music. 33 | 34 | ### PlayableExtractorPlugin 35 | 36 | - [distube-vk-music-plugin](https://www.npmjs.com/package/distube-vk-music-plugin): Support VK Music. 37 | 38 | # Bots 39 | 40 | - [DisTube Example](https://github.com/distubejs/example) - Example bot with simple command handler. 41 | - [AICoTest](https://github.com/AlexInCube/AlCoTest) - Advanced audiobot. 42 | 43 | > [!NOTE] 44 | > To add your plugins/bots to this list, please [create a pull request](https://github.com/skick1234/DisTube/pulls) with the edited [`wiki/Projects-Hub.md`](https://github.com/skick1234/DisTube/blob/main/wiki/Projects-Hub.md) file. 45 | -------------------------------------------------------------------------------- /wiki/YouTube-Cookies.md: -------------------------------------------------------------------------------- 1 | # Why using cookies 2 | 3 | - Prevent YouTube rate limiting (429 Error) 4 | - Capable of playing videos accessible through your account, including age-restricted, exclusive member, premium, private, and more. 5 | 6 | # How to get cookies 7 | 8 | - Install [EditThisCookie](http://www.editthiscookie.com/) extension for your browser. 9 | - Go to [YouTube](https://www.youtube.com/). 10 | - Log in to your account. (You should use a new account for this purpose) 11 | - Click on the extension icon and click "Export". 12 | - Copy the content of the exported file and paste it into your code. 13 | 14 | ```ts 15 | import { DisTube } from "distube"; 16 | import { YouTubePlugin } from "@distube/youtube"; 17 | 18 | const distube = new DisTube({ 19 | plugins: [ 20 | new YouTubePlugin({ 21 | cookies: [ 22 | { 23 | domain: ".youtube.com", 24 | expirationDate: 1234567890, 25 | hostOnly: false, 26 | httpOnly: true, 27 | name: "XXX", 28 | path: "/", 29 | sameSite: "no_restriction", 30 | secure: true, 31 | session: false, 32 | value: "---xxx---", 33 | }, 34 | { 35 | domain: ".youtube.com", 36 | "...": "...", 37 | }, 38 | ], 39 | }), 40 | ], 41 | }); 42 | ``` 43 | 44 | - Or you can paste it into a file and use `fs.readFileSync` to read it. 45 | 46 | ```js 47 | const { DisTube } = require("distube"); 48 | const fs = require("fs"); 49 | 50 | const distube = new DisTube({ 51 | plugins: [new YouTubePlugin({ cookies: JSON.parse(fs.readFileSync("cookies.json")) })], 52 | }); 53 | ``` 54 | -------------------------------------------------------------------------------- /wiki/_Footer.md: -------------------------------------------------------------------------------- 1 | [Documentation](https://distube.js.org) - 2 | [FAQ](https://discord.gg/feaDd9h) - 3 | [Discord Support Server](https://discord.gg/feaDd9h) 4 | 5 | Buy Me a Coffee at ko-fi.com 6 | --------------------------------------------------------------------------------