├── .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 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
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 |
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 |