├── .devcontainer
├── Dockerfile
├── devcontainer.json
└── docker-compose.yml
├── .dockerignore
├── .env.example
├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ ├── config.yml
│ └── new_feature.md
├── PULL_REQUEST_TEMPLATE.md
└── workflows
│ ├── docker-image.yml
│ └── python-unittest.yml
├── .gitignore
├── .vscode
├── launch.json
├── settings.json
└── tasks.json
├── Dockerfile
├── LICENSE.md
├── README.md
├── data
└── radios.yaml
├── docker-compose.yml
├── jukebot
├── __init__.py
├── __main__.py
├── abstract_components
│ ├── __init__.py
│ ├── abstract_cache.py
│ ├── abstract_collection.py
│ ├── abstract_map.py
│ ├── abstract_request.py
│ └── abstract_service.py
├── cogs
│ ├── __init__.py
│ ├── music.py
│ ├── queue.py
│ ├── radio.py
│ ├── search.py
│ ├── system.py
│ └── utility.py
├── components
│ ├── __init__.py
│ ├── audio_stream.py
│ ├── player.py
│ ├── playerset.py
│ ├── requests
│ │ ├── __init__.py
│ │ ├── music_request.py
│ │ ├── search_request.py
│ │ ├── shazam_request.py
│ │ └── stream_request.py
│ ├── result.py
│ ├── resultset.py
│ └── song.py
├── exceptions
│ ├── __init__.py
│ ├── player_exception.py
│ └── query_exceptions.py
├── jukebot.py
├── listeners
│ ├── __init__.py
│ ├── error_handler.py
│ ├── intercept_handler.py
│ ├── logger_handler.py
│ └── voice_handler.py
├── services
│ ├── __init__.py
│ ├── music
│ │ ├── __init__.py
│ │ ├── current_song_service.py
│ │ ├── grab_service.py
│ │ ├── join_service.py
│ │ ├── leave_service.py
│ │ ├── loop_service.py
│ │ ├── pause_service.py
│ │ ├── play_service.py
│ │ ├── resume_service.py
│ │ ├── skip_service.py
│ │ └── stop_service.py
│ ├── queue
│ │ ├── __init__.py
│ │ ├── add_service.py
│ │ ├── clear_service.py
│ │ ├── remove_service.py
│ │ ├── show_service.py
│ │ └── shuffle_service.py
│ └── reset_service.py
├── utils
│ ├── __init__.py
│ ├── aioweb.py
│ ├── applications.py
│ ├── checks.py
│ ├── converter.py
│ ├── coro.py
│ ├── embed.py
│ ├── environment.py
│ ├── extensions.py
│ ├── intents.py
│ ├── logging.py
│ └── regex.py
└── views
│ ├── __init__.py
│ ├── activity_view.py
│ ├── promote_view.py
│ └── search_view.py
├── logs
└── .gitkeep
├── poetry.lock
├── pyproject.toml
└── tests
├── __init__.py
├── legacy.test_query.py
├── test_jukebot.py
├── test_music_request.py
├── test_radios.py
├── test_search_request.py
├── test_shazam_request.py
└── test_stream_request.py
/.devcontainer/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.11-bookworm
2 |
3 | WORKDIR /app
4 |
5 | RUN apt-get update && \
6 | apt-get install --no-install-recommends -y ffmpeg
7 |
8 | RUN pip install --no-cache-dir -U poetry
9 |
10 | COPY poetry.lock pyproject.toml ./
11 |
12 | RUN poetry config virtualenvs.create false \
13 | && poetry install --no-root --no-interaction --no-ansi --with dev
14 |
15 | COPY . ./
16 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the
2 | // README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose
3 | {
4 | "name": "JukeBot dev container",
5 |
6 | // Update the 'dockerComposeFile' list if you have more compose files or use different names.
7 | // The .devcontainer/docker-compose.yml file contains any overrides you need/want to make.
8 | "dockerComposeFile": [
9 | "../docker-compose.yml",
10 | "docker-compose.yml"
11 | ],
12 |
13 | // The 'service' property is the name of the service for the container that VS Code should
14 | // use. Update this value and .devcontainer/docker-compose.yml to the real service name.
15 | "service": "jukebot",
16 |
17 | // The optional 'workspaceFolder' property is the path VS Code should open by default when
18 | // connected. This is typically a file mount in .devcontainer/docker-compose.yml
19 | "workspaceFolder": "/app/${localWorkspaceFolderBasename}",
20 |
21 | // Features to add to the dev container. More info: https://containers.dev/features.
22 | // "features": {},
23 |
24 | // Configure tool-specific properties.
25 | "customizations": {
26 | "vscode": {
27 | "extensions": [
28 | "ms-azuretools.vscode-docker",
29 | "ms-python.python",
30 | "ms-python.vscode-pylance",
31 | "ms-python.black-formatter",
32 | "ms-python.isort",
33 | "njpwerner.autodocstring",
34 | "aaron-bond.better-comments",
35 | "donjayamanne.python-environment-manager",
36 | "mhutchie.git-graph"
37 | ]
38 | }
39 | }
40 |
41 | // Uncomment the next line to run commands after the container is created.
42 | // "postCreateCommand": "poetry install"
43 |
44 | // Use 'forwardPorts' to make a list of ports inside the container available locally.
45 | // "forwardPorts": [],
46 |
47 | // Uncomment the next line if you want start specific services in your Docker Compose config.
48 | // "runServices": [],
49 |
50 | // Uncomment the next line if you want to keep your containers running after VS Code shuts down.
51 | // "shutdownAction": "none",
52 |
53 | // Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root.
54 | // "remoteUser": "devcontainer"
55 | }
56 |
--------------------------------------------------------------------------------
/.devcontainer/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | # Update this to the name of the service you want to work with in your docker-compose.yml file
4 | jukebot:
5 | # Uncomment if you want to override the service's Dockerfile to one in the .devcontainer
6 | # folder. Note that the path of the Dockerfile and context is relative to the *primary*
7 | # docker-compose.yml file (the first in the devcontainer.json "dockerComposeFile"
8 | # array). The sample below assumes your primary file is in the root of your project.
9 | #
10 | build:
11 | context: .
12 | dockerfile: .devcontainer/Dockerfile
13 |
14 | volumes:
15 | # Update this to wherever you want VS Code to mount the folder of your project
16 | - ..:/app
17 |
18 | # Uncomment the next four lines if you will use a ptrace-based debugger like C++, Go, and Rust.
19 | # cap_add:
20 | # - SYS_PTRACE
21 | # security_opt:
22 | # - seccomp:unconfined
23 |
24 | # Overrides default command so things don't shut down after the process ends.
25 | command: /bin/sh -c "while sleep 1000; do :; done"
26 |
27 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # Ignore everything
2 | *
3 |
4 | # Allow directories
5 | !/data
6 | !/jukebot
7 | !/logs
8 |
9 | # Allow files
10 | !/docker-compose.yml
11 | !/Dockerfile
12 | !/.dockerignore
13 | !/poetry.lock
14 | !/pyproject.toml
15 | !/.env.example
16 | !/README.md
17 | !/LICENSE.md
18 |
19 | # Ignore unnecessary files inside allowed directories
20 | # This should go after the allowed directories
21 | **/*~
22 | **/*.log
23 | **/.DS_Store
24 | **/Thumbs.db
25 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # dev or prod
2 | ENVIRONMENT=dev
3 | LOG_LEVEL=DEBUG
4 | DISCORD_LOG_LEVEL=WARNING
5 |
6 | BOT_TOKEN=your_token_here
7 |
8 | BOT_TEST_GUILD_IDS="123456, 456789"
9 | # system's slash commands are deployed only in this guild.
10 | # it's highly recommended to set one or more guild ids here
11 | # to avoid deploying them globally.
12 | BOT_ADMIN_GUILD_IDS="123456"
13 |
14 | BOT_SEARCH_TIMEOUT=20.0
15 | BOT_MAX_SIZE_QUEUE=40
16 | BOT_MAX_IDLE_TIME=120.0
17 | BOT_OWNER_IDS=000000000000000000
18 |
19 | BOT_INVITE_URL=https://disnake.dev
20 | BOT_SERVER_URL=https://disnake.dev
21 | BOT_VOTE_URL=https://disnake.dev
22 | BOT_DONATE_URL=https://disnake.dev
23 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | ko_fi: dysta
4 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | description: Bug report template
4 | about: Bug report
5 | labels: 'bug'
6 |
7 | ---
8 | ### Description
9 |
10 | ##### Expected result
11 |
12 | ##### Actual result
13 |
14 | ### Reproduction Steps
15 |
16 | ### Other (screen)
17 |
18 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: true
2 | contact_links:
3 | - name: Discord
4 | url: https://dsc.gg/jukebot-land
5 | about: Please ask and answer questions here.
6 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/new_feature.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Enhancement
3 | description: New feature template
4 | about: New feature or request
5 | labels: 'enhancement'
6 | assignees: ''
7 |
8 | ---
9 | ### Description
10 |
11 |
12 | ### Other
13 |
14 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ## Summary
2 |
3 |
4 |
5 | ## Checklist
6 |
7 |
8 | - [ ] If code changes were made then they have been tested.
9 | - [ ] I have updated the documentation to reflect the changes.
10 | - [ ] This PR fixes an issue (#ID).
11 | - [ ] This PR adds something new (e.g. new feature/methods).
12 | - [ ] This PR is a breaking change (e.g. methods or parameters removed/renamed)
13 | - [ ] This PR is **not** a code change (e.g. documentation, README, ...)
14 |
--------------------------------------------------------------------------------
/.github/workflows/docker-image.yml:
--------------------------------------------------------------------------------
1 | name: Docker Image CI
2 |
3 | on: workflow_dispatch
4 |
5 | jobs:
6 | build:
7 |
8 | runs-on: ubuntu-latest
9 | env:
10 | repo_name: 'dysta/jukebot'
11 |
12 | steps:
13 | - name: Checkout
14 | uses: actions/checkout@v4
15 |
16 | - name: Login to Docker Hub
17 | uses: docker/login-action@v3
18 | with:
19 | username: ${{ secrets.DOCKERHUB_USERNAME }}
20 | password: ${{ secrets.DOCKERHUB_TOKEN }}
21 |
22 | - name: Build and push
23 | uses: docker/build-push-action@v5
24 | with:
25 | context: .
26 | file: Dockerfile
27 | push: true
28 | tags: '${{ env.repo_name }}:latest'
29 |
30 | - name: Update repo description
31 | uses: peter-evans/dockerhub-description@v3
32 | with:
33 | username: ${{ secrets.DOCKERHUB_USERNAME }}
34 | password: ${{ secrets.DOCKERHUB_TOKEN }}
35 | repository: ${{ env.repo_name }}
36 | short-description: Discord music bot written in Python 3
37 | readme-filepath: ./README.md
--------------------------------------------------------------------------------
/.github/workflows/python-unittest.yml:
--------------------------------------------------------------------------------
1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions
2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
3 |
4 | name: JukeBot CI Full
5 |
6 | on:
7 | push:
8 | branches:
9 | - 'main'
10 | pull_request:
11 | branches:
12 | - '**'
13 |
14 | jobs:
15 | unit-tests:
16 | name: Unittest Python
17 | runs-on: ubuntu-latest
18 | continue-on-error: true
19 |
20 | strategy:
21 | matrix:
22 | python-version: ['3.9', '3.10', '3.11']
23 |
24 | steps:
25 | - name: Checkout
26 | uses: actions/checkout@v4
27 |
28 | - name: Set up Python ${{ matrix.python-version }}
29 | uses: actions/setup-python@v2
30 | with:
31 | python-version: ${{ matrix.python-version }}
32 |
33 | - name: Set up Poetry
34 | uses: Gr1N/setup-poetry@v9
35 |
36 | - name: Cache dependencies
37 | uses: actions/cache@v4
38 | with:
39 | path: ~/.cache/pypoetry/virtualenvs
40 | key: ${{ runner.os }}-poetry-${{ hashFiles('poetry.lock') }}
41 | restore-keys: ${{ runner.os }}-poetry-${{ hashFiles('poetry.lock') }}
42 |
43 | - name: Install dependencies
44 | run: poetry install --without dev
45 |
46 | - name: Launch unit test
47 | run: poetry run task tests
48 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | **/__pycache__
2 |
3 | .env
4 |
5 | **/.mypy_cache
6 |
7 | **/.idea
8 |
9 | **/logs/*
10 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Utilisez IntelliSense pour en savoir plus sur les attributs possibles.
3 | // Pointez pour afficher la description des attributs existants.
4 | // Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "name": "Python: Module",
9 | "type": "python",
10 | "request": "launch",
11 | "module": "jukebot",
12 | "justMyCode": true
13 | },
14 | {
15 | "name": "Python: Current File",
16 | "type": "python",
17 | "request": "test", // <----- this is for tests
18 | "console": "integratedTerminal"
19 | }
20 | ]
21 | }
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "python.testing.unittestArgs": [
3 | "-v",
4 | "-s",
5 | "./tests",
6 | "-p",
7 | "test_*.py"
8 | ],
9 | "python.testing.pytestEnabled": false,
10 | "python.testing.unittestEnabled": true,
11 | "[python]": {
12 | "editor.defaultFormatter": "ms-python.black-formatter"
13 | },
14 | "python.formatting.provider": "none"
15 | }
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks": [
4 | {
5 | "type": "shell",
6 | "label": "autoflake.removeUnusedImports",
7 | "command": "${command:python.interpreterPath} -m",//or "${command:python.interpreterPath}\\..\\Activate.ps1\r\n",
8 | "args": [
9 | "autoflake",
10 | ".",
11 | "-r",
12 | "-i",
13 | "-v",
14 | "--remove-all-unused-imports",
15 | "--ignore-init-module-imports",
16 | "--remove-unused-variables",
17 | ],
18 | "presentation": {
19 | "echo": true,
20 | "reveal": "silent",
21 | "focus": false,
22 | "panel": "dedicated",
23 | "showReuseMessage": false,
24 | "clear": false,
25 | "close": false
26 | },
27 | "problemMatcher": []
28 | },
29 | ]
30 | }
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.10-slim-bookworm
2 |
3 | WORKDIR /app
4 |
5 | RUN apt-get update && \
6 | apt-get install --no-install-recommends -y ffmpeg && \
7 | apt-get clean && rm -rf /var/lib/apt/lists/*
8 |
9 | RUN pip install --no-cache-dir -U poetry
10 |
11 | COPY poetry.lock pyproject.toml ./
12 |
13 | RUN poetry config virtualenvs.create false \
14 | && poetry install --no-root --no-interaction --no-ansi --compile --without dev -E speed
15 |
16 | COPY . ./
17 |
18 | CMD ["poetry", "run", "task", "start"]
19 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | [](#readme)
4 |
5 |
6 | # JukeBot
7 | [](https://github.com/DisnakeDev/disnake "Powered by Disnake")
8 | [](https://python-poetry.org "Powered by Poetry") \
9 | Discord music bot written in Python 3
10 | ___
11 |
12 | ## 🧩 Installation
13 | ```
14 | git clone https://github.com/Dysta/JukeBot
15 | cd JukeBot
16 | poetry install
17 | ```
18 |
19 | ## ⚙ Configuration
20 | Rename `.env.example` to `.env` and fill in the values.
21 |
22 | ## 🚀 Launch
23 | Run `poetry run task start`.
24 |
25 | ## ⁉ Other
26 | The bot needs [ffmpeg](https://ffmpeg.org/) to work.
27 | ### Install on Windows
28 | Use the package manager [Chocolatey](https://community.chocolatey.org/) and run in an **admin shell** `choco install ffmpeg`.
29 |
30 | ### Install on Linux
31 | Use **apt**, run `sudo apt install ffmpeg`.
32 |
33 | ### Install on MacOS
34 | Use **brew**, run `brew install ffmpeg`.
35 |
36 | ___
37 |
38 | ## 🌐 Deployment
39 | Clone the repos. \
40 | Rename `.env.example` to `.env` and fill in the values. \
41 | Run `docker-compose up -d`.
42 |
43 | **OR**
44 |
45 | C/C the file `.env.example` in local, fill in the values. \
46 | Run `docker run --env-file dysta/jukebot`.
47 |
48 | ___
49 |
50 | ## 🔮 Devcontainer
51 | Clone the project then open it in `vscode`. If you have the `devcontainer` extension, it will ask you to reopen the project in a devcontainer. If not, open the commands prompt then search for **reopen in container**. \
52 | All the needs will be automatically installed.
53 |
54 | ___
55 |
56 |
57 | 🗨 Features & Commands
58 |
59 |
60 | ### Music
61 | - [X] **`join`**
62 | - [X] **`play`**
63 | - [X] **`playtop`**
64 | - [X] **`playskip`**
65 | - [X] **`search`**
66 | - [X] **`nowplaying`**
67 | - [X] **`grab`**
68 | - [ ] **`seek`**
69 | - [X] **`loop`**
70 | - [X] **`pause`**
71 | - [X] **`resume`**
72 | - [ ] **`lyrics`**
73 | - [X] **`disconnect`**
74 | - [X] **`share`**
75 | ### Queue
76 | - [X] **`queue`**
77 | - [X] **`loopqueue`**
78 | - [ ] **`move`**
79 | - [ ] **`skipto`**
80 | - [X] **`shuffle`**
81 | - [X] **`remove`**
82 | - [X] **`clear`**
83 | - [ ] **`removedupes`**
84 | ### Utility
85 | - [X] **`prefix`**
86 | - [X] **`reset`**
87 | ### Effect
88 | - [ ] **`speed`**
89 | - [ ] **`bass`**
90 | - [ ] **`nightcore`**
91 | - [ ] **`slowed`**
92 | ### Others
93 | - [X] **`info`**
94 | - [X] **`invite`**
95 | - [X] **`donate`**
96 | - [X] **`watch`**
97 | - [X] **`help`**
98 |
99 |
100 | ## 🤝 Contributing
101 |
102 | Contributions are what make the open source community an amazing place to learn, be inspired, and create.
103 | Any contributions you make are **greatly appreciated**.
104 |
105 | 1. [Fork the repository](https://github.com/Dysta/JukeBot/fork)
106 | 2. Clone your fork `git clone https://github.com/Dysta/JukeBot.git`
107 | 3. Create your feature branch `git checkout -b AmazingFeature`
108 | 4. Stage changes `git add .`
109 | 5. Commit your changes `git commit -m 'Added some AmazingFeature'`
110 | 6. Push to the branch `git push origin AmazingFeature`
111 | 7. Submit a pull request
112 |
113 | ## ❤️ Credits
114 |
115 | Released with ❤️ by [Dysta](https://github.com/Dysta).
116 |
--------------------------------------------------------------------------------
/data/radios.yaml:
--------------------------------------------------------------------------------
1 | - lofi:
2 | - "https://www.youtube.com/watch?v=jfKfPfyJRdk"
3 | - "https://www.youtube.com/watch?v=rUxyKA_-grg"
4 | - "https://www.youtube.com/watch?v=HAZoLuME-PU"
5 | - "https://www.youtube.com/watch?v=7NOSDKb0HlU"
6 |
7 | - jazz:
8 | - "https://www.youtube.com/watch?v=Dx5qFachd3A"
9 | - "https://www.youtube.com/watch?v=fEvM-OUbaKs"
10 | - "https://www.youtube.com/watch?v=g06AjrOlki0"
11 | - "https://www.youtube.com/watch?v=DSGyEsJ17cI"
12 | - "https://www.youtube.com/watch?v=c3IVTi6TlfE"
13 | - "https://www.youtube.com/watch?v=_rMZt292mJc"
14 |
15 | - rocknroll:
16 | - "https://www.youtube.com/watch?v=zdBGqWnpDRk"
17 | - "https://www.youtube.com/playlist?list=PLZN_exA7d4RVmCQrG5VlWIjMOkMFZVVOc"
18 |
19 | - chiptune:
20 | - "https://www.youtube.com/watch?v=KzFmtxFG9z4"
21 | - "https://www.youtube.com/watch?v=VcQqVMQHYYU&pp=ygUOY2hpcHR1bmUgcmFkaW8%3D"
22 |
23 | - synthwave:
24 | - "https://www.youtube.com/playlist?list=PL9a7fFpVuuJCNx1VfUKlusNP-4TGBTuz5"
25 | - "https://www.youtube.com/watch?v=Ss3DEK1rGuM"
26 | - "https://www.youtube.com/watch?v=FvwJ_Fh5_uE"
27 | - "https://www.youtube.com/watch?v=4xDzrJKXOOY"
28 |
29 | - lounge:
30 | - "https://www.youtube.com/watch?v=fEvM-OUbaKs"
31 | - "https://www.youtube.com/watch?v=Dx5qFachd3A"
32 | - "https://www.youtube.com/watch?v=XQuR1OxYJt0"
33 | - "https://www.youtube.com/watch?v=3XbEUv_MCj0"
34 |
35 | - minecraft:
36 | - "https://www.youtube.com/watch?v=Pa_s7ogtokM"
37 | - "https://www.youtube.com/watch?v=TsTtqGAxvWk"
38 | - "https://www.youtube.com/watch?v=Dg0IjOzopYU"
39 | - "https://www.youtube.com/watch?v=snphzO9UFJM"
40 | - "https://www.youtube.com/watch?v=0KvlwMd3C4Y"
41 | - "https://www.youtube.com/playlist?list=PL3817D41C7D841E23"
42 |
43 | - rap:
44 | - "https://www.youtube.com/watch?v=05689ErDUdM"
45 | - "https://www.youtube.com/watch?v=0MOkLkTP-Jk"
46 |
47 | - phonk:
48 | - https://www.youtube.com/watch?v=S6helKOW5P0
49 | - https://www.youtube.com/watch?v=8v_kKMaq5po
50 |
51 | - poprock:
52 | - "https://www.youtube.com/watch?v=EurKD84TFtA"
53 | - "https://www.youtube.com/watch?v=uRImXboQnzE"
54 | - "https://www.youtube.com/watch?v=Va-h6WZPUzQ"
55 |
56 | - workout:
57 | - "https://www.youtube.com/watch?v=qWf-FPFmVw0"
58 | - "https://www.youtube.com/watch?v=fBnpWP4Fneg"
59 | - "https://www.youtube.com/watch?v=bT408U-LOn8"
60 |
61 | - trap:
62 | - "https://www.youtube.com/watch?v=EA-6o1_vrsA"
63 | - "https://www.youtube.com/watch?v=-cCR-oqsLRQ"
64 | - https://www.youtube.com/watch?v=EapVttArmsE
65 |
66 | - slowed:
67 | - "https://www.youtube.com/playlist?list=PLF_ZnpSKNQQFTeHaQ1ZR5l5cx5nRiD80w"
68 | - "https://www.youtube.com/playlist?list=PLsmLp2JHrigK1FBB-nuy3panZJzH5sHWO"
69 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 |
3 | services:
4 |
5 | jukebot:
6 | image: dysta/jukebot
7 | build: .
8 | deploy:
9 | restart_policy:
10 | condition: on-failure
11 | max_attempts: 3
12 | window: 15s
13 | env_file:
14 | - .env
15 | volumes:
16 | - .:/app:cached
17 |
--------------------------------------------------------------------------------
/jukebot/__init__.py:
--------------------------------------------------------------------------------
1 | __version__ = "0.1.0"
2 | from .jukebot import JukeBot
3 |
--------------------------------------------------------------------------------
/jukebot/__main__.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import logging as plogging
4 | import os
5 | import sys
6 | from typing import List, Optional, Set
7 |
8 | from disnake import Activity, ActivityType
9 | from disnake.ext import commands
10 | from dotenv import load_dotenv
11 | from loguru import logger
12 |
13 | from jukebot import JukeBot
14 | from jukebot.utils import Extensions, intents, logging
15 |
16 |
17 | def get_owner_ids() -> Set[int]:
18 | ids = os.environ["BOT_OWNER_IDS"].split(",")
19 | return set(map(int, ids))
20 |
21 |
22 | def get_test_guilds_ids() -> Optional[List[int]]:
23 | ids = list(filter(lambda x: x, os.environ["BOT_TEST_GUILD_IDS"].split(",")))
24 | if not ids:
25 | return None
26 | return list(map(int, ids))
27 |
28 |
29 | def main():
30 | load_dotenv(".env")
31 | logging.set_logging(
32 | plogging.getLevelName(os.environ["LOG_LEVEL"]),
33 | intercept_disnake_log=True,
34 | disnake_loglevel=plogging.getLevelName(os.environ["DISCORD_LOG_LEVEL"]),
35 | )
36 |
37 | bot = JukeBot(
38 | activity=Activity(name=f"some good vibes 🎶", type=ActivityType.listening),
39 | intents=intents.get(),
40 | owner_ids=get_owner_ids(),
41 | test_guilds=get_test_guilds_ids(),
42 | )
43 |
44 | for e in Extensions.all():
45 | try:
46 | bot.load_extension(name=f"{e['package']}.{e['name']}")
47 | logger.success(f"Extension '{e['package']}.{e['name']}' loaded")
48 | except commands.ExtensionNotFound as e:
49 | logger.error(e)
50 |
51 | logger.info(f"Starting bot...")
52 | bot.run(os.environ["BOT_TOKEN"])
53 |
54 |
55 | if __name__ == "__main__":
56 | try:
57 | if not sys.platform in ("win32", "cygwin", "cli"):
58 | import uvloop
59 |
60 | uvloop.install()
61 | except ImportError:
62 | logger.opt(lazy=True).info("Bot launch without speed extra.")
63 | main()
64 |
--------------------------------------------------------------------------------
/jukebot/abstract_components/__init__.py:
--------------------------------------------------------------------------------
1 | from .abstract_cache import AbstractCache
2 | from .abstract_collection import AbstractCollection
3 | from .abstract_map import AbstractMap
4 | from .abstract_request import AbstractRequest
5 | from .abstract_service import AbstractService
6 |
--------------------------------------------------------------------------------
/jukebot/abstract_components/abstract_cache.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from collections import abc
4 | from typing import Generic, TypeVar
5 |
6 | _T = TypeVar("_T")
7 | _V = TypeVar("_V")
8 |
9 |
10 | class AbstractCache(abc.MutableSequence, Generic[_T, _V]):
11 | _cache: dict = {}
12 |
13 | def __setitem__(self, k: _T, v: _V) -> None:
14 | self._cache[k] = v
15 |
16 | def __contains__(self, k: _T):
17 | try:
18 | self._cache[k]
19 | except KeyError:
20 | return False
21 | return True
22 |
23 | def __delitem__(self, k: _T) -> None:
24 | del self._cache[k]
25 |
26 | def __getitem__(self, k: _T) -> _V:
27 | return self._cache[k]
28 |
29 | def __len__(self) -> int:
30 | return len(self._cache)
31 |
32 | def __str__(self) -> str:
33 | return str(self._cache)
34 |
35 | def clear(self) -> None:
36 | self._cache = {}
37 |
38 | def insert(self, index: _T, value: _V) -> None:
39 | self.__setitem__(index, value)
40 |
--------------------------------------------------------------------------------
/jukebot/abstract_components/abstract_collection.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from collections import abc
4 | from dataclasses import dataclass
5 | from typing import Generic, Iterator, List, TypeVar
6 |
7 | _T = TypeVar("_T")
8 |
9 |
10 | @dataclass
11 | class AbstractCollection(abc.Collection, Generic[_T]):
12 | set: List[_T]
13 |
14 | def __len__(self) -> int:
15 | return len(self.set)
16 |
17 | def __iter__(self) -> Iterator[_T]:
18 | for e in self.set:
19 | yield e
20 |
21 | def __contains__(self, e: object) -> bool:
22 | return e in self.set
23 |
24 | def __getitem__(self, idx: int) -> _T:
25 | return self.set[idx]
26 |
27 | def __str__(self) -> str:
28 | return str(self.set)
29 |
30 | def __add__(self, other: AbstractCollection) -> AbstractCollection:
31 | assert isinstance(other, AbstractCollection)
32 | self.set += other.set
33 | return self
34 |
--------------------------------------------------------------------------------
/jukebot/abstract_components/abstract_map.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from collections import abc
4 | from typing import Generic, Iterator, TypeVar
5 |
6 | _T = TypeVar("_T")
7 | _V = TypeVar("_V")
8 |
9 |
10 | class AbstractMap(abc.MutableMapping, Generic[_T, _V]):
11 | def __new__(cls):
12 | instance = super(AbstractMap, cls).__new__(cls)
13 | instance._collection = dict()
14 | return instance
15 |
16 | def __setitem__(self, k: _T, v: _V) -> None:
17 | self._collection[k] = v
18 |
19 | def __contains__(self, k: _T):
20 | return k in self._collection.keys()
21 |
22 | def __delitem__(self, k: _T) -> None:
23 | del self._collection[k]
24 |
25 | def __getitem__(self, k: _T) -> _V:
26 | return self._collection[k]
27 |
28 | def __len__(self) -> int:
29 | return len(self._collection)
30 |
31 | def __iter__(self) -> Iterator[_T]:
32 | for e in self._collection:
33 | yield e
34 |
35 | def __str__(self) -> str:
36 | return str(self._collection)
37 |
--------------------------------------------------------------------------------
/jukebot/abstract_components/abstract_request.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 | from typing import Any
3 |
4 |
5 | class AbstractRequest(ABC):
6 | """Class that represent a request made by the user during a command."""
7 |
8 | def __init__(self, query: str) -> None:
9 | self._query: str = query
10 | self._result: Any = None
11 | self._success: bool = False
12 |
13 | @abstractmethod
14 | async def setup(self):
15 | """Setup the Request. Called before the execute method."""
16 |
17 | @abstractmethod
18 | async def execute(self):
19 | """Execute the Request and handle the result type"""
20 |
21 | @abstractmethod
22 | async def terminate(self):
23 | """Cleanup the Request. Called after the execute method."""
24 |
25 | async def __aenter__(self):
26 | await self.setup()
27 | return self
28 |
29 | async def __aexit__(self, exc_type, exc_val, exc_tb):
30 | await self.terminate()
31 |
32 | @property
33 | def query(self) -> str:
34 | """Return the current query
35 |
36 | Returns
37 | -------
38 | str
39 | The current query
40 | """
41 | return self._query
42 |
43 | @property
44 | def result(self) -> Any:
45 | """Return the current result
46 |
47 | Returns
48 | -------
49 | Any
50 | The current result
51 | """
52 | return self._result
53 |
54 | @property
55 | def success(self) -> bool:
56 | """Return the current success
57 |
58 | Returns
59 | -------
60 | bool
61 | The current success
62 | """
63 | return self._success
64 |
--------------------------------------------------------------------------------
/jukebot/abstract_components/abstract_service.py:
--------------------------------------------------------------------------------
1 | class AbstractService:
2 | """This class allow you to implement your own service and using it from the bot.
3 | To implement a service, create a new file in the `services` package then implement this class.
4 | Don't forget, services are named `XXXService` where `XXX` is your action.
5 | To register a service, go to the cog where it's used then in the setup function, add your service by using `bot.add_service(XXXService())`.
6 | To use a service: `bot.services.XXX` where `XXX` is your previous named action.
7 | """
8 |
9 | def __init__(self, bot):
10 | self.bot = bot
11 |
12 | async def __call__(self, *args, **kwargs):
13 | raise NotImplementedError
14 |
15 | def __enter__(self):
16 | return self
17 |
18 | def __exit__(self, exc_type, exc_val, exc_tb): ...
19 |
--------------------------------------------------------------------------------
/jukebot/cogs/__init__.py:
--------------------------------------------------------------------------------
1 | from .music import Music
2 | from .system import System
3 | from .utility import Utility
4 |
--------------------------------------------------------------------------------
/jukebot/cogs/music.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Optional
4 | from urllib import parse
5 |
6 | from disnake import APISlashCommand, CommandInteraction, Embed, Forbidden
7 | from disnake.ext import commands
8 | from disnake.ext.commands import BucketType
9 |
10 | from jukebot import JukeBot
11 | from jukebot.components.player import Player
12 | from jukebot.components.requests import ShazamRequest
13 | from jukebot.exceptions import QueryFailed
14 | from jukebot.services.music import (
15 | CurrentSongService,
16 | GrabService,
17 | JoinService,
18 | LeaveService,
19 | LoopService,
20 | PauseService,
21 | PlayService,
22 | ResumeService,
23 | SkipService,
24 | StopService,
25 | )
26 | from jukebot.utils import aioweb, checks, embed, regex
27 |
28 |
29 | class Music(commands.Cog):
30 | def __init__(self, bot):
31 | self.bot: JukeBot = bot
32 |
33 | @commands.slash_command()
34 | @commands.cooldown(1, 5.0, BucketType.user)
35 | @commands.check(checks.user_is_connected)
36 | async def play(self, inter: CommandInteraction, query: str, top: Optional[bool] = False):
37 | """Play music from URL or search
38 |
39 | Parameters
40 | ----------
41 | inter : CommandInteraction
42 | The interaction
43 | query : str
44 | The URL or search to play
45 | top : Optional[bool], optional
46 | Put the requested song at the top of the queue, by default False
47 | """
48 | if self.bot.players.get(inter.guild.id).is_playing:
49 | # ? if player is playing, play command is just a shortcut for queue add command
50 | await self.bot.get_slash_command("queue add").callback(self, inter, query, top)
51 | return
52 |
53 | if not inter.response.is_done():
54 | await inter.response.defer()
55 |
56 | song, loop = await self.bot.services.play(interaction=inter, query=query, top=top)
57 |
58 | e: Embed = embed.music_message(song, loop)
59 | await inter.edit_original_message(embed=e)
60 |
61 | @commands.slash_command()
62 | @commands.cooldown(1, 5.0, BucketType.user)
63 | @commands.check(checks.bot_and_user_in_same_channel)
64 | @commands.check(checks.bot_is_connected)
65 | @commands.check(checks.user_is_connected)
66 | async def leave(self, inter: CommandInteraction):
67 | """Disconnect the bot from the current connected voice channel.
68 |
69 | Parameters
70 | ----------
71 | inter : CommandInteraction
72 | The interaction
73 | """
74 | await self.bot.services.leave(guild_id=inter.guild.id)
75 |
76 | e = embed.basic_message(title="Player disconnected")
77 | await inter.send(embed=e)
78 |
79 | @commands.slash_command()
80 | @commands.cooldown(1, 5.0, BucketType.user)
81 | @commands.check(checks.bot_is_playing)
82 | @commands.check(checks.bot_and_user_in_same_channel)
83 | @commands.check(checks.bot_is_connected)
84 | @commands.check(checks.user_is_connected)
85 | async def stop(self, inter: CommandInteraction):
86 | """Stop and skip current music without disconnecting bot from voice channel
87 |
88 | Parameters
89 | ----------
90 | inter : CommandInteraction
91 | The interaction
92 | """
93 | await self.bot.services.stop(guild_id=inter.guild.id)
94 |
95 | e = embed.basic_message(title="Player stopped")
96 | await inter.send(embed=e)
97 |
98 | @commands.slash_command()
99 | @commands.cooldown(1, 5.0, BucketType.user)
100 | @commands.check(checks.bot_is_playing)
101 | @commands.check(checks.bot_and_user_in_same_channel)
102 | @commands.check(checks.bot_is_connected)
103 | @commands.check(checks.user_is_connected)
104 | async def pause(self, inter: CommandInteraction):
105 | """Pause the current music without skipping it
106 |
107 | Parameters
108 | ----------
109 | inter : CommandInteraction
110 | The interaction
111 | """
112 | await self.bot.services.pause(guild_id=inter.guild.id)
113 |
114 | e = embed.basic_message(title="Player paused")
115 | await inter.send(embed=e)
116 |
117 | @commands.slash_command()
118 | @commands.cooldown(1, 5.0, BucketType.user)
119 | @commands.check(checks.bot_is_not_playing)
120 | @commands.check(checks.bot_and_user_in_same_channel)
121 | @commands.check(checks.bot_is_connected)
122 | @commands.check(checks.user_is_connected)
123 | async def resume(self, inter: CommandInteraction):
124 | """Resumes the current or the next music
125 |
126 | Parameters
127 | ----------
128 | inter : CommandInteraction
129 | The interaction
130 | """
131 | player: Player = self.bot.players[inter.guild.id]
132 | if player.state.is_stopped and not player.queue.is_empty():
133 | # ? if player is stopped but queue isn't empty, resume the queue
134 | await self.bot.get_slash_command("play").callback(self, inter, query="")
135 | return
136 |
137 | ok = await self.bot.services.resume(guild_id=inter.guild.id)
138 |
139 | if ok:
140 | e = embed.basic_message(title="Player resumed")
141 | else:
142 | cmd: APISlashCommand = self.bot.get_global_command_named("play")
143 | e = embed.basic_message(
144 | title="Nothing is currently playing", content=f"Try to add a music !"
145 | )
146 |
147 | await inter.send(embed=e)
148 |
149 | @commands.slash_command()
150 | @commands.cooldown(1, 5.0, BucketType.user)
151 | @commands.check(checks.bot_and_user_in_same_channel)
152 | @commands.check(checks.bot_is_connected)
153 | @commands.check(checks.user_is_connected)
154 | async def current(self, inter: CommandInteraction):
155 | """Shows the music currently playing and its progress
156 |
157 | Parameters
158 | ----------
159 | inter : CommandInteraction
160 | The interaction
161 | """
162 | song, stream, loop = await self.bot.services.current_song(guild_id=inter.guild.id)
163 |
164 | if stream and song:
165 | e = embed.music_message(song, loop, stream.progress)
166 | else:
167 | cmd: APISlashCommand = self.bot.get_global_command_named("play")
168 | e = embed.basic_message(
169 | title="Nothing is currently playing", content=f"Try to add a music !"
170 | )
171 |
172 | await inter.send(embed=e)
173 |
174 | @commands.slash_command()
175 | @commands.cooldown(1, 5.0, BucketType.user)
176 | @commands.check(checks.user_is_connected)
177 | @commands.check(checks.bot_is_not_connected)
178 | async def join(self, inter: CommandInteraction):
179 | """Connect the bot to the voice channel you are in
180 |
181 | Parameters
182 | ----------
183 | inter : CommandInteraction
184 | The interaction
185 | """
186 | await self.bot.services.join(interaction=inter)
187 |
188 | e = embed.basic_message(
189 | content=f"Connected to <#{inter.author.voice.channel.id}>\n" f"Bound to <#{inter.channel.id}>\n",
190 | )
191 | await inter.send(embed=e)
192 |
193 | @commands.slash_command()
194 | @commands.cooldown(3, 10.0, BucketType.user)
195 | @commands.check(checks.bot_is_streaming)
196 | @commands.check(checks.bot_and_user_in_same_channel)
197 | @commands.check(checks.bot_is_connected)
198 | @commands.check(checks.user_is_connected)
199 | async def skip(self, inter: CommandInteraction):
200 | """Skips to the next music in the queue. Stop the music if the queue is empty
201 |
202 | Parameters
203 | ----------
204 | inter : CommandInteraction
205 | The interaction
206 | """
207 | await self.bot.services.skip(guild_id=inter.guild.id)
208 |
209 | e: embed = embed.basic_message(title="Skipped !")
210 | await inter.send(embed=e)
211 |
212 | @commands.slash_command()
213 | @commands.check(checks.bot_is_playing)
214 | @commands.check(checks.bot_and_user_in_same_channel)
215 | @commands.check(checks.bot_is_connected)
216 | @commands.check(checks.user_is_connected)
217 | @commands.cooldown(1, 10.0, BucketType.user)
218 | async def grab(self, inter: CommandInteraction):
219 | """Similar to the current command but sends the message in DM
220 |
221 | Parameters
222 | ----------
223 | inter : CommandInteraction
224 | The interaction
225 | """
226 | song, stream = await self.bot.services.grab(guild_id=inter.guild.id)
227 |
228 | e = embed.grab_message(song, stream.progress)
229 | e.add_field(
230 | name="Voice channel",
231 | value=f"`{inter.guild.name} — {inter.author.voice.channel.name}`",
232 | )
233 | try:
234 | await inter.author.send(embed=e)
235 | await inter.send("Check your DMs!", ephemeral=True)
236 | except Forbidden:
237 | await inter.send("Your DMs are closed!", embed=e, ephemeral=True)
238 |
239 | @commands.slash_command()
240 | @commands.cooldown(1, 5.0, BucketType.user)
241 | @commands.check(checks.bot_and_user_in_same_channel)
242 | @commands.check(checks.bot_is_connected)
243 | @commands.check(checks.user_is_connected)
244 | async def loop(
245 | self,
246 | inter: CommandInteraction,
247 | mode: commands.option_enum(["song", "queue", "none"]),
248 | ):
249 | """
250 | Allow user to enable or disable the looping of a song or queue.
251 |
252 | Parameters
253 | ----------
254 | inter: The interaction
255 | mode: The loop mode
256 | - song (loop the current song)
257 | - queue (loop the current queue)
258 | - none (disable looping)
259 | """
260 | new_status = await self.bot.services.loop(guild_id=inter.guild.id, mode=mode)
261 |
262 | e: embed = embed.basic_message(title=new_status)
263 | await inter.send(embed=e)
264 |
265 | @commands.slash_command()
266 | @commands.cooldown(1, 15.0, BucketType.guild)
267 | @commands.max_concurrency(1, BucketType.guild)
268 | async def find(self, inter: CommandInteraction, url: str):
269 | """
270 | Parses the media at the given URL and returns the music used in it
271 |
272 | Parameters
273 | ----------
274 | inter: The interaction
275 | url: The url of the media to analyze
276 | """
277 | if not regex.is_url(url):
278 | raise commands.UserInputError("Query must be an url to a media")
279 |
280 | await inter.response.defer()
281 |
282 | async with ShazamRequest(url) as req:
283 | await req.execute()
284 |
285 | if not req.success:
286 | raise QueryFailed(f"No music found for this media..", query="", full_query=url)
287 |
288 | e: Embed = embed.music_found_message(req.result)
289 | await inter.edit_original_message(embed=e)
290 |
291 | @commands.slash_command()
292 | @commands.cooldown(1, 10.0, BucketType.guild)
293 | @commands.max_concurrency(1, BucketType.guild)
294 | async def share(self, inter: CommandInteraction, url: str):
295 | """
296 | Returns all streaming platforms available for the given media
297 |
298 | Parameters
299 | ----------
300 | inter: The inter
301 | url: The url of the music/album to share
302 | """
303 | if not regex.is_url(url):
304 | raise commands.UserInputError("You must provide an URL.")
305 |
306 | await inter.response.defer()
307 |
308 | base_url = "https://api.song.link/v1-alpha.1/links?url=%s"
309 | code, data = await aioweb.cached_query(base_url % parse.quote(url))
310 |
311 | if code != 200:
312 | raise commands.UserInputError("The URL didn't return anything")
313 |
314 | content = " | ".join(
315 | f"[{k.capitalize()}]({v['url']})" for k, v in sorted(data["linksByPlatform"].items(), key=lambda x: x)
316 | )
317 | title: str = data["entitiesByUniqueId"][data["entityUniqueId"]].get("title", "Unknown title")
318 | artist: str = data["entitiesByUniqueId"][data["entityUniqueId"]].get("artistName", "Unknown artist")
319 | img: str = data["entitiesByUniqueId"][data["entityUniqueId"]].get("thumbnailUrl", "")
320 | e: Embed = embed.share_message(
321 | inter.author,
322 | title=f"{artist} - {title}",
323 | content=content,
324 | url=data["pageUrl"],
325 | img=img,
326 | )
327 | await inter.edit_original_message(embed=e)
328 |
329 |
330 | def setup(bot: JukeBot):
331 | bot.add_cog(Music(bot))
332 |
333 | bot.add_service(GrabService(bot))
334 | bot.add_service(JoinService(bot))
335 | bot.add_service(LeaveService(bot))
336 | bot.add_service(LoopService(bot))
337 | bot.add_service(PauseService(bot))
338 | bot.add_service(PlayService(bot))
339 | bot.add_service(ResumeService(bot))
340 | bot.add_service(SkipService(bot))
341 | bot.add_service(StopService(bot))
342 | bot.add_service(CurrentSongService(bot))
343 |
--------------------------------------------------------------------------------
/jukebot/cogs/queue.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING, Optional
4 |
5 | from disnake import CommandInteraction, Embed
6 | from disnake.ext import commands
7 | from disnake.ext.commands import BucketType
8 |
9 | from jukebot import JukeBot
10 | from jukebot.components.requests.music_request import MusicRequest
11 | from jukebot.services.queue import (
12 | AddService,
13 | ClearService,
14 | RemoveService,
15 | ShowService,
16 | ShuffleService,
17 | )
18 | from jukebot.utils import checks, embed
19 |
20 | if TYPE_CHECKING:
21 | from jukebot.components import Result, ResultSet
22 |
23 |
24 | class Queue(commands.Cog):
25 | def __init__(self, bot):
26 | self.bot: JukeBot = bot
27 |
28 | @commands.slash_command()
29 | async def queue(self, inter: CommandInteraction):
30 | """Base command to perform manipulations on the bot queue
31 |
32 | Parameters
33 | ----------
34 | inter : CommandInteraction
35 | The interaction
36 | """
37 |
38 | @queue.sub_command()
39 | @commands.cooldown(1, 3.0, BucketType.user)
40 | @commands.check(checks.bot_is_connected)
41 | @commands.check(checks.user_is_connected)
42 | async def show(self, inter: CommandInteraction):
43 | """Shows queue, music count and total duration
44 |
45 | Parameters
46 | ----------
47 | inter : CommandInteraction
48 | The interaction
49 | """
50 | queue: ResultSet = await self.bot.services.show(guild_id=inter.guild.id)
51 |
52 | e: Embed = embed.queue_message(queue, self.bot, title=f"Queue for {inter.guild.name}")
53 | await inter.send(embed=e)
54 |
55 | @queue.sub_command()
56 | @commands.cooldown(1, 5.0, BucketType.user)
57 | @commands.check(checks.bot_and_user_in_same_channel)
58 | @commands.check(checks.bot_is_connected)
59 | @commands.check(checks.user_is_connected)
60 | async def add(
61 | self,
62 | inter: CommandInteraction,
63 | query: str,
64 | top: Optional[bool] = False,
65 | ):
66 | """Add a song to the current queue
67 |
68 | Parameters
69 | ----------
70 | inter: The interaction
71 | query: the URL or query to play
72 | top: Whether or not to put music at the top of the queue
73 | """
74 | if not inter.response.is_done():
75 | await inter.response.defer()
76 |
77 | type, res = await self.bot.services.add(guild_id=inter.guild.id, author=inter.author, query=query, top=top)
78 |
79 | if type == MusicRequest.ResultType.PLAYLIST:
80 | e: Embed = embed.basic_queue_message(content=f"Enqueued : {len(res)} songs")
81 | else:
82 | e: Embed = embed.result_enqueued(res)
83 | await inter.edit_original_message(embed=e)
84 |
85 | @queue.sub_command()
86 | @commands.cooldown(1, 5.0, BucketType.user)
87 | @commands.check(checks.bot_queue_is_not_empty)
88 | @commands.check(checks.bot_and_user_in_same_channel)
89 | @commands.check(checks.bot_is_connected)
90 | @commands.check(checks.user_is_connected)
91 | async def clear(self, inter: CommandInteraction):
92 | """Remove all music from the current queue
93 |
94 | Parameters
95 | ----------
96 | inter : CommandInteraction
97 | The interaction
98 | """
99 | await self.bot.services.clear(guild_id=inter.guild.id)
100 |
101 | e: Embed = embed.basic_message(title="The queue have been cleared.")
102 | await inter.send(embed=e)
103 |
104 | @queue.sub_command()
105 | @commands.check(checks.bot_queue_is_not_empty)
106 | @commands.check(checks.bot_and_user_in_same_channel)
107 | @commands.check(checks.bot_is_connected)
108 | @commands.check(checks.user_is_connected)
109 | @commands.cooldown(1, 10.0, BucketType.guild)
110 | async def shuffle(self, inter: CommandInteraction):
111 | """Shuffles the current queue
112 |
113 | Parameters
114 | ----------
115 | inter : CommandInteraction
116 | The interaction
117 | """
118 | await self.bot.services.shuffle(guild_id=inter.guild.id)
119 |
120 | e: Embed = embed.basic_message(title="Queue shuffled.")
121 | await inter.send(embed=e)
122 |
123 | @queue.sub_command()
124 | @commands.cooldown(1, 5.0, BucketType.user)
125 | @commands.check(checks.bot_queue_is_not_empty)
126 | @commands.check(checks.bot_and_user_in_same_channel)
127 | @commands.check(checks.bot_is_connected)
128 | @commands.check(checks.user_is_connected)
129 | async def remove(self, inter: CommandInteraction, song: str):
130 | """Remove a song from the queue
131 |
132 | Parameters
133 | ----------
134 | inter: The interaction
135 | song: The name of the music to remove. The bot auto completes the answer
136 | """
137 | elem: Result = await self.bot.services.remove(guild_id=inter.guild.id, song=song)
138 |
139 | e: Embed = embed.basic_message(content=f"`{elem.title}` have been removed from the queue")
140 | await inter.send(embed=e)
141 |
142 | @remove.autocomplete("song")
143 | async def remove_autocomplete(self, inter: CommandInteraction, data: str):
144 | data = data.lower()
145 | queue: ResultSet = self.bot.players[inter.guild.id].queue
146 | return [e.title for e in queue if data in e.title.lower()][:25]
147 |
148 |
149 | def setup(bot: JukeBot):
150 | bot.add_cog(Queue(bot))
151 |
152 | bot.add_service(AddService(bot))
153 | bot.add_service(ClearService(bot))
154 | bot.add_service(RemoveService(bot))
155 | bot.add_service(ShuffleService(bot))
156 | bot.add_service(ShowService(bot))
157 |
--------------------------------------------------------------------------------
/jukebot/cogs/radio.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import random
4 | from typing import TYPE_CHECKING
5 |
6 | from disnake import CommandInteraction
7 | from disnake.ext import commands
8 | from disnake.ext.commands import BucketType
9 | from loguru import logger
10 |
11 | from jukebot import JukeBot
12 | from jukebot.utils import checks, converter, embed
13 |
14 | if TYPE_CHECKING:
15 | from disnake import Embed
16 |
17 |
18 | class Radio(commands.Cog):
19 | def __init__(self, bot):
20 | self.bot: JukeBot = bot
21 | self._radios: dict = {}
22 |
23 | async def cog_load(self) -> None:
24 | self._radios = converter.radios_yaml_to_dict()
25 |
26 | async def _radio_process(self, inter: CommandInteraction, choices: list):
27 | query: str = random.choice(choices)
28 | logger.opt(lazy=True).debug(f"Choice is {query}")
29 | await self.bot.services.play(interaction=inter, query=query, top=True)
30 |
31 | @commands.slash_command(description="Launch a random radio")
32 | @commands.cooldown(1, 5.0, BucketType.user)
33 | @commands.check(checks.user_is_connected)
34 | async def radio(self, inter: CommandInteraction, radio: str):
35 | choices: list = self._radios.get(radio, [])
36 | if not choices:
37 | e: Embed = embed.error_message(content=f"No radio found with the name `{radio}`")
38 | await inter.send(embed=e)
39 | return
40 |
41 | await self._radio_process(inter, choices)
42 |
43 | @radio.autocomplete("radio")
44 | async def radio_autocomplete(self, inter: CommandInteraction, query: str):
45 | return [e for e in self._radios.keys() if query in e.lower()][:25]
46 |
47 |
48 | def setup(bot: JukeBot):
49 | bot.add_cog(Radio(bot))
50 |
--------------------------------------------------------------------------------
/jukebot/cogs/search.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import asyncio
4 | from typing import TYPE_CHECKING
5 |
6 | from disnake import CommandInteraction
7 | from disnake.ext import commands
8 | from disnake.ext.commands import BucketType
9 | from loguru import logger
10 |
11 | from jukebot import JukeBot, components
12 | from jukebot.components.requests import SearchRequest
13 | from jukebot.exceptions import QueryCanceled, QueryFailed
14 | from jukebot.utils import checks, embed
15 | from jukebot.views import SearchDropdownView, SearchInteraction
16 |
17 | if TYPE_CHECKING:
18 | from jukebot.components import ResultSet
19 |
20 |
21 | class Search(commands.Cog):
22 | def __init__(self, bot):
23 | self.bot: JukeBot = bot
24 |
25 | @commands.slash_command()
26 | @commands.max_concurrency(1, BucketType.user)
27 | @commands.cooldown(1, 5.0, BucketType.user)
28 | @commands.check(checks.user_is_connected)
29 | async def search(
30 | self,
31 | inter: CommandInteraction,
32 | query: str,
33 | source: SearchRequest.Engine = SearchRequest.Engine.Youtube.value,
34 | top: bool = False,
35 | ):
36 | """
37 | Allows you to search the first 10 results for the desired music.
38 |
39 | Parameters
40 | ----------
41 | inter: The interaction
42 | query: The query to search
43 | source: The website to use to search for the query
44 | top: Whether or not put the result in top of the queue
45 | """
46 | await inter.response.defer()
47 | logger.opt(lazy=True).debug(
48 | f"Search query '{query}' with source '{source}' for guild '{inter.guild.name} (ID: {inter.guild.id})'."
49 | )
50 |
51 | async with SearchRequest(query, source) as req:
52 | await req.execute()
53 |
54 | if not req.success:
55 | raise QueryFailed(
56 | f"Nothing found for {query}",
57 | query=query,
58 | full_query=f"{source}{query}",
59 | )
60 |
61 | results: ResultSet = components.ResultSet.from_result(req.result)
62 | logger.opt(lazy=True).debug(f"Results of the query is {results}")
63 |
64 | if not results:
65 | raise QueryFailed(f"Nothing found for {query}", query=query, full_query=f"{source}{query}")
66 |
67 | e = embed.search_result_message(playlist=results, title=f"Result for {query}")
68 |
69 | v = SearchDropdownView(inter.author, results)
70 | await inter.edit_original_message(embed=e, view=v)
71 | await v.wait()
72 |
73 | await inter.edit_original_message(embed=e, view=None)
74 |
75 | result: str = v.result
76 | if result == SearchInteraction.CANCEL_TEXT:
77 | raise QueryCanceled("Search Canceled", query=query, full_query=f"{source}{query}")
78 |
79 | logger.opt(lazy=True).debug(
80 | f"Query '{source}{query}' successful for guild '{inter.guild.name} (ID: {inter.guild.id})'."
81 | )
82 |
83 | # ? we call play cause search is barely a shortcut for play with a search before
84 | func = self.bot.get_slash_command("play").callback(self, inter, result, top)
85 | asyncio.ensure_future(func, loop=self.bot.loop)
86 |
87 |
88 | def setup(bot: JukeBot):
89 | bot.add_cog(Search(bot))
90 |
--------------------------------------------------------------------------------
/jukebot/cogs/system.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import io
4 | import os
5 | from datetime import datetime
6 | from typing import Optional
7 |
8 | from disnake import CommandInteraction, File
9 | from disnake.ext import commands
10 | from loguru import logger
11 |
12 | from jukebot import JukeBot
13 | from jukebot.utils import Extensions, converter, embed
14 |
15 | ADMIN_GUILD_IDS = (
16 | list(map(int, os.environ["BOT_ADMIN_GUILD_IDS"].split(","))) if "BOT_ADMIN_GUILD_IDS" in os.environ else []
17 | )
18 |
19 |
20 | class System(commands.Cog):
21 | def __init__(self, bot):
22 | self.bot: JukeBot = bot
23 |
24 | def _reload_all_cogs(self):
25 | logger.opt(lazy=True).info("Reloading all extensions.")
26 | for e in Extensions.all():
27 | self.bot.reload_extension(name=f"{e['package']}.{e['name']}")
28 | logger.opt(lazy=True).success(f"Extension {e['package']}.{e['name']} reloaded.")
29 | logger.opt(lazy=True).info("All extensions have been successfully reloaded.")
30 |
31 | def _reload_cog(self, name):
32 | logger.opt(lazy=True).info(f"Reloading '{name}' extension.")
33 | e = Extensions.get(name)
34 | if e is None:
35 | logger.error(f"Extension {name!r} could not be loaded.")
36 | raise commands.ExtensionNotFound(name)
37 | self.bot.reload_extension(name=f"{e['package']}.{e['name']}")
38 | logger.opt(lazy=True).success(f"Extension {e['package']}.{e['name']} reloaded.")
39 |
40 | @commands.slash_command(
41 | guild_ids=ADMIN_GUILD_IDS,
42 | )
43 | @commands.is_owner()
44 | async def reload(self, inter: CommandInteraction, cog_name: Optional[str] = None):
45 | """Reload the given cog. If no cog is given, reload all cogs.
46 |
47 | Parameters
48 | ----------
49 | inter : CommandInteraction
50 | The interaction
51 | cog_name : Optional[str], optional
52 | The cog name to reload, by default None
53 | """
54 | try:
55 | if not cog_name:
56 | self._reload_all_cogs()
57 | else:
58 | self._reload_cog(cog_name)
59 | except Exception as e:
60 | await inter.send(f"❌ | {e}", ephemeral=True)
61 | return
62 |
63 | await inter.send("✅ | reloaded", ephemeral=True)
64 |
65 | @commands.slash_command(guild_ids=ADMIN_GUILD_IDS)
66 | @commands.is_owner()
67 | async def refresh(self, inter: CommandInteraction):
68 | """Updates all cached properties of the bot
69 |
70 | Parameters
71 | ----------
72 | inter : CommandInteraction
73 | The interaction
74 | """
75 | try:
76 | del self.bot.members_count
77 | logger.opt(lazy=True).success("Members count reset.")
78 | del self.bot.guilds_count
79 | logger.opt(lazy=True).success("Guilds count reset.")
80 | except Exception as e:
81 | await inter.send(f"❌ | {e}", ephemeral=True)
82 | return
83 |
84 | await inter.send("✅ | refresh", ephemeral=True)
85 |
86 | @commands.slash_command(guild_ids=ADMIN_GUILD_IDS)
87 | @commands.is_owner()
88 | async def stats(self, inter: CommandInteraction):
89 | """Displays bot statistics such as number of servers, users or players created
90 |
91 | Parameters
92 | ----------
93 | inter : CommandInteraction
94 | The interaction
95 | """
96 | e = embed.info_message(title=f"Stats about {self.bot.user.name}")
97 | e.add_field(name="📡 Ping", value=f"┕`{self.bot.latency * 1000:.2f}ms`")
98 | uptime = datetime.now() - self.bot.start_time
99 | days, hours, minutes, seconds = converter.seconds_to_time(int(uptime.total_seconds()))
100 | e.add_field(
101 | name="⏱ Uptime",
102 | value=f"┕`{days}d, {hours}h, {minutes}m, {seconds}s`",
103 | )
104 | e.add_field(name=embed.VOID_TOKEN, value=embed.VOID_TOKEN)
105 | e.add_field(name="🏛️ Servers", value=f"┕`{len(self.bot.guilds)}`", inline=True)
106 | e.add_field(
107 | name="👥 Members",
108 | value=f"┕`{len(set(self.bot.get_all_members()))}`",
109 | )
110 | e.add_field(name=embed.VOID_TOKEN, value=embed.VOID_TOKEN)
111 | e.add_field(
112 | name="📻 Players created",
113 | value=f"┕`{len(self.bot.players)}`",
114 | )
115 | e.add_field(
116 | name="🎶 Players playing",
117 | value=f"┕`{len(self.bot.players.playing())}`",
118 | )
119 | e.add_field(name=embed.VOID_TOKEN, value=embed.VOID_TOKEN)
120 |
121 | await inter.send(embed=e, ephemeral=True)
122 |
123 | @commands.slash_command(guild_ids=ADMIN_GUILD_IDS)
124 | @commands.is_owner()
125 | async def commands(self, inter: CommandInteraction):
126 | """Generates a .yaml file containing all bot commands
127 |
128 | Parameters
129 | ----------
130 | inter : CommandInteraction
131 | The interaction
132 | """
133 | cmds = sorted(self.bot.slash_commands, key=lambda x: x.name)
134 | msg: str = "---\n"
135 | for c in cmds:
136 | opts: str = ""
137 | if c.body.options:
138 | opts = " ".join([e.name for e in c.body.options])
139 | msg += f"""
140 | - title: {c.name}
141 | icon: none
142 | usage: /{c.name} {opts}
143 | desc: {c.body.description}
144 | """
145 | data = io.BytesIO(msg.encode())
146 | await inter.send(file=File(data, "cmds.yaml"), ephemeral=True)
147 |
148 |
149 | def setup(bot: JukeBot):
150 | bot.add_cog(System(bot))
151 |
--------------------------------------------------------------------------------
/jukebot/cogs/utility.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from datetime import datetime
4 |
5 | from disnake import CommandInteraction, InviteTarget
6 | from disnake.ext import commands
7 | from disnake.ext.commands import BucketType
8 |
9 | from jukebot import JukeBot
10 | from jukebot.services import ResetService
11 | from jukebot.utils import applications, checks, converter, embed
12 | from jukebot.views import ActivityView, PromoteView
13 |
14 |
15 | class Utility(commands.Cog):
16 | def __init__(self, bot):
17 | self.bot: JukeBot = bot
18 |
19 | @commands.slash_command(
20 | description="Get information about the bot like the ping and the uptime.",
21 | )
22 | @commands.cooldown(1, 10.0, BucketType.user)
23 | async def info(self, inter: CommandInteraction):
24 | """Displays information about the bot like its ping, uptime, prefix and more
25 |
26 | Parameters
27 | ----------
28 | inter : CommandInteraction
29 | The interaction
30 | """
31 | e = embed.info_message()
32 | e.add_field(name="🤖 Name", value=f"┕`{self.bot.user.display_name}`", inline=True)
33 | e.add_field(name="📡 Ping", value=f"┕`{self.bot.latency * 1000:.2f}ms`", inline=True)
34 | uptime = datetime.now() - self.bot.start_time
35 | days, hours, minutes, seconds = converter.seconds_to_time(int(uptime.total_seconds()))
36 | e.add_field(
37 | name="⏱ Uptime",
38 | value=f"┕`{days}d, {hours}h, {minutes}m, {seconds}s`",
39 | inline=True,
40 | )
41 | e.add_field(name="🏛️ Servers", value=f"┕`{self.bot.guilds_count}`", inline=True)
42 | e.add_field(
43 | name="👥 Members",
44 | value=f"┕`{self.bot.members_count}`",
45 | inline=True,
46 | )
47 | e.add_field(
48 | name="🪄 Prefix",
49 | value=f"┕`/`",
50 | inline=True,
51 | )
52 | e.set_image(url="https://cdn.discordapp.com/attachments/829356508696412231/948936347752747038/juke-banner.png")
53 | await inter.send(embed=e, ephemeral=True)
54 |
55 | @commands.slash_command()
56 | @commands.cooldown(1, 8.0, BucketType.user)
57 | async def links(self, inter: CommandInteraction):
58 | """Send all links to support the bot
59 |
60 | Parameters
61 | ----------
62 | inter : CommandInteraction
63 | The interaction
64 | """
65 | await inter.send(view=PromoteView())
66 |
67 | @commands.slash_command()
68 | @commands.cooldown(1, 15.0, BucketType.guild)
69 | @commands.max_concurrency(1, BucketType.guild)
70 | @commands.check(checks.user_is_connected)
71 | async def watch(self, inter: CommandInteraction):
72 | """Launch a Youtube Together session in the voice channel where you are currently.
73 |
74 | Parameters
75 | ----------
76 | inter : CommandInteraction
77 | The interaction
78 | """
79 | max_time = 180
80 | invite = await inter.author.voice.channel.create_invite(
81 | max_age=max_time,
82 | reason="Watch Together",
83 | target_type=InviteTarget.embedded_application,
84 | target_application=applications.default["youtube"],
85 | )
86 | e = embed.activity_message(
87 | "Watch Together started!",
88 | f"An activity started in `{inter.author.voice.channel.name}`.\n",
89 | )
90 |
91 | await inter.send(embed=e, view=ActivityView(invite.url), delete_after=float(max_time))
92 |
93 | @commands.slash_command()
94 | @commands.cooldown(1, 15.0, BucketType.guild)
95 | async def reset(self, inter: CommandInteraction):
96 | """Disconnects the bot and resets its internal state if something isn't working.
97 |
98 | Parameters
99 | ----------
100 | inter : CommandInteraction
101 | The interaction
102 | """
103 | await self.bot.services.reset(guild=inter.guild)
104 |
105 | e = embed.info_message(content="The player has been reset.")
106 | await inter.send(embed=e)
107 |
108 |
109 | def setup(bot: JukeBot):
110 | bot.add_cog(Utility(bot))
111 |
112 | bot.add_service(ResetService(bot))
113 |
--------------------------------------------------------------------------------
/jukebot/components/__init__.py:
--------------------------------------------------------------------------------
1 | from .audio_stream import AudioStream
2 | from .player import Player
3 | from .playerset import PlayerSet
4 | from .result import Result
5 | from .resultset import ResultSet
6 | from .song import Song
7 |
--------------------------------------------------------------------------------
/jukebot/components/audio_stream.py:
--------------------------------------------------------------------------------
1 | from disnake import FFmpegOpusAudio
2 |
3 |
4 | class AudioStream(FFmpegOpusAudio):
5 | def __init__(self, source: str):
6 | super(AudioStream, self).__init__(
7 | source,
8 | before_options=_PlayerOption.FFMPEG_BEFORE_OPTIONS, # "-nostdin",
9 | options=_PlayerOption.FFMPEG_OPTIONS,
10 | )
11 | self._progress: int = 0
12 |
13 | def read(self) -> bytes:
14 | data = super().read()
15 | if data:
16 | self._progress += 1
17 | return data
18 |
19 | @property
20 | def progress(self) -> int:
21 | # 20ms
22 | return int(self._progress * 0.02)
23 |
24 |
25 | class _PlayerOption:
26 | FFMPEG_BEFORE_OPTIONS = " ".join(
27 | [
28 | "-vn",
29 | "-reconnect 1",
30 | "-reconnect_streamed 1",
31 | "-reconnect_delay_max 3",
32 | "-nostdin",
33 | ]
34 | )
35 |
36 | FFMPEG_OPTIONS = ""
37 |
--------------------------------------------------------------------------------
/jukebot/components/player.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import asyncio
4 | import os
5 | from asyncio import Task
6 | from enum import IntEnum, auto
7 | from typing import Optional
8 |
9 | from disnake import CommandInteraction, VoiceChannel, VoiceClient
10 | from disnake.ext.commands import Bot
11 | from loguru import logger
12 |
13 | from jukebot.components.audio_stream import AudioStream
14 | from jukebot.components.requests import StreamRequest
15 | from jukebot.components.resultset import ResultSet
16 | from jukebot.components.song import Song
17 | from jukebot.utils import coro
18 |
19 |
20 | class Player:
21 | class State(IntEnum):
22 | IDLE = auto()
23 | PLAYING = auto()
24 | PAUSED = auto()
25 | STOPPED = auto()
26 | SKIPPING = auto()
27 | DISCONNECTING = auto()
28 |
29 | @property
30 | def is_playing(self) -> bool:
31 | return self == Player.State.PLAYING
32 |
33 | @property
34 | def is_paused(self) -> bool:
35 | return self == Player.State.PAUSED
36 |
37 | @property
38 | def is_stopped(self) -> bool:
39 | return self == Player.State.STOPPED
40 |
41 | @property
42 | def is_skipping(self) -> bool:
43 | return self == Player.State.SKIPPING
44 |
45 | @property
46 | def is_inactive(self) -> bool:
47 | return self in [
48 | Player.State.IDLE,
49 | Player.State.PAUSED,
50 | Player.State.STOPPED,
51 | ]
52 |
53 | @property
54 | def is_leaving(self) -> bool:
55 | return self in [Player.State.STOPPED, Player.State.DISCONNECTING]
56 |
57 | class Loop(IntEnum):
58 | DISABLED = 0
59 | SONG = 1
60 | QUEUE = 2
61 |
62 | @property
63 | def is_song_loop(self) -> bool:
64 | return self == Player.Loop.SONG
65 |
66 | @property
67 | def is_queue_loop(self) -> bool:
68 | return self == Player.Loop.QUEUE
69 |
70 | def __init__(self, bot: Bot, guild_id: int):
71 | self.bot: Bot = bot
72 | self._guild_id: int = guild_id
73 |
74 | self._voice: Optional[VoiceClient] = None
75 | self._stream: Optional[AudioStream] = None
76 | self._inter: Optional[CommandInteraction] = None
77 | self._song: Optional[Song] = None
78 | self._queue: ResultSet = ResultSet.empty()
79 | self._state: Player.State = Player.State.IDLE
80 | self._idle_task: Optional[Task] = None
81 | self._loop: Player.Loop = Player.Loop.DISABLED
82 |
83 | async def join(self, channel: VoiceChannel):
84 | self._voice = await channel.connect(timeout=2.0)
85 | self.state = Player.State.IDLE
86 |
87 | async def play(self, song: Song, replay: bool = False):
88 | if replay:
89 | # ? we must requery the song to refresh the stream URL
90 | # ? some website can invalidate the stream URL after some times
91 | author = song.requester
92 | async with StreamRequest(song.web_url) as req:
93 | await req.execute()
94 |
95 | song: Song = Song(req.result)
96 | song.requester = author
97 |
98 | if self._voice and self._voice.is_playing():
99 | self._voice.stop()
100 |
101 | stream = AudioStream(song.stream_url)
102 |
103 | self._voice.play(stream, after=self._after)
104 | self._stream = stream
105 | self._song = song
106 | self.state = Player.State.PLAYING
107 |
108 | async def disconnect(self, force=False):
109 | if self._voice:
110 | self.state = Player.State.DISCONNECTING
111 | await self._voice.disconnect(force=force)
112 |
113 | def skip(self):
114 | if self._voice:
115 | self.state = Player.State.SKIPPING
116 | self._voice.stop()
117 |
118 | def stop(self):
119 | if self._voice:
120 | self.state = Player.State.STOPPED
121 | self._voice.stop()
122 |
123 | def pause(self):
124 | if self._voice:
125 | self.state = Player.State.PAUSED
126 | self._voice.pause()
127 |
128 | def resume(self):
129 | if self._voice:
130 | self.state = Player.State.PLAYING
131 | self._voice.resume()
132 |
133 | def _after(self, error):
134 | if error:
135 | logger.opt(lazy=True).error(error)
136 | self.state = Player.State.IDLE
137 | return
138 |
139 | if self.state.is_leaving:
140 | return
141 |
142 | if self._loop.is_song_loop and not self.state.is_skipping:
143 | func = self.play(self.song, replay=True)
144 | coro.run_threadsafe(func, loop=self.bot.loop)
145 | return
146 |
147 | if self._loop.is_queue_loop:
148 | func = self.bot.add_service(guild_id=self._guild_id, author=self.song.requester, query=self.song.web_url)
149 | asyncio.ensure_future(func, loop=self.bot.loop)
150 |
151 | self._stream = None
152 | self._song = None
153 |
154 | if not self._queue.is_empty():
155 | func = self.bot.get_slash_command("play").callback(self, self.interaction, query="")
156 | coro.run_threadsafe(func, self.bot.loop)
157 | return
158 |
159 | self.state = Player.State.IDLE
160 |
161 | def _idle_callback(self) -> None:
162 | if not self._idle_task.cancelled():
163 | func = self.bot.get_slash_command("leave").callback(self, self.interaction)
164 | asyncio.ensure_future(func, loop=self.bot.loop)
165 |
166 | def _set_idle_task(self) -> None:
167 | if self.state.is_inactive and not self._idle_task:
168 | self._idle_task = self.bot.loop.call_later(
169 | delay=float(os.environ["BOT_MAX_IDLE_TIME"]),
170 | callback=self._idle_callback,
171 | )
172 | elif not self.state.is_inactive and self._idle_task:
173 | self._idle_task.cancel()
174 | self._idle_task = None
175 |
176 | @property
177 | def is_streaming(self) -> bool:
178 | return self.is_connected and bool(self.stream)
179 |
180 | @property
181 | def is_playing(self) -> bool:
182 | return self.is_streaming and self.state.is_playing
183 |
184 | @property
185 | def is_paused(self) -> bool:
186 | return self.is_streaming and self.state.is_paused
187 |
188 | @property
189 | def is_connected(self) -> bool:
190 | return bool(self._voice)
191 |
192 | @property
193 | def stream(self) -> Optional[AudioStream]:
194 | return self._stream
195 |
196 | @property
197 | def voice(self) -> Optional[VoiceClient]:
198 | return self._voice
199 |
200 | @property
201 | def song(self) -> Optional[Song]:
202 | return self._song
203 |
204 | @property
205 | def queue(self) -> ResultSet:
206 | return self._queue
207 |
208 | @queue.setter
209 | def queue(self, q) -> None:
210 | self._queue = q
211 |
212 | @property
213 | def interaction(self) -> Optional[CommandInteraction]:
214 | return self._inter
215 |
216 | @interaction.setter
217 | def interaction(self, c: CommandInteraction) -> None:
218 | self._inter = c
219 |
220 | @property
221 | def state(self) -> State:
222 | return self._state
223 |
224 | @state.setter
225 | def state(self, new: State) -> None:
226 | self._state = new
227 | self._set_idle_task()
228 |
229 | @property
230 | def loop(self) -> Player.Loop:
231 | return self._loop
232 |
233 | @loop.setter
234 | def loop(self, lp: Player.Loop) -> None:
235 | self._loop = lp
236 |
--------------------------------------------------------------------------------
/jukebot/components/playerset.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 | from jukebot.abstract_components import AbstractMap
6 | from jukebot.components.player import Player
7 |
8 | if TYPE_CHECKING:
9 | from typing import List
10 |
11 |
12 | class PlayerSet(AbstractMap[int, Player]):
13 | _instance = None
14 |
15 | def __new__(cls, bot):
16 | if cls._instance is None:
17 | cls._instance = super(PlayerSet, cls).__new__(cls)
18 | cls.bot = bot
19 | return cls._instance
20 |
21 | def __getitem__(self, key):
22 | if not key in self._collection:
23 | self._collection[key] = Player(self.bot, guild_id=key)
24 | return self._collection[key]
25 |
26 | def playing(self) -> List[Player]:
27 | return [p for p in self._collection.values() if p.is_playing]
28 |
--------------------------------------------------------------------------------
/jukebot/components/requests/__init__.py:
--------------------------------------------------------------------------------
1 | from .music_request import MusicRequest
2 | from .search_request import SearchRequest
3 | from .shazam_request import ShazamRequest
4 | from .stream_request import StreamRequest
5 |
--------------------------------------------------------------------------------
/jukebot/components/requests/music_request.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import asyncio
4 | from enum import Enum, auto
5 |
6 | import yt_dlp
7 | from loguru import logger
8 |
9 | from jukebot.abstract_components import AbstractRequest
10 | from jukebot.utils import regex
11 |
12 |
13 | class MusicRequest(AbstractRequest):
14 | """Class that represent a music request.
15 | Music request are done during the queue add command and only here.
16 | Music request can retrive a single tracks, a playlist or a set.
17 | Music request query can be made of an url.
18 | """
19 |
20 | class ResultType(Enum):
21 | """Define the type of the result
22 |
23 | Parameters
24 | ----------
25 | Enum : Enum
26 | The type of the result.
27 | Can be a single track, a playlist or an unknown format
28 | """
29 |
30 | TRACK = auto()
31 | PLAYLIST = auto()
32 | UNKNOWN = auto()
33 |
34 | YTDL_OPTIONS: dict = {
35 | "format": "bestaudio/best",
36 | "outtmpl": "%(extractor)s-%(id)s-%(title)s.%(ext)s",
37 | "restrictfilenames": True,
38 | "nocheckcertificate": True,
39 | "ignoreerrors": True,
40 | "logtostderr": False,
41 | "quiet": True,
42 | "no_warnings": True,
43 | "default_search": "auto",
44 | "source_address": "0.0.0.0",
45 | "usenetrc": True,
46 | "socket_timeout": 3,
47 | "noplaylist": True,
48 | "cachedir": False,
49 | # "logger": _QueryLogger(),
50 | }
51 |
52 | def __init__(self, query: str) -> None:
53 | super().__init__(query)
54 | self._params: dict = {**MusicRequest.YTDL_OPTIONS}
55 | self._type: MusicRequest.ResultType = MusicRequest.ResultType.UNKNOWN
56 | self._single_search: bool = False
57 |
58 | async def setup(self):
59 | if not regex.is_url(self._query):
60 | self._query = f"ytsearch1:{self._query}"
61 | self._single_search = True
62 |
63 | async def execute(self):
64 | with yt_dlp.YoutubeDL(params=self._params) as ytdl:
65 | loop = asyncio.get_event_loop()
66 | try:
67 | data = await loop.run_in_executor(
68 | None,
69 | lambda: ytdl.extract_info(url=self._query, download=False, process=False),
70 | )
71 | except Exception as e:
72 | logger.error(f"Exception in query {self._query}. {e}")
73 | return
74 |
75 | self._result = data
76 |
77 | async def terminate(self):
78 | if not self._result:
79 | logger.opt(lazy=True).debug(f"No info retrieved for query {self._query}")
80 | return
81 |
82 | if not "entries" in self._result:
83 | # ? MusicRequest returned an only track
84 | self._type = MusicRequest.ResultType.TRACK
85 | self._success = True
86 | return
87 |
88 | # ? assume that MusicRequest return a playlist
89 | self._result = list(self._result.pop("entries"))
90 | # ? check if result isn't an empty iterator
91 | if not len(self._result):
92 | self._result = None
93 | logger.opt(lazy=True).debug(f"No info retrieved for query {self._query}")
94 | return
95 |
96 | if self._single_search:
97 | self._result = self._result[0]
98 | self._type = MusicRequest.ResultType.TRACK
99 | else:
100 | self._type = MusicRequest.ResultType.PLAYLIST
101 |
102 | self._success = True
103 |
104 | @property
105 | def type(self) -> MusicRequest.ResultType:
106 | return self._type
107 |
108 | def _clean_data(self) -> None:
109 | """Remove useless data from the result"""
110 | useless_keys: list = ["formats", "thumbnails"]
111 |
112 | def inner(results) -> None:
113 | for e in useless_keys:
114 | if e in results:
115 | results.pop(e)
116 |
117 | if isinstance(self._result, list):
118 | for r in self._result:
119 | inner(r)
120 | else:
121 | inner(self._result)
122 |
--------------------------------------------------------------------------------
/jukebot/components/requests/search_request.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import asyncio
4 | from enum import Enum
5 |
6 | import yt_dlp
7 | from loguru import logger
8 |
9 | from jukebot.abstract_components import AbstractRequest
10 | from jukebot.utils import regex
11 |
12 |
13 | class SearchRequest(AbstractRequest):
14 | """Class that represent a search request.
15 | Search request are done during the search command and only here.
16 | Search request retrieve the 10 first track from a query on youtube or soundcloud website.
17 | Search request can retrive only 10 tracks, not playlist or sets.
18 | Search request can't be created with url, only a query of words.
19 | """
20 |
21 | class Engine(str, Enum):
22 | Youtube = "ytsearch10:"
23 | SoundCloud = "scsearch10:"
24 |
25 | @classmethod
26 | def value_of(cls, value) -> SearchRequest.Engine:
27 | for k, v in cls.__members__.items():
28 | if value in [k, v]:
29 | return v
30 | raise ValueError(f"'{cls.__name__}' enum not found for '{value}'")
31 |
32 | YTDL_OPTIONS: dict = {
33 | "format": "bestaudio/best",
34 | "outtmpl": "%(extractor)s-%(id)s-%(title)s.%(ext)s",
35 | "restrictfilenames": True,
36 | "nocheckcertificate": True,
37 | "ignoreerrors": True,
38 | "logtostderr": False,
39 | "quiet": True,
40 | "no_warnings": True,
41 | "default_search": "auto",
42 | "source_address": "0.0.0.0",
43 | "usenetrc": True,
44 | "socket_timeout": 3,
45 | "noplaylist": True,
46 | "cachedir": False,
47 | # "logger": _QueryLogger(),
48 | }
49 |
50 | def __init__(self, query: str, engine: str) -> None:
51 | if regex.is_url(query):
52 | raise ValueError(f"query must be words, not direct url")
53 |
54 | self._engine: SearchRequest.Engine = SearchRequest.Engine.value_of(engine)
55 | self._params: dict = {**SearchRequest.YTDL_OPTIONS}
56 |
57 | super().__init__(f"{self._engine.value}{query}")
58 |
59 | async def setup(self):
60 | # * nothing to do
61 | pass
62 |
63 | async def execute(self):
64 | with yt_dlp.YoutubeDL(params=self._params) as ytdl:
65 | loop = asyncio.get_event_loop()
66 | try:
67 | data = await loop.run_in_executor(
68 | None,
69 | lambda: ytdl.extract_info(url=self._query, download=False, process=False),
70 | )
71 | except Exception as e:
72 | logger.error(f"Exception in query {self._query}. {e}")
73 | return
74 |
75 | self._result = data
76 |
77 | async def terminate(self):
78 | if not self._result:
79 | logger.opt(lazy=True).debug(f"No info retrieved for query {self._query}")
80 | return
81 |
82 | if not "entries" in self._result:
83 | # ? SearchRequest have to retrieved a playlist,
84 | # ? this shouldn't happen so we delete the result
85 | logger.warning(f"Query {self._query} don't retrieve a playlist. Deleleting the result.")
86 | self._result = None
87 | return
88 |
89 | self._result = list(self._result.pop("entries"))
90 | if not len(self._result):
91 | logger.warning(f"Query {self._query} don't retrieve any results. Deleleting the result.")
92 | self._result = None
93 | return
94 |
95 | self._success = True
96 |
--------------------------------------------------------------------------------
/jukebot/components/requests/shazam_request.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import os
3 | import random
4 | import string
5 | import tempfile
6 | from pathlib import Path
7 |
8 | import yt_dlp
9 | from loguru import logger
10 | from shazamio import Serialize, Shazam
11 |
12 | # Silence useless bug reports messages
13 | yt_dlp.utils.bug_reports_message = lambda: ""
14 |
15 | from jukebot.abstract_components import AbstractRequest
16 |
17 |
18 | class _QueryLogger:
19 | def debug(self, msg) -> None:
20 | if msg.startswith("[debug] "):
21 | logger.opt(lazy=True).debug(msg)
22 | else:
23 | self.info(msg)
24 |
25 | def info(self, msg) -> None:
26 | if len(msg) > 1:
27 | logger.opt(lazy=True).info(msg)
28 |
29 | def warning(self, msg) -> None:
30 | logger.warning(msg)
31 |
32 | def error(self, msg) -> None:
33 | logger.error(msg)
34 |
35 |
36 | class ShazamRequest(AbstractRequest):
37 | """Class that represent a Shazam request.
38 | Shazam request are done during the find command and only here.
39 | Shazam request retrieve all the data to recognize a media given via an URL.
40 | Shazam request can retrive only one media, not playlist or sets
41 | """
42 |
43 | FILENAME_CHARS: str = string.ascii_letters + string.digits
44 | FILENAME_LENGHT: int = 8
45 | YTDL_BASE_OPTIONS: dict = {
46 | "format": "bestaudio/best",
47 | "external_downloader": "ffmpeg",
48 | "external_downloader_args": {
49 | "ffmpeg_i": ["-vn", "-ss", "00:00", "-to", "00:30"],
50 | "ffmpeg": ["-t", "20"],
51 | },
52 | "nopart": True,
53 | "cachedir": False,
54 | "logger": _QueryLogger(),
55 | }
56 |
57 | def __init__(self, query: str):
58 | super().__init__(query=query)
59 | self._path: str = ""
60 | self._params: dict = {**ShazamRequest.YTDL_BASE_OPTIONS}
61 | self._delete_path: bool = False
62 |
63 | async def setup(self):
64 | rdm_str: str = "".join(
65 | [random.choice(ShazamRequest.FILENAME_CHARS) for _ in range(ShazamRequest.FILENAME_LENGHT)]
66 | )
67 | self._path = Path(tempfile.gettempdir(), "jukebot", rdm_str)
68 | self._params.update({"outtmpl": f"{self._path}"})
69 |
70 | logger.opt(lazy=True).debug(f"Generated path {self._path} with parameters {self._params}")
71 |
72 | async def execute(self):
73 | with yt_dlp.YoutubeDL(params=self._params) as ytdl:
74 | loop = asyncio.get_event_loop()
75 | try:
76 | await loop.run_in_executor(
77 | None,
78 | lambda: ytdl.download(self._query),
79 | )
80 | except Exception as e:
81 | logger.error(e)
82 | return
83 |
84 | self._delete_path = True
85 | logger.opt(lazy=True).debug(f"Query {self._query} saved at {self._path}")
86 | shazam = Shazam()
87 | out = await shazam.recognize_song(data=self._path)
88 | result = Serialize.full_track(data=out)
89 | logger.debug(result.track)
90 | if not result.track:
91 | return
92 |
93 | youtube_data = await shazam.get_youtube_data(link=result.track.youtube_link)
94 | data: dict = {
95 | "title": youtube_data["caption"],
96 | "url": youtube_data["actions"][0]["uri"],
97 | "image_url": youtube_data["image"]["url"],
98 | }
99 |
100 | self._result = data
101 | self._success = True
102 |
103 | logger.opt(lazy=True).debug(f"Query data {self._result}")
104 |
105 | async def terminate(self):
106 | if self._delete_path:
107 | loop = asyncio.get_event_loop()
108 | try:
109 | await loop.run_in_executor(None, lambda: os.remove(self._path))
110 | logger.opt(lazy=True).info(f"Query {self._query} deleted at {self._path}")
111 | except Exception as e:
112 | logger.error(e)
113 |
--------------------------------------------------------------------------------
/jukebot/components/requests/stream_request.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | import yt_dlp
4 | from loguru import logger
5 |
6 | from jukebot.abstract_components import AbstractRequest
7 |
8 |
9 | class StreamRequest(AbstractRequest):
10 | """Class that represent a stream request.
11 | Stream request are done during the play command and only here.
12 | Stream request retrieve all the data used by the bot to stream the audio in a given voice channel.
13 | Stream request can retrive only one track, not playlist or sets.
14 | """
15 |
16 | YTDL_OPTIONS: dict = {
17 | "format": "bestaudio/best",
18 | "outtmpl": "%(extractor)s-%(id)s-%(title)s.%(ext)s",
19 | "restrictfilenames": True,
20 | "nocheckcertificate": True,
21 | "ignoreerrors": True,
22 | "logtostderr": False,
23 | "quiet": True,
24 | "no_warnings": True,
25 | "default_search": "auto",
26 | "source_address": "0.0.0.0",
27 | "usenetrc": True,
28 | "socket_timeout": 3,
29 | "noplaylist": True,
30 | "cachedir": False,
31 | # "logger": _QueryLogger(),
32 | }
33 |
34 | def __init__(self, query: str) -> None:
35 | super().__init__(query)
36 | self._params: dict = {**StreamRequest.YTDL_OPTIONS}
37 |
38 | async def setup(self):
39 | # * nothing to do
40 | pass
41 |
42 | async def execute(self):
43 | with yt_dlp.YoutubeDL(params=self._params) as ytdl:
44 | loop = asyncio.get_event_loop()
45 | try:
46 | data = await loop.run_in_executor(
47 | None,
48 | lambda: ytdl.extract_info(url=self._query, download=False),
49 | )
50 | except Exception as e:
51 | logger.error(f"Exception in query {self._query}. {e}")
52 | return
53 |
54 | self._result = data
55 |
56 | async def terminate(self):
57 | if not self._result:
58 | logger.opt(lazy=True).debug(f"No info retrieved for query {self._query}")
59 | return
60 |
61 | if "entries" in self._result:
62 | # ? StreamRequest have retrieved a playlist,
63 | # ? this shouldn't happen so we delete the result
64 | logger.warning(f"Query {self._query} retrieve a playlist. Deleleting the result.")
65 | self._result = None
66 | return
67 |
68 | self._success = True
69 |
--------------------------------------------------------------------------------
/jukebot/components/result.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from dataclasses import dataclass
4 | from typing import Optional
5 |
6 | from disnake import Member
7 |
8 | from jukebot.utils import converter
9 |
10 |
11 | @dataclass
12 | class Result:
13 | web_url: str
14 | title: str
15 | channel: str
16 | duration: int = 0
17 | fmt_duration: str = "0:00"
18 | live: bool = False
19 | requester: Optional[Member] = None
20 |
21 | def __init__(self, info: dict):
22 | self.web_url = info.get("url") or info.get("original_url")
23 | self.title = info.get("title", "Unknown")
24 | self.channel = info.get("channel") or info.get("uploader") or "Unknown"
25 | self.duration = round(info.get("duration") or 0)
26 | self.live = info.get("duration") is None
27 | self.fmt_duration = "ထ" if self.live else converter.seconds_to_youtube_format(self.duration)
28 |
29 | if self.title == "Unknown" or self.channel == "Unknwon":
30 | self._define_complementary_info_from_url()
31 |
32 | def _define_complementary_info_from_url(self):
33 | """This method try to define title and channel from url"""
34 | if "soundcloud" in self.web_url:
35 | # ? SoundCloud API return only the url
36 | # ? we try to define title and channel from it
37 | if "?" in self.web_url:
38 | # ? Remove metadata from url
39 | self.web_url = self.web_url.split("?")[0]
40 |
41 | # ? should give [https, "" (because of double slash), soundlouc, channel, title, secret (if exist)]
42 | data = self.web_url.split("/")
43 | data = data[3:] # ? we remove the 3 first element (https, "", soundcloud.com)
44 | tmp_channel: str = data[0] # ? we keep channel
45 | tmp_title: str = data[1] # ? and title
46 | if self.channel == "Unknown":
47 | self.channel = tmp_channel.replace("-", " ").title()
48 | if self.title == "Unknown":
49 | self.title = tmp_title.replace("-", " ").title()
50 |
--------------------------------------------------------------------------------
/jukebot/components/resultset.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import random
4 | from dataclasses import dataclass
5 | from typing import List, Optional, Union
6 |
7 | from disnake import Member
8 |
9 | from jukebot.abstract_components import AbstractCollection
10 | from jukebot.components.result import Result
11 |
12 |
13 | @dataclass
14 | class ResultSet(AbstractCollection[Result]):
15 | @classmethod
16 | def from_result(cls, results: list, requester: Optional[Member] = None) -> ResultSet:
17 | result_set: List[Result] = []
18 | for r in results:
19 | tmp: Result = Result(r)
20 | tmp.requester = requester
21 | result_set.append(tmp)
22 |
23 | return cls(set=result_set)
24 |
25 | @classmethod
26 | def empty(cls):
27 | return cls(set=[])
28 |
29 | def get(self) -> Result:
30 | return self.set.pop(0)
31 |
32 | def put(self, result: Union[Result, ResultSet]) -> None:
33 | if isinstance(result, ResultSet):
34 | self.set += result
35 | else:
36 | self.set.append(result)
37 |
38 | def add(self, result: Union[Result, ResultSet]) -> None:
39 | if isinstance(result, ResultSet):
40 | self.set[0:0] = result[::-1]
41 | else:
42 | self.set.insert(0, result)
43 |
44 | def remove(self, elem: str) -> Optional[Result]:
45 | elem = elem.lower()
46 | for i, e in enumerate(self.set):
47 | if e.title.lower() == elem:
48 | return self.set.pop(i)
49 | return None
50 |
51 | def is_empty(self) -> bool:
52 | return len(self.set) == 0
53 |
54 | def shuffle(self) -> None:
55 | random.shuffle(self.set)
56 |
--------------------------------------------------------------------------------
/jukebot/components/song.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from dataclasses import dataclass
4 | from typing import Optional
5 |
6 | from disnake import Member
7 |
8 | from jukebot.utils import converter
9 |
10 |
11 | @dataclass
12 | class Song:
13 | title: str = "Unknown"
14 | stream_url: Optional[str] = None
15 | web_url: str = ""
16 | thumbnail: Optional[str] = None
17 | channel: str = "Unknown"
18 | duration: int = 0
19 | fmt_duration: str = "0:00"
20 | live: bool = False
21 | requester: Optional[Member] = None
22 |
23 | def __init__(self, info: dict):
24 | self.stream_url = info["url"]
25 | self.title = info.get("title", "Unknown")
26 | self.live = info.get("is_live", False)
27 | self.duration = round(info.get("duration") or 0)
28 | self.fmt_duration = "ထ" if self.live else converter.seconds_to_youtube_format(self.duration)
29 | self.thumbnail = info.get("thumbnail", None)
30 | self.channel = info.get("channel") or info.get("uploader") or "Unknown"
31 | self.web_url = info.get("webpage_url", "")
32 |
--------------------------------------------------------------------------------
/jukebot/exceptions/__init__.py:
--------------------------------------------------------------------------------
1 | from .player_exception import (
2 | PlayerConnexionException,
3 | PlayerDontExistException,
4 | PlayerException,
5 | )
6 | from .query_exceptions import QueryCanceled, QueryException, QueryFailed
7 |
--------------------------------------------------------------------------------
/jukebot/exceptions/player_exception.py:
--------------------------------------------------------------------------------
1 | from disnake.ext.commands import CommandError
2 |
3 |
4 | class PlayerException(CommandError):
5 | def __init__(self, message: str) -> None:
6 | super().__init__(message)
7 |
8 |
9 | class PlayerConnexionException(PlayerException):
10 | pass
11 |
12 |
13 | class PlayerDontExistException(PlayerException):
14 | pass
15 |
--------------------------------------------------------------------------------
/jukebot/exceptions/query_exceptions.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Optional
4 |
5 | from disnake.ext.commands import CommandError
6 |
7 |
8 | class QueryException(CommandError):
9 | def __init__(self, message: str, *, query: Optional[str] = None, full_query: Optional[str] = None) -> None:
10 | super().__init__(message)
11 | self.query: Optional[str] = query
12 | self.full_query: Optional[str] = full_query
13 |
14 |
15 | class QueryFailed(QueryException):
16 | pass
17 |
18 |
19 | class QueryCanceled(QueryException):
20 | pass
21 |
--------------------------------------------------------------------------------
/jukebot/jukebot.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import traceback
4 | from datetime import datetime
5 | from functools import cached_property
6 |
7 | from disnake.ext import commands
8 | from loguru import logger
9 |
10 | from jukebot.abstract_components.abstract_map import AbstractMap
11 | from jukebot.abstract_components.abstract_service import AbstractService
12 | from jukebot.components import PlayerSet
13 | from jukebot.utils import regex
14 |
15 |
16 | class ServiceMap(AbstractMap[str, AbstractService]):
17 | pass
18 |
19 |
20 | class JukeBot(commands.InteractionBot):
21 | def __init__(self, *args, **kwargs):
22 | super().__init__(*args, **kwargs)
23 | self._start = datetime.now()
24 | self._players: PlayerSet = PlayerSet(self)
25 | self._services: ServiceMap = ServiceMap()
26 |
27 | async def on_ready(self):
28 | logger.info(f"Logged in as {self.user} (ID: {self.user.id})")
29 |
30 | async def on_error(self, event, *args, **kwargs):
31 | logger.error(f"{event=}{args}{kwargs}")
32 | logger.error(f"{''.join(traceback.format_stack())}")
33 |
34 | def add_service(self, service: AbstractService):
35 | """Add service add a service to the bot. The services are in the services packages.
36 | When a service is added, you can call it using `bot.services.`.
37 | For example, if your service is called `PlayService`, then you can call it using `bot.services.play`.
38 |
39 | Parameters
40 | ----------
41 | service : AbstractService
42 | The service to add. Must implement `AbstractService`!
43 | """
44 | name: str = regex.to_snake(service.__class__.__name__).replace("_service", "")
45 | self.services[name] = service
46 | setattr(self.services, name, service)
47 |
48 | @property
49 | def start_time(self):
50 | return self._start
51 |
52 | @property
53 | def players(self) -> PlayerSet:
54 | return self._players
55 |
56 | @property
57 | def services(self) -> ServiceMap:
58 | return self._services
59 |
60 | @cached_property
61 | def members_count(self) -> int:
62 | return len(set(self.get_all_members()))
63 |
64 | @cached_property
65 | def guilds_count(self) -> int:
66 | return len(self.guilds)
67 |
--------------------------------------------------------------------------------
/jukebot/listeners/__init__.py:
--------------------------------------------------------------------------------
1 | from .error_handler import ErrorHandler
2 | from .intercept_handler import InterceptHandler
3 |
--------------------------------------------------------------------------------
/jukebot/listeners/error_handler.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from disnake import CommandInteraction
4 | from disnake.ext import commands
5 | from disnake.ext.commands import CommandError
6 | from loguru import logger
7 |
8 | from jukebot import exceptions
9 | from jukebot.utils import embed
10 |
11 |
12 | class ErrorHandler(commands.Cog):
13 | def __init__(self, bot):
14 | self._bot = bot
15 |
16 | @commands.Cog.listener()
17 | async def on_slash_command_error(self, inter: CommandInteraction, error: CommandError):
18 | if isinstance(error, commands.CommandNotFound):
19 | return
20 |
21 | if isinstance(error, exceptions.QueryException):
22 | logger.opt(lazy=True).warning(
23 | f"Query Exception [{error.__class__.__name__}] '{error.query}' ({error.full_query}) for guild '{inter.guild.name} (ID: {inter.guild.id})'."
24 | )
25 | e = embed.music_not_found_message(
26 | title=error,
27 | )
28 | if inter.response.is_done():
29 | await inter.edit_original_message(embed=e)
30 | else:
31 | await inter.send(embed=e, ephemeral=True)
32 | return
33 |
34 | e = embed.error_message(content=error)
35 | if inter.response.is_done():
36 | await inter.edit_original_message(embed=e)
37 | else:
38 | await inter.send(embed=e, ephemeral=True)
39 |
40 |
41 | def setup(bot):
42 | bot.add_cog(ErrorHandler(bot))
43 |
--------------------------------------------------------------------------------
/jukebot/listeners/intercept_handler.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from loguru import logger
4 |
5 |
6 | class InterceptHandler(logging.Handler):
7 | def emit(self, record) -> None:
8 | try:
9 | level = logger.level(record.levelname).name
10 | except ValueError:
11 | level = record.levelno
12 |
13 | # Find caller from where originated the logged message
14 | frame, depth = logging.currentframe(), 2
15 | while frame.f_code.co_filename == logging.__file__:
16 | frame = frame.f_back
17 | depth += 1
18 |
19 | logger.opt(depth=depth, exception=record.exc_info, lazy=True).log(level, record.getMessage())
20 |
--------------------------------------------------------------------------------
/jukebot/listeners/logger_handler.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import traceback
4 |
5 | from disnake import CommandInteraction
6 | from disnake.ext import commands
7 | from disnake.ext.commands import CommandError
8 | from loguru import logger
9 |
10 | from jukebot import exceptions
11 |
12 |
13 | def fancy_traceback(exc: Exception) -> str:
14 | return "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
15 |
16 |
17 | class LoggerHandler(commands.Cog):
18 | def __init__(self, bot):
19 | self._bot = bot
20 |
21 | @commands.Cog.listener()
22 | async def on_slash_command(self, inter: CommandInteraction):
23 | logger.opt(lazy=True).info(
24 | f"Server: '{inter.guild.name}' (ID: {inter.guild.id}) | "
25 | f"Channel: '{inter.channel.name}' (ID: {inter.channel.id}) | "
26 | f"Invoker: '{inter.author}' | "
27 | f"Command: '{inter.application_command.cog.qualified_name}:{inter.application_command.name}' | "
28 | f"raw options: '{inter.options}'."
29 | )
30 |
31 | @commands.Cog.listener()
32 | async def on_slash_command_completion(self, inter: CommandInteraction):
33 | logger.opt(lazy=True).success(
34 | f"Server: '{inter.guild.name}' (ID: {inter.guild.id}) | "
35 | f"Channel: '{inter.channel.name}' (ID: {inter.channel.id}) | "
36 | f"Invoker: '{inter.author}' | "
37 | f"Command: '{inter.application_command.cog.qualified_name}:{inter.application_command.name}' | "
38 | f"raw options: '{inter.options}'."
39 | )
40 |
41 | @commands.Cog.listener()
42 | async def on_slash_command_error(self, inter: CommandInteraction, error: CommandError):
43 | if isinstance(error, exceptions.QueryException):
44 | # handled in error_handler.py
45 | return
46 |
47 | logger.error(
48 | f"Server: '{inter.guild.name}' (ID: {inter.guild.id}) | "
49 | f"Channel: '{inter.channel.name}' (ID: {inter.channel.id}) | "
50 | f"Invoker: '{inter.author}' | "
51 | f"Command: '{inter.application_command.cog.qualified_name}:{inter.application_command.name}' | "
52 | f"raw options: '{inter.options}' | "
53 | f"error: {error}\n"
54 | f"traceback : {fancy_traceback(error)}"
55 | )
56 |
57 |
58 | def setup(bot):
59 | bot.add_cog(LoggerHandler(bot))
60 |
--------------------------------------------------------------------------------
/jukebot/listeners/voice_handler.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 | from disnake import Member, VoiceChannel, VoiceState
6 | from disnake.ext import commands
7 | from disnake.ext.commands import Bot
8 |
9 |
10 | if TYPE_CHECKING:
11 | from jukebot.components import Player
12 |
13 |
14 | class VoiceHandler(commands.Cog):
15 | def __init__(self, bot):
16 | self.bot: Bot = bot
17 |
18 | @commands.Cog.listener()
19 | async def on_voice_state_update(self, member: Member, before: VoiceState, after: VoiceState):
20 | if before.channel != after.channel:
21 | if before.channel is None:
22 | self.bot.dispatch("voice_channel_connect", member, after.channel)
23 | elif after.channel is None:
24 | self.bot.dispatch("voice_channel_disconnect", member, before.channel)
25 | if len(before.channel.members) == 1:
26 | self.bot.dispatch("voice_channel_alone", member, before.channel)
27 | else:
28 | self.bot.dispatch("voice_channel_move", member, before.channel, after.channel)
29 |
30 | @commands.Cog.listener()
31 | async def on_voice_channel_connect(self, member: Member, channel: VoiceChannel):
32 | if member == self.bot.user:
33 | return
34 | if self.bot.user in channel.members:
35 | player: Player = self.bot.players[channel.guild.id]
36 | if player.is_paused:
37 | await self.bot.services.resume(guild_id=channel.guild.id)
38 |
39 | @commands.Cog.listener()
40 | async def on_voice_channel_alone(self, member: Member, channel: VoiceChannel):
41 | if channel.members[0].id == self.bot.user.id:
42 | player: Player = self.bot.players[channel.guild.id]
43 | if player.is_playing:
44 | await self.bot.services.pause(guild_id=channel.guild.id)
45 |
46 |
47 | def setup(bot):
48 | bot.add_cog(VoiceHandler(bot))
49 |
--------------------------------------------------------------------------------
/jukebot/services/__init__.py:
--------------------------------------------------------------------------------
1 | from .reset_service import ResetService
2 |
--------------------------------------------------------------------------------
/jukebot/services/music/__init__.py:
--------------------------------------------------------------------------------
1 | from .current_song_service import CurrentSongService
2 | from .grab_service import GrabService
3 | from .join_service import JoinService
4 | from .leave_service import LeaveService
5 | from .loop_service import LoopService
6 | from .pause_service import PauseService
7 | from .play_service import PlayService
8 | from .resume_service import ResumeService
9 | from .skip_service import SkipService
10 | from .stop_service import StopService
11 |
--------------------------------------------------------------------------------
/jukebot/services/music/current_song_service.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 |
6 | from jukebot.abstract_components import AbstractService
7 |
8 | if TYPE_CHECKING:
9 | from jukebot.components import AudioStream, Player, Song
10 |
11 |
12 | class CurrentSongService(AbstractService):
13 | async def __call__(self, /, guild_id: int):
14 | player: Player = self.bot.players[guild_id]
15 |
16 | stream: AudioStream = player.stream
17 | song: Song = player.song
18 | loop: Player.Loop = player.loop
19 |
20 | return song, stream, loop
21 |
--------------------------------------------------------------------------------
/jukebot/services/music/grab_service.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 |
6 | from jukebot.abstract_components import AbstractService
7 |
8 | if TYPE_CHECKING:
9 | from jukebot.components import AudioStream, Player, Song
10 |
11 |
12 | class GrabService(AbstractService):
13 | async def __call__(self, /, guild_id: int):
14 | player: Player = self.bot.players[guild_id]
15 | stream: AudioStream = player.stream
16 | song: Song = player.song
17 |
18 | return song, stream
19 |
--------------------------------------------------------------------------------
/jukebot/services/music/join_service.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import copy
4 | from typing import TYPE_CHECKING
5 |
6 | from disnake import CommandInteraction
7 |
8 | from jukebot.abstract_components import AbstractService
9 | from jukebot.exceptions import PlayerConnexionException
10 |
11 | if TYPE_CHECKING:
12 | from jukebot.components import Player
13 |
14 |
15 | class JoinService(AbstractService):
16 | async def __call__(
17 | self,
18 | /,
19 | interaction: CommandInteraction,
20 | ):
21 | player: Player = self.bot.players[interaction.guild.id]
22 | try:
23 | await player.join(interaction.author.voice.channel)
24 | except:
25 | # we remove the created player
26 | self.bot.players.pop(interaction.guild.id)
27 |
28 | cmd = self.bot.get_global_command_named("reset")
29 | raise PlayerConnexionException(
30 | f"Can't connect to **{interaction.author.voice.channel.name}**. "
31 | f"Check both __bot__ and __channel__ permissions.\n"
32 | f"If the issue persists, try to reset your player with ."
33 | )
34 |
35 | cpy_inter = copy.copy(interaction)
36 | # time to trick the copied interaction
37 | # used to display bot as invoker
38 | cpy_inter.author = self.bot.user
39 | # used to send a message in the channel instead of replying to an interaction
40 | # NB: possible source of bug, edit_original_message take content as a kwargs compare to send
41 | cpy_inter.send = cpy_inter.edit_original_message = cpy_inter.channel.send
42 |
43 | player.interaction = cpy_inter
44 |
--------------------------------------------------------------------------------
/jukebot/services/music/leave_service.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 |
4 | from jukebot.abstract_components import AbstractService
5 |
6 |
7 | class LeaveService(AbstractService):
8 | async def __call__(self, /, guild_id: int):
9 | # once the bot leave, we destroy is instance from the container
10 | await self.bot.players.pop(guild_id).disconnect()
11 |
--------------------------------------------------------------------------------
/jukebot/services/music/loop_service.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 |
6 | from jukebot import components
7 | from jukebot.abstract_components import AbstractService
8 |
9 | if TYPE_CHECKING:
10 | from jukebot.components import Player
11 |
12 |
13 | class LoopService(AbstractService):
14 | async def __call__(self, /, guild_id: int, mode: str):
15 | # TODO: refactor to use enum str
16 | player: Player = self.bot.players[guild_id]
17 | if mode == "song":
18 | player.loop = components.Player.Loop.SONG
19 | new_status = "Loop is set to song"
20 | elif mode == "queue":
21 | player.loop = components.Player.Loop.QUEUE
22 | new_status = "Loop is set to queue"
23 | elif mode == "none":
24 | player.loop = components.Player.Loop.DISABLED
25 | new_status = "Loop is disabled"
26 |
27 | return new_status
28 |
--------------------------------------------------------------------------------
/jukebot/services/music/pause_service.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 |
4 |
5 | from jukebot.abstract_components import AbstractService
6 |
7 |
8 | class PauseService(AbstractService):
9 | async def __call__(self, /, guild_id: int):
10 | self.bot.players[guild_id].pause()
11 |
--------------------------------------------------------------------------------
/jukebot/services/music/play_service.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING, Optional
4 |
5 | from disnake import CommandInteraction
6 | from loguru import logger
7 |
8 | from jukebot import components
9 | from jukebot.abstract_components import AbstractService
10 | from jukebot.components.requests import StreamRequest
11 | from jukebot.exceptions.player_exception import PlayerConnexionException
12 |
13 | if TYPE_CHECKING:
14 | from jukebot.components import Player, Result, Song
15 |
16 |
17 | class PlayService(AbstractService):
18 | async def __call__(
19 | self,
20 | /,
21 | interaction: CommandInteraction,
22 | query: str,
23 | top: Optional[bool] = False,
24 | ):
25 | player: Player = self.bot.players[interaction.guild.id]
26 |
27 | if not player.is_connected:
28 | await self.bot.services.join(interaction=interaction)
29 |
30 | if query:
31 | await self.bot.services.add(
32 | guild_id=interaction.guild.id,
33 | author=interaction.user,
34 | query=query,
35 | top=top,
36 | )
37 |
38 | if player.is_playing:
39 | # ? stop here cause it mean that we used play command as queue add command
40 | return
41 |
42 | rqs: Result = player.queue.get()
43 | author = rqs.requester
44 | async with StreamRequest(rqs.web_url) as req:
45 | await req.execute()
46 |
47 | song: Song = components.Song(req.result)
48 | song.requester = author
49 |
50 | try:
51 | await player.play(song)
52 | except Exception as e:
53 | logger.opt(lazy=True).error(
54 | f"Server {interaction.guild.name} ({interaction.guild.id}) can't play in its player. Err {e}"
55 | )
56 |
57 | cmd = self.bot.get_global_command_named("reset")
58 | raise PlayerConnexionException(
59 | "The player cannot play on the voice channel. This is because he's not connected to a voice channel or he's already playing something.\n"
60 | "This situation can happen when the player has been abruptly disconnected by Discord or a user (kicked from a voice channel). "
61 | f"Use the command to reset the player in this case."
62 | )
63 |
64 | logger.opt(lazy=True).success(
65 | f"Server {interaction.guild.name} ({interaction.guild.id}) can play in its player."
66 | )
67 |
68 | return song, player.loop
69 |
--------------------------------------------------------------------------------
/jukebot/services/music/resume_service.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 |
6 | from jukebot.abstract_components import AbstractService
7 |
8 |
9 | if TYPE_CHECKING:
10 | from jukebot.components import Player
11 |
12 |
13 | class ResumeService(AbstractService):
14 | async def __call__(self, /, guild_id: int):
15 | player: Player = self.bot.players[guild_id]
16 | if player.is_paused:
17 | player.resume()
18 | return True
19 |
20 | return False
21 |
--------------------------------------------------------------------------------
/jukebot/services/music/skip_service.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 |
4 |
5 | from jukebot.abstract_components import AbstractService
6 |
7 |
8 | class SkipService(AbstractService):
9 | async def __call__(self, /, guild_id: int):
10 | self.bot.players[guild_id].skip()
11 |
--------------------------------------------------------------------------------
/jukebot/services/music/stop_service.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 |
4 | from jukebot.abstract_components import AbstractService
5 |
6 |
7 | class StopService(AbstractService):
8 | async def __call__(self, /, guild_id: int):
9 | self.bot.players[guild_id].stop()
10 |
--------------------------------------------------------------------------------
/jukebot/services/queue/__init__.py:
--------------------------------------------------------------------------------
1 | from .add_service import AddService
2 | from .clear_service import ClearService
3 | from .remove_service import RemoveService
4 | from .show_service import ShowService
5 | from .shuffle_service import ShuffleService
6 |
--------------------------------------------------------------------------------
/jukebot/services/queue/add_service.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING, Optional
4 |
5 | from disnake import Member
6 |
7 | from jukebot import components
8 | from jukebot.abstract_components import AbstractService
9 | from jukebot.components.requests.music_request import MusicRequest
10 | from jukebot.exceptions import QueryFailed
11 |
12 | if TYPE_CHECKING:
13 | from jukebot.components import Player, Result, ResultSet
14 |
15 |
16 | class AddService(AbstractService):
17 | async def __call__(
18 | self,
19 | /,
20 | guild_id: int,
21 | author: Member,
22 | query: str,
23 | top: Optional[bool] = False,
24 | ):
25 | async with MusicRequest(query) as req:
26 | await req.execute()
27 |
28 | if not req.success:
29 | raise QueryFailed(f"Nothing found for {query}", query=query, full_query=query)
30 |
31 | if req.type == MusicRequest.ResultType.PLAYLIST:
32 | res: ResultSet = components.ResultSet.from_result(req.result, author)
33 | player: Player = self.bot.players[guild_id]
34 | else:
35 | res: Result = components.Result(req.result)
36 | res.requester = author
37 | player: Player = self.bot.players[guild_id]
38 |
39 | if top:
40 | player.queue.add(res)
41 | else:
42 | player.queue.put(res)
43 |
44 | return req.type, res
45 |
--------------------------------------------------------------------------------
/jukebot/services/queue/clear_service.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 | from disnake.ext import commands
6 |
7 | from jukebot import components
8 | from jukebot.abstract_components import AbstractService
9 |
10 | if TYPE_CHECKING:
11 | from jukebot.components import Player
12 |
13 |
14 | class ClearService(AbstractService):
15 | async def __call__(self, /, guild_id: int):
16 | player: Player = self.bot.players[guild_id]
17 | if player.loop == components.Player.Loop.QUEUE:
18 | raise commands.UserInputError("Can't clear queue when queue loop is enabled")
19 |
20 | player.queue = components.ResultSet.empty()
21 |
--------------------------------------------------------------------------------
/jukebot/services/queue/remove_service.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 | from disnake.ext import commands
6 |
7 | from jukebot.abstract_components import AbstractService
8 |
9 | if TYPE_CHECKING:
10 | from jukebot.components import ResultSet
11 |
12 |
13 | class RemoveService(AbstractService):
14 | async def __call__(self, /, guild_id: int, song: str):
15 | queue: ResultSet = self.bot.players[guild_id].queue
16 | if not (elem := queue.remove(song)):
17 | raise commands.UserInputError(f"Can't delete song `{song}`. Not in playlist.")
18 |
19 | return elem
20 |
--------------------------------------------------------------------------------
/jukebot/services/queue/show_service.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 |
4 | from jukebot.abstract_components import AbstractService
5 |
6 |
7 | class ShowService(AbstractService):
8 | async def __call__(self, /, guild_id: int):
9 | return self.bot.players[guild_id].queue
10 |
--------------------------------------------------------------------------------
/jukebot/services/queue/shuffle_service.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 |
4 | from jukebot.abstract_components import AbstractService
5 |
6 |
7 | class ShuffleService(AbstractService):
8 | async def __call__(self, /, guild_id: int):
9 | self.bot.players[guild_id].queue.shuffle()
10 |
--------------------------------------------------------------------------------
/jukebot/services/reset_service.py:
--------------------------------------------------------------------------------
1 | from typing import TYPE_CHECKING
2 |
3 | from disnake import Guild
4 | from loguru import logger
5 |
6 | from jukebot.abstract_components import AbstractService
7 | from jukebot.exceptions.player_exception import PlayerDontExistException
8 |
9 | if TYPE_CHECKING:
10 | from jukebot.components import Player
11 |
12 |
13 | class ResetService(AbstractService):
14 | async def __call__(self, /, guild: Guild):
15 | if not guild.id in self.bot.players:
16 | logger.opt(lazy=True).debug(f"Server {guild.name} ({guild.id}) try to kill a player that don't exist.")
17 | raise PlayerDontExistException("No player detected in this server.")
18 |
19 | player: Player = self.bot.players.pop(guild.id)
20 | try:
21 | await player.disconnect(force=True)
22 | except Exception as e:
23 | logger.opt(lazy=True).error(
24 | f"Error when force disconnecting the player of the guild {guild.name} ({guild.id}). " f"Error: {e}"
25 | )
26 |
27 | logger.opt(lazy=True).success(f"Server {guild.name} ({guild.id}) has successfully reset his player.")
28 |
--------------------------------------------------------------------------------
/jukebot/utils/__init__.py:
--------------------------------------------------------------------------------
1 | from .environment import Environment
2 | from .extensions import Extensions
3 |
--------------------------------------------------------------------------------
/jukebot/utils/aioweb.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from urllib import parse
4 |
5 | import aiohttp
6 | import asyncstdlib as alib
7 | from loguru import logger
8 |
9 | _MOZ_HEADER = {"User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64)"}
10 |
11 |
12 | @alib.lru_cache(maxsize=1024)
13 | async def cached_query(url, enquote_url: bool = False) -> (int, str):
14 | logger.opt(lazy=True).info(f"Cached query for url {url}")
15 | return await _get(url, enquote_url)
16 |
17 |
18 | async def uncached_query(url, enquote_url: bool = False) -> (int, str):
19 | logger.opt(lazy=True).info(f"Uncached query for url {url}")
20 | return await _get(url, enquote_url)
21 |
22 |
23 | async def _get(url, enquote) -> (int, str):
24 | url = url if not enquote else parse.quote(url)
25 |
26 | async with aiohttp.ClientSession(headers=_MOZ_HEADER) as session:
27 | async with session.get(url) as rep:
28 | logger.opt(lazy=True).info(f"Get url {url}")
29 | logger.opt(lazy=True).info(f"URL {url} status: {rep.status}")
30 | logger.opt(lazy=True).debug(f"URL {url} content-type: {rep.headers['content-type']}")
31 | if rep.status != 200:
32 | return rep.status, ""
33 |
34 | if "application/json" in rep.headers["content-type"]:
35 | return rep.status, await rep.json()
36 | return rep.status, await rep.text()
37 |
--------------------------------------------------------------------------------
/jukebot/utils/applications.py:
--------------------------------------------------------------------------------
1 | # credit to discord-together lib
2 | # https://github.com/apurv-r/discord-together/blob/main/discord_together/discordTogetherMain.py#L6-L22
3 | default = {
4 | # Credits to RemyK888
5 | "youtube": "880218394199220334",
6 | "poker": "755827207812677713",
7 | "betrayal": "773336526917861400",
8 | "fishing": "814288819477020702",
9 | "chess": "832012774040141894",
10 | # Credits to awesomehet2124
11 | "letter-tile": "879863686565621790",
12 | "word-snack": "879863976006127627",
13 | "doodle-crew": "878067389634314250",
14 | "spellcast": "852509694341283871",
15 | "awkword": "879863881349087252",
16 | "checkers": "832013003968348200",
17 | }
18 |
--------------------------------------------------------------------------------
/jukebot/utils/checks.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING, Optional
4 |
5 | from disnake import CommandInteraction, VoiceClient
6 | from disnake.ext.commands import CheckFailure
7 |
8 | if TYPE_CHECKING:
9 | from jukebot.components import Player
10 |
11 |
12 | def _get_player(inter: CommandInteraction) -> Optional[Player]:
13 | """
14 | Avoid creating a player if the player does't exist
15 | Parameters
16 | ----------
17 | inter: The interaction
18 |
19 | Returns
20 | -------
21 | Player if it exist, None otherwise
22 | """
23 | if not inter.guild.id in inter.bot.players:
24 | return None
25 | return inter.bot.players[inter.guild.id]
26 |
27 |
28 | def user_is_connected(inter: CommandInteraction):
29 | if not inter.author.voice:
30 | raise CheckFailure("You must be on a voice channel to use this command.")
31 | return True
32 |
33 |
34 | def bot_is_connected(inter: CommandInteraction):
35 | if not inter.guild.voice_client:
36 | raise CheckFailure("The bot is not connected to a voice channel.")
37 | return True
38 |
39 |
40 | def bot_is_not_connected(inter: CommandInteraction):
41 | if inter.guild.voice_client:
42 | raise CheckFailure("The bot is already connected to a voice channel.")
43 | return True
44 |
45 |
46 | def bot_and_user_in_same_channel(inter: CommandInteraction):
47 | b_conn: bool = inter.guild.voice_client
48 | u_conn: bool = inter.author.voice
49 | if not b_conn or not u_conn:
50 | raise CheckFailure("You or the bot is not connected to a voice channel.")
51 |
52 | b_vc: VoiceClient = inter.guild.voice_client.channel
53 | u_vc: VoiceClient = inter.author.voice.channel
54 | if not b_vc == u_vc:
55 | raise CheckFailure("You're not connected to the same voice channel as the bot.")
56 | return True
57 |
58 |
59 | def bot_is_playing(inter: CommandInteraction):
60 | player: Player = _get_player(inter)
61 | if not player or not player.is_playing:
62 | raise CheckFailure("The bot is not currently playing.")
63 | return True
64 |
65 |
66 | def bot_is_not_playing(inter: CommandInteraction):
67 | player: Player = _get_player(inter)
68 | if not player or player.is_playing:
69 | raise CheckFailure("The bot is already playing.")
70 | return True
71 |
72 |
73 | def bot_is_streaming(inter: CommandInteraction):
74 | player: Player = _get_player(inter)
75 | if not player or not player.is_streaming:
76 | raise CheckFailure("The bot is not currently playing.")
77 | return True
78 |
79 |
80 | def bot_not_playing_live(inter: CommandInteraction):
81 | player: Player = _get_player(inter)
82 | if not player or (player.is_playing and player.song.live):
83 | raise CheckFailure("The bot is playing a live audio.")
84 | return True
85 |
86 |
87 | def bot_queue_is_not_empty(inter: CommandInteraction):
88 | player: Player = _get_player(inter)
89 | if not player or player.queue.is_empty():
90 | raise CheckFailure("The queue is empty.")
91 | return True
92 |
--------------------------------------------------------------------------------
/jukebot/utils/converter.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import re
4 |
5 | import yaml
6 |
7 |
8 | def seconds_to_time(seconds: int) -> (int, int, int, int):
9 | h, r = divmod(seconds, 3600)
10 | m, s = divmod(r, 60)
11 | d, h = divmod(h, 24)
12 | return d, h, m, s
13 |
14 |
15 | def time_to_youtube_format(time: (int, int, int, int)) -> str:
16 | return re.sub(r"^[0:]*(.+:..)$", r"\1", ":".join([f"{e:02d}" for e in time]))
17 |
18 |
19 | def seconds_to_youtube_format(seconds: int) -> str:
20 | return time_to_youtube_format(seconds_to_time(seconds))
21 |
22 |
23 | def number_to_emoji(n: int) -> str:
24 | number: dict = {
25 | 1: "1️⃣",
26 | 2: "2️⃣",
27 | 3: "3️⃣",
28 | 4: "4️⃣",
29 | 5: "5️⃣",
30 | 6: "6️⃣",
31 | 7: "7️⃣",
32 | 8: "8️⃣",
33 | 9: "9️⃣",
34 | 10: "🔟",
35 | }
36 | return number[n]
37 |
38 |
39 | def duration_seconds_to_progress_bar(time: int, total: int, ticks: int = 30) -> str:
40 | x = int(ticks * (time / total)) if total else 0
41 | line = "".join(["▬" if t != x else "🔘" for t in range(ticks)])
42 | return line
43 |
44 |
45 | def radios_yaml_to_dict() -> dict:
46 | radios: dict = {}
47 | with open("./data/radios.yaml", "r") as f:
48 | data = yaml.safe_load(f)
49 | for e in data:
50 | radios.update(e)
51 | return radios
52 |
--------------------------------------------------------------------------------
/jukebot/utils/coro.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | from loguru import logger
4 |
5 |
6 | def run_threadsafe(coro, loop):
7 | fut = asyncio.run_coroutine_threadsafe(coro, loop)
8 | try:
9 | fut.result()
10 | except Exception as e:
11 | logger.error(e)
12 |
--------------------------------------------------------------------------------
/jukebot/utils/embed.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import itertools
4 | import random
5 | from typing import TYPE_CHECKING
6 |
7 | import disnake
8 | from disnake import APISlashCommand, Member
9 | from disnake.ext.commands import Bot
10 |
11 | from jukebot.utils import converter
12 |
13 | if TYPE_CHECKING:
14 | from jukebot.components import Result, ResultSet, Song
15 | from jukebot.components.player import Player
16 |
17 | VOID_TOKEN = "\u200B"
18 |
19 |
20 | def _base_embed(content="", color=0x38383D):
21 | return disnake.Embed(title="", description=content, color=color)
22 |
23 |
24 | def error_message(title="", content=""):
25 | embed: disnake.Embed = _base_embed(content=content, color=0xDB3C30)
26 | embed.set_author(
27 | name="Error" if title == "" else title,
28 | icon_url="https://icons.iconarchive.com/icons/papirus-team/papirus-status/512/dialog-error-icon.png",
29 | )
30 | return embed
31 |
32 |
33 | def info_message(title="", content=""):
34 | embed: disnake.Embed = _base_embed(content=content, color=0x30A3DB)
35 |
36 | embed.set_author(
37 | name="Information" if title == "" else title,
38 | icon_url="https://icons.iconarchive.com/icons/papirus-team/papirus-status/512/dialog-information-icon.png",
39 | )
40 | return embed
41 |
42 |
43 | def basic_message(title="", content=""):
44 | embed: disnake.Embed = _base_embed(content=content, color=0x4F4F4F)
45 | embed.set_author(
46 | name="Information" if title == "" else title,
47 | icon_url="https://cdn.discordapp.com/attachments/573225654452092930/908327963718713404/juke-icon.png",
48 | )
49 | return embed
50 |
51 |
52 | def activity_message(title="", content=""):
53 | colors = [0xF6C333, 0xF4B400]
54 | c = colors[random.randint(0, 1)]
55 | embed: disnake.Embed = _base_embed(content=content, color=c)
56 | embed.set_author(
57 | name="Information" if title == "" else title,
58 | icon_url="https://icons.iconarchive.com/icons/papirus-team/papirus-apps/512/dragon-ball-online-global-icon.png",
59 | )
60 | return embed
61 |
62 |
63 | def music_message(song: Song, loop_mode: Player.Loop, current_duration: int = 0):
64 | colors = [0x736DAB, 0xFFBA58]
65 | c = colors[random.randint(0, 1)]
66 |
67 | embed: disnake.Embed = _base_embed(content="", color=c)
68 | embed.set_author(
69 | name=song.title,
70 | url=song.web_url,
71 | icon_url="https://icons.iconarchive.com/icons/papirus-team/papirus-apps/512/musicbrainz-icon.png",
72 | )
73 |
74 | if song.thumbnail:
75 | embed.set_thumbnail(url=song.thumbnail)
76 |
77 | embed.add_field(name="Channel", value=song.channel, inline=True)
78 | if current_duration:
79 | embed.add_field(VOID_TOKEN, VOID_TOKEN, inline=True)
80 | embed.add_field("Loop", loop_mode.name.capitalize(), inline=True)
81 |
82 | if current_duration:
83 | line = converter.duration_seconds_to_progress_bar(current_duration, song.duration)
84 | fmt_current: str = converter.seconds_to_youtube_format(current_duration)
85 | embed.add_field(name="Progression", value=f"`{fmt_current} {line} {song.fmt_duration}`")
86 | else:
87 | embed.add_field(name="Duration", value=f"`{song.fmt_duration}`")
88 |
89 | return embed
90 |
91 |
92 | def music_search_message(title="", content=""):
93 | embed: disnake.Embed = _base_embed(content=content, color=0x4F4F4F)
94 | embed.set_author(
95 | name="Search" if title == "" else title,
96 | icon_url="https://icons.iconarchive.com/icons/papirus-team/papirus-apps/512/d-feet-icon.png",
97 | )
98 | return embed
99 |
100 |
101 | def music_not_found_message(title="", content=""):
102 | embed: disnake.Embed = _base_embed(content=content, color=0xEBA229)
103 | embed.set_author(
104 | name="Error" if title == "" else title,
105 | icon_url="https://icons.iconarchive.com/icons/papirus-team/papirus-apps/512/plasma-search-icon.png",
106 | )
107 | return embed
108 |
109 |
110 | def music_found_message(music: dict, title=""):
111 | embed: disnake.Embed = _base_embed(content=f"[{music['title']}]({music['url']})", color=0x54B23F)
112 | embed.set_author(
113 | name="Music found!" if title == "" else title,
114 | icon_url="https://cdn.discordapp.com/attachments/573225654452092930/952197615221612594/d-feet-icon.png",
115 | )
116 | embed.set_thumbnail(url=music["image_url"])
117 | return embed
118 |
119 |
120 | def search_result_message(playlist: ResultSet, title=""):
121 | content = "\n\n".join(
122 | [
123 | f"{converter.number_to_emoji(i)} `{s.title} by {s.channel}` **[{s.fmt_duration}]**"
124 | for i, s in enumerate(playlist, start=1)
125 | ]
126 | )
127 | embed: disnake.Embed = music_search_message(title=title, content=content)
128 | embed.add_field(
129 | name=VOID_TOKEN,
130 | value="Use the selector below to choose a result.",
131 | )
132 | return embed
133 |
134 |
135 | def basic_queue_message(title="", content=""):
136 | colors = [0x438F96, 0x469961, 0x3F3F3F]
137 | c = colors[random.randint(0, 2)]
138 | embed: disnake.Embed = _base_embed(content=content, color=c)
139 | embed.set_author(
140 | name="Information" if title == "" else title,
141 | icon_url="https://cdn.icon-icons.com/icons2/1381/PNG/512/xt7playermpv_94294.png",
142 | )
143 | return embed
144 |
145 |
146 | def queue_message(playlist: ResultSet, bot: Bot, title=""):
147 | playlist_slice = itertools.islice(playlist, 10)
148 | content = "\n\n".join(
149 | [
150 | f"`{i}` • `{s.title}` on `{s.channel}` **[{s.fmt_duration}]** — `{s.requester}`"
151 | for i, s in enumerate(playlist_slice, start=1)
152 | ]
153 | )
154 | embed: disnake.Embed = basic_queue_message(title=title, content=content)
155 | embed.add_field(name="Total songs", value=f"`{len(playlist)}`")
156 | total_time: int = sum([e.duration for e in playlist if not e.live])
157 | total_time_fmt: str = converter.seconds_to_youtube_format(total_time)
158 | embed.add_field(name="Total duration", value=f"`{total_time_fmt}`")
159 |
160 | cmd: APISlashCommand = bot.get_global_command_named("queue")
161 | embed.add_field(
162 | name=VOID_TOKEN,
163 | value=f"Use command or to add or remove a song.",
164 | inline=False,
165 | )
166 | return embed
167 |
168 |
169 | def result_enqueued(res: Result):
170 | colors = [0x438F96, 0x469961, 0x3F3F3F]
171 | c = colors[random.randint(0, 2)]
172 | embed: disnake.Embed = _base_embed(content="", color=c)
173 | embed.set_author(
174 | name=f"Enqueued : {res.title}",
175 | url=res.web_url,
176 | icon_url="https://cdn.icon-icons.com/icons2/1381/PNG/512/xt7playermpv_94294.png",
177 | )
178 | embed.add_field(name="Channel", value=res.channel, inline=True)
179 | embed.add_field(name="Duration", value=res.fmt_duration)
180 | return embed
181 |
182 |
183 | def grab_message(song: Song, current_duration: int = 0):
184 | embed: disnake.Embed = _base_embed(content=f"[{song.title}]({song.web_url})", color=0x366ADB)
185 | embed.set_author(
186 | name="Saved music",
187 | icon_url="https://icons.iconarchive.com/icons/papirus-team/papirus-apps/512/atunes-icon.png",
188 | )
189 | embed.add_field(name="Channel", value=song.channel)
190 | fmt_current: str = converter.seconds_to_youtube_format(current_duration)
191 | embed.add_field(name="Time code", value=f"`{fmt_current}/{song.fmt_duration}`")
192 | if song.thumbnail:
193 | embed.set_thumbnail(url=song.thumbnail)
194 | return embed
195 |
196 |
197 | def share_message(author: Member, content, title="", url="", img=""):
198 | embed: disnake.Embed = _base_embed(content=content, color=0x366ADB)
199 | embed.set_author(
200 | name=title if title else f"Music shared by {author}",
201 | icon_url="https://icons.iconarchive.com/icons/papirus-team/papirus-apps/512/atunes-icon.png",
202 | url=url,
203 | )
204 | if img:
205 | embed.set_thumbnail(url=img)
206 | return embed
207 |
--------------------------------------------------------------------------------
/jukebot/utils/environment.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 |
3 |
4 | class Environment(str, Enum):
5 | DEVELOPMENT = "dev"
6 | PRODUCTION = "prod"
7 |
--------------------------------------------------------------------------------
/jukebot/utils/extensions.py:
--------------------------------------------------------------------------------
1 | class Extensions:
2 | __list__ = [
3 | {"package": "jukebot.listeners", "name": "logger_handler"},
4 | {"package": "jukebot.listeners", "name": "error_handler"},
5 | {"package": "jukebot.listeners", "name": "voice_handler"},
6 | {"package": "jukebot.cogs", "name": "utility"},
7 | {"package": "jukebot.cogs", "name": "music"},
8 | {"package": "jukebot.cogs", "name": "system"},
9 | {"package": "jukebot.cogs", "name": "search"},
10 | {"package": "jukebot.cogs", "name": "queue"},
11 | {"package": "jukebot.cogs", "name": "radio"},
12 | ]
13 |
14 | @staticmethod
15 | def all():
16 | return Extensions.__list__
17 |
18 | @staticmethod
19 | def get(name):
20 | for e in Extensions.__list__:
21 | if e["name"] == name:
22 | return e
23 | else:
24 | return None
25 |
26 | def __repr__(self):
27 | return self.__list__.__repr__()
28 |
--------------------------------------------------------------------------------
/jukebot/utils/intents.py:
--------------------------------------------------------------------------------
1 | from disnake import Intents
2 |
3 |
4 | def get() -> Intents:
5 | intents = Intents.none()
6 | intents.guilds = True
7 | intents.voice_states = True
8 | return intents
9 |
--------------------------------------------------------------------------------
/jukebot/utils/logging.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import datetime
4 | import logging
5 | import logging as plogging
6 | import os
7 | from contextlib import contextmanager
8 |
9 | from loguru import logger
10 |
11 | from jukebot.listeners import InterceptHandler
12 |
13 | from .environment import Environment
14 |
15 |
16 | def set_logging(
17 | jukebot_loglevel: int,
18 | /,
19 | intercept_disnake_log: bool = True,
20 | disnake_loglevel: int = logging.INFO,
21 | ):
22 | logger.info(f"Environment is set to '{os.environ['ENVIRONMENT']}'.")
23 | logger.info(f"Jukebot log messages with level {plogging.getLevelName(jukebot_loglevel)}.")
24 |
25 | if intercept_disnake_log:
26 | logger.info(f"Intercepting disnake log messages with level {plogging.getLevelName(disnake_loglevel)}.")
27 | logging.basicConfig(handlers=[InterceptHandler()], level=disnake_loglevel)
28 |
29 | if not os.environ["ENVIRONMENT"] in list(Environment):
30 | logger.critical(f"Unknown environment {os.environ['ENVIRONMENT']}.")
31 | exit(1)
32 |
33 | if os.environ["ENVIRONMENT"] == Environment.PRODUCTION:
34 | logger.remove()
35 | fmt = "{time:YYYY-MM-DD at HH:mm:ss} || {level} || {name} || {message}"
36 | logger.add(
37 | f"./logs/log-{datetime.datetime.now():%Y-%m-%d}.log",
38 | level=jukebot_loglevel,
39 | format=fmt,
40 | rotation="01:00",
41 | retention="10 days",
42 | enqueue=True,
43 | mode="w",
44 | encoding="utf-8",
45 | )
46 |
47 |
48 | @contextmanager
49 | def disable_logging(name: str | None = None) -> None:
50 | """Temporary disable logging for a given module
51 |
52 | Parameters
53 | ----------
54 | name : str | None
55 | The module name where you want to disable logging
56 | Example
57 | _______
58 | ```
59 | class MyTest(unittest.TestCase):
60 |
61 | def test_do_something(self):
62 | with disable_logger('mypackage.mymodule'):
63 | mymodule.do_something()
64 | ```
65 | """
66 | name = "jukebot" if not name else name
67 | logger.disable(name)
68 | try:
69 | yield
70 | finally:
71 | logger.enable(name)
72 |
--------------------------------------------------------------------------------
/jukebot/utils/regex.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | URL_REGEX = r"https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)"
4 | URL_PATTERN = re.compile(URL_REGEX, re.MULTILINE)
5 |
6 | TO_SNAKE_REGEX = r"(? bool:
11 | return not URL_PATTERN.match(s) is None
12 |
13 |
14 | def to_snake(pascal_str: str) -> str:
15 | snake_str = re.sub(TO_SNAKE_PATTERN, r"_\1", pascal_str)
16 | return snake_str.lower()
17 |
--------------------------------------------------------------------------------
/jukebot/views/__init__.py:
--------------------------------------------------------------------------------
1 | from .activity_view import ActivityView
2 | from .promote_view import PromoteView
3 | from .search_view import SearchDropdownView, SearchInteraction
4 |
--------------------------------------------------------------------------------
/jukebot/views/activity_view.py:
--------------------------------------------------------------------------------
1 | import disnake
2 |
3 |
4 | class _JoinActivityButton(disnake.ui.Button):
5 | def __init__(self, url):
6 | label = "Join the activity"
7 | emoji = "🌠"
8 |
9 | super().__init__(url=url, label=label, emoji=emoji)
10 |
11 |
12 | class ActivityView(disnake.ui.View):
13 | def __init__(self, code):
14 | super(ActivityView, self).__init__()
15 | self.add_item(_JoinActivityButton(code))
16 |
--------------------------------------------------------------------------------
/jukebot/views/promote_view.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | import disnake
4 |
5 |
6 | class _VoteButton(disnake.ui.Button):
7 | def __init__(self):
8 | url = os.environ["BOT_VOTE_URL"]
9 | label = "Vote"
10 | emoji = "📈"
11 |
12 | super().__init__(url=url, label=label, emoji=emoji)
13 |
14 |
15 | class _InviteButton(disnake.ui.Button):
16 | def __init__(self):
17 | url = os.environ["BOT_INVITE_URL"]
18 | label = "Invite me"
19 | emoji = "➕"
20 |
21 | super().__init__(url=url, label=label, emoji=emoji)
22 |
23 |
24 | class _DonateButton(disnake.ui.Button):
25 | def __init__(self):
26 | url = os.environ["BOT_DONATE_URL"]
27 | label = "Donate"
28 | emoji = "✨"
29 |
30 | super().__init__(url=url, label=label, emoji=emoji)
31 |
32 |
33 | class _ServerButton(disnake.ui.Button):
34 | def __init__(self):
35 | url = os.environ["BOT_SERVER_URL"]
36 | label = "Community"
37 | emoji = "🫂"
38 |
39 | super().__init__(url=url, label=label, emoji=emoji)
40 |
41 |
42 | class PromoteView(disnake.ui.View):
43 | def __init__(self):
44 | super(PromoteView, self).__init__()
45 | self.add_item(_InviteButton())
46 | self.add_item(_VoteButton())
47 | self.add_item(_ServerButton())
48 | self.add_item(_DonateButton())
49 |
--------------------------------------------------------------------------------
/jukebot/views/search_view.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import os
4 | from typing import TYPE_CHECKING
5 |
6 | import disnake
7 | from disnake import Interaction, Member
8 |
9 | if TYPE_CHECKING:
10 | from jukebot.components import ResultSet
11 |
12 |
13 | class SearchInteraction:
14 | CANCEL_REACTION = "❌"
15 | CANCEL_TEXT = "Cancel"
16 | NUMBER_REACTION = [
17 | "1️⃣",
18 | "2️⃣",
19 | "3️⃣",
20 | "4️⃣",
21 | "5️⃣",
22 | "6️⃣",
23 | "7️⃣",
24 | "8️⃣",
25 | "9️⃣",
26 | "🔟",
27 | ]
28 |
29 |
30 | class _SearchDropdown(disnake.ui.Select):
31 | def __init__(self, results: ResultSet):
32 | self._results = results
33 | options = [
34 | disnake.SelectOption(
35 | label=r.title,
36 | value=r.web_url,
37 | description=f"on {r.channel} — {r.fmt_duration}",
38 | emoji=SearchInteraction.NUMBER_REACTION[i],
39 | )
40 | for i, r in enumerate(results)
41 | ]
42 | options.append(
43 | disnake.SelectOption(
44 | label="Cancel",
45 | value=SearchInteraction.CANCEL_TEXT,
46 | description="Cancel the current search",
47 | emoji=SearchInteraction.CANCEL_REACTION,
48 | )
49 | )
50 |
51 | super().__init__(
52 | placeholder="Choose a song...",
53 | min_values=1,
54 | max_values=1,
55 | options=options,
56 | )
57 |
58 |
59 | class SearchDropdownView(disnake.ui.View):
60 | def __init__(self, author: Member, results: ResultSet):
61 | super().__init__(timeout=float(os.environ["BOT_SEARCH_TIMEOUT"]))
62 | self._author = author
63 | self._drop = _SearchDropdown(results)
64 | self.add_item(self._drop)
65 | self._timeout = False
66 |
67 | async def interaction_check(self, interaction: Interaction):
68 | if self._author != interaction.user:
69 | return
70 | self.stop()
71 |
72 | async def on_timeout(self) -> None:
73 | self._timeout = True
74 |
75 | @property
76 | def result(self):
77 | return self._drop.values[0] if not self._timeout else SearchInteraction.CANCEL_TEXT
78 |
--------------------------------------------------------------------------------
/logs/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dysta/JukeBot/8d4e19686af9766970f208ec99a45bf8b43ba378/logs/.gitkeep
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "JukeBot"
3 | version = "0.1.0"
4 | description = "A music bot for Discord using Disnake"
5 | authors = ["Dysta "]
6 |
7 | [tool.poetry.dependencies]
8 | python = "^3.8"
9 | disnake = {extras = ["speed", "voice"], version = "^2.4.0"}
10 | loguru = "^0.5.3"
11 | uvloop = {version = "^0.16.0", optional = true}
12 | taskipy = "^1.10.1"
13 | python-dotenv = "^0.19.2"
14 | shazamio = "^0.1.0"
15 | asyncstdlib = "^3.10.4"
16 | yt-dlp = "^2024.7.1"
17 | pyyaml = "^6.0.1"
18 |
19 | [tool.poetry.extras]
20 | speed = ["uvloop"]
21 |
22 | [tool.poetry.group.dev.dependencies]
23 | black = "^24.3.0"
24 | autoflake = "^2.3.1"
25 | isort = "^5.10.1"
26 |
27 | [tool.taskipy.tasks]
28 | start = { cmd = "python -m jukebot", help = "run the bot" }
29 | black = { cmd = "python -m black .", help = "blackify the code" }
30 | clean = { cmd = "python -m autoflake . -r -i -v --ignore-init-module-imports --remove-all-unused-imports --remove-unused-variables", help = "remove unused code/import/variable" }
31 | tests = { cmd = "python -m unittest -v", help = "run all the tests" }
32 |
33 | [tool.black]
34 | line-length = 120
35 |
36 | [tool.isort]
37 | profile = "black"
38 |
39 | [build-system]
40 | requires = ["poetry-core>=1.0.0"]
41 | build-backend = "poetry.core.masonry.api"
42 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dysta/JukeBot/8d4e19686af9766970f208ec99a45bf8b43ba378/tests/__init__.py
--------------------------------------------------------------------------------
/tests/legacy.test_query.py:
--------------------------------------------------------------------------------
1 | import unittest
2 |
3 | from jukebot.components import Query, Result, ResultSet, Song
4 | from jukebot.utils.logging import disable_logging
5 |
6 |
7 | class TestQueryComponents(unittest.IsolatedAsyncioTestCase):
8 | async def test_query_to_song_from_url(self):
9 | with disable_logging():
10 | qry: Query = Query("https://www.youtube.com/watch?v=hpwnjXrPxtM")
11 | await qry.process()
12 | self.assertTrue(qry.success)
13 | self.assertEqual(qry.type, Query.Type.TRACK)
14 |
15 | song: Song = Song(qry)
16 | self.assertEqual(song.title, "tech house mix | vascoprod")
17 | self.assertEqual(song.duration, 1824)
18 | self.assertEqual(song.fmt_duration, "30:24")
19 | self.assertEqual(song.web_url, "https://www.youtube.com/watch?v=hpwnjXrPxtM")
20 | self.assertEqual(song.channel, "vascoprod")
21 | self.assertFalse(song.live)
22 |
23 | async def test_query_to_song_from_url_live(self):
24 | with disable_logging():
25 | qry: Query = Query("https://www.youtube.com/watch?v=rUxyKA_-grg")
26 | await qry.process()
27 | self.assertTrue(qry.success)
28 | self.assertEqual(qry.type, Query.Type.TRACK)
29 |
30 | song: Song = Song(qry)
31 | self.assertIn("lofi hip hop radio 💤 - beats to sleep/chill", song.title)
32 | self.assertEqual(song.duration, 0)
33 | self.assertEqual(song.fmt_duration, "ထ")
34 | self.assertEqual(song.web_url, "https://www.youtube.com/watch?v=rUxyKA_-grg")
35 | self.assertEqual(song.channel, "Lofi Girl")
36 | self.assertTrue(song.live)
37 |
38 | async def test_query_to_song_from_str(self):
39 | with disable_logging():
40 | qry: Query = Query("home resonance")
41 | await qry.process()
42 | self.assertTrue(qry.success)
43 | self.assertEqual(qry.type, Query.Type.TRACK)
44 |
45 | song: Song = Song(qry)
46 | self.assertEqual(song.title, "HOME - Resonance")
47 | self.assertEqual(song.duration, 213)
48 | self.assertEqual(song.fmt_duration, "3:33")
49 | self.assertEqual(song.web_url, "https://www.youtube.com/watch?v=8GW6sLrK40k")
50 | self.assertEqual(song.channel, "Electronic Gems")
51 | self.assertFalse(song.live)
52 |
53 | async def test_query_to_song_from_str_live(self):
54 | with disable_logging():
55 | qry: Query = Query("lofi hip hop radio - beats to relax/study to")
56 | await qry.process()
57 | self.assertTrue(qry.success)
58 | self.assertEqual(qry.type, Query.Type.TRACK)
59 |
60 | song: Song = Song(qry)
61 | self.assertIn("lofi hip hop radio 📚 - beats to relax/study to", song.title)
62 | self.assertEqual(song.duration, 0)
63 | self.assertEqual(song.fmt_duration, "ထ")
64 | self.assertEqual(song.web_url, "https://www.youtube.com/watch?v=jfKfPfyJRdk")
65 | self.assertEqual(song.channel, "Lofi Girl")
66 | self.assertTrue(song.live)
67 |
68 | async def test_query_to_playlist_from_url(self):
69 | with disable_logging():
70 | qry: Query = Query("https://www.youtube.com/playlist?list=PLjnOFoOKDEU9rzMtOaKGLABN7QhG19Nl0")
71 | await qry.process()
72 | self.assertTrue(qry.success)
73 | self.assertEqual(qry.type, Query.Type.PLAYLIST)
74 | self.assertEqual(len(qry.results), 8)
75 |
76 | results: ResultSet = ResultSet(qry)
77 | result: Result = None
78 |
79 | # test each result
80 | result = results.get()
81 | self.assertEqual(len(results), 7)
82 | self.assertEqual(result.title, "Clams Casino - Water Theme")
83 | self.assertEqual(result.duration, 110)
84 | self.assertEqual(result.fmt_duration, "1:50")
85 | self.assertIsNotNone(result.web_url)
86 | self.assertEqual(result.channel, "Clams Casino")
87 | self.assertFalse(result.live)
88 |
89 | result = results.get()
90 | self.assertEqual(len(results), 6)
91 | self.assertEqual(result.title, "Clams Casino - Water Theme 2")
92 | self.assertEqual(result.duration, 122)
93 | self.assertEqual(result.fmt_duration, "2:02")
94 | self.assertIsNotNone(result.web_url)
95 | self.assertEqual(result.channel, "Clams Casino")
96 | self.assertFalse(result.live)
97 |
98 | result = results.get()
99 | self.assertEqual(len(results), 5)
100 | self.assertEqual(result.title, "Clams Casino - Misty")
101 | self.assertEqual(result.duration, 144)
102 | self.assertEqual(result.fmt_duration, "2:24")
103 | self.assertIsNotNone(result.web_url)
104 | self.assertEqual(result.channel, "Clams Casino")
105 | self.assertFalse(result.live)
106 |
107 | result = results.get()
108 | self.assertEqual(len(results), 4)
109 | self.assertEqual(result.title, "Clams Casino - Tunnel Speed")
110 | self.assertEqual(result.duration, 138)
111 | self.assertEqual(result.fmt_duration, "2:18")
112 | self.assertIsNotNone(result.web_url)
113 | self.assertEqual(result.channel, "Clams Casino")
114 | self.assertFalse(result.live)
115 |
116 | result = results.get()
117 | self.assertEqual(len(results), 3)
118 | self.assertEqual(result.title, "Clams Casino - Pine")
119 | self.assertEqual(result.duration, 127)
120 | self.assertEqual(result.fmt_duration, "2:07")
121 | self.assertIsNotNone(result.web_url)
122 | self.assertEqual(result.channel, "Clams Casino")
123 | self.assertFalse(result.live)
124 |
125 | result = results.get()
126 | self.assertEqual(len(results), 2)
127 | self.assertEqual(result.title, "Clams Casino - Emblem")
128 | self.assertEqual(result.duration, 110)
129 | self.assertEqual(result.fmt_duration, "1:50")
130 | self.assertIsNotNone(result.web_url)
131 | self.assertEqual(result.channel, "Clams Casino")
132 | self.assertFalse(result.live)
133 |
134 | result = results.get()
135 | self.assertEqual(len(results), 1)
136 | self.assertEqual(result.title, "Clams Casino - Unknown")
137 | self.assertEqual(result.duration, 81)
138 | self.assertEqual(result.fmt_duration, "1:21")
139 | self.assertIsNotNone(result.web_url)
140 | self.assertEqual(result.channel, "Clams Casino")
141 | self.assertFalse(result.live)
142 |
143 | result = results.get()
144 | self.assertEqual(len(results), 0)
145 | self.assertEqual(result.title, "Clams Casino - Winter Flower")
146 | self.assertEqual(result.duration, 173)
147 | self.assertEqual(result.fmt_duration, "2:53")
148 | self.assertIsNotNone(result.web_url)
149 | self.assertEqual(result.channel, "Clams Casino")
150 | self.assertFalse(result.live)
151 |
--------------------------------------------------------------------------------
/tests/test_jukebot.py:
--------------------------------------------------------------------------------
1 | import unittest
2 |
3 | from jukebot import __version__
4 |
5 |
6 | class MisceleanousTest(unittest.TestCase):
7 | def test_version(self):
8 | assert __version__ == "0.1.0"
9 |
--------------------------------------------------------------------------------
/tests/test_music_request.py:
--------------------------------------------------------------------------------
1 | import unittest
2 |
3 | from jukebot.components import Result, ResultSet
4 | from jukebot.components.requests import MusicRequest
5 | from jukebot.utils.logging import disable_logging
6 |
7 |
8 | class TestMusicRequestComponent(unittest.IsolatedAsyncioTestCase):
9 | async def test_music_request_success_youtube_url(self):
10 | with disable_logging():
11 | async with MusicRequest("https://www.youtube.com/watch?v=8GW6sLrK40k") as req:
12 | await req.execute()
13 |
14 | self.assertTrue(req.success)
15 | self.assertIsNotNone(req.result)
16 | self.assertEqual(req.type, MusicRequest.ResultType.TRACK)
17 |
18 | result: dict = req.result
19 |
20 | self.assertEqual(result.get("title"), "HOME - Resonance")
21 | self.assertEqual(result.get("uploader"), "Electronic Gems")
22 | self.assertEqual(result.get("webpage_url"), "https://www.youtube.com/watch?v=8GW6sLrK40k")
23 | self.assertEqual(result.get("duration"), 213)
24 | self.assertEqual(result.get("live_status"), "not_live")
25 | self.assertIsNotNone(result.get("thumbnail"))
26 |
27 | async def test_music_request_success_youtube_live_url(self):
28 | with disable_logging():
29 | async with MusicRequest("https://www.youtube.com/watch?v=4xDzrJKXOOY") as req:
30 | await req.execute()
31 |
32 | self.assertTrue(req.success)
33 | self.assertIsNotNone(req.result)
34 | self.assertEqual(req.type, MusicRequest.ResultType.TRACK)
35 |
36 | result: dict = req.result
37 |
38 | self.assertIn("synthwave radio 🌌 beats to chill/game to", result.get("title"))
39 | self.assertEqual(result.get("uploader"), "Lofi Girl")
40 | self.assertEqual(result.get("webpage_url"), "https://www.youtube.com/watch?v=4xDzrJKXOOY")
41 | self.assertIsNone(result.get("duration"))
42 | self.assertEqual(result.get("live_status"), "is_live")
43 | self.assertIsNotNone(result.get("thumbnail"))
44 |
45 | async def test_music_request_success_soundcloud_url(self):
46 | with disable_logging():
47 | async with MusicRequest(
48 | "https://soundcloud.com/wstd7331/skychaser-1045-sky-fm-2nd-part-slowed-and-reverb"
49 | ) as req:
50 | await req.execute()
51 |
52 | self.assertTrue(req.success)
53 | self.assertIsNotNone(req.result)
54 | self.assertEqual(req.type, MusicRequest.ResultType.TRACK)
55 |
56 | result: dict = req.result
57 |
58 | self.assertEqual(result.get("title"), "Skychaser - 104.5 Sky FM (2nd Part, Slowed And Reverb)")
59 | self.assertEqual(result.get("uploader"), "[wstd7331]")
60 | self.assertEqual(
61 | result.get("webpage_url"),
62 | "https://soundcloud.com/wstd7331/skychaser-1045-sky-fm-2nd-part-slowed-and-reverb",
63 | )
64 | self.assertEqual(round(result.get("duration")), 201)
65 | self.assertIsNone(result.get("thumbnail"))
66 |
67 | async def test_music_request_playlist_youtube_url(self):
68 | with disable_logging():
69 | async with MusicRequest("https://www.youtube.com/playlist?list=PLjnOFoOKDEU9rzMtOaKGLABN7QhG19Nl0") as req:
70 | await req.execute()
71 |
72 | self.assertTrue(req.success)
73 | self.assertIsNotNone(req.result)
74 | self.assertEqual(req.type, MusicRequest.ResultType.PLAYLIST)
75 |
76 | results: list = req.result
77 |
78 | self.assertTrue(isinstance(results, list))
79 | self.assertEqual(len(results), 8)
80 |
81 | # test each result
82 | result = results.pop(0)
83 | self.assertEqual(len(results), 7)
84 | self.assertEqual(result.get("title"), "Clams Casino - Water Theme")
85 | self.assertEqual(result.get("duration"), 110)
86 | self.assertIsNotNone(result.get("url"))
87 | self.assertEqual(result.get("channel"), "Clams Casino")
88 |
89 | result = results.pop(0)
90 | self.assertEqual(len(results), 6)
91 | self.assertEqual(result.get("title"), "Clams Casino - Water Theme 2")
92 | self.assertEqual(result.get("duration"), 122)
93 | self.assertIsNotNone(result.get("url"))
94 | self.assertEqual(result.get("channel"), "Clams Casino")
95 |
96 | result = results.pop(0)
97 | self.assertEqual(len(results), 5)
98 | self.assertEqual(result.get("title"), "Clams Casino - Misty")
99 | self.assertEqual(result.get("duration"), 145)
100 | self.assertIsNotNone(result.get("url"))
101 | self.assertEqual(result.get("channel"), "Clams Casino")
102 |
103 | result = results.pop(0)
104 | self.assertEqual(len(results), 4)
105 | self.assertEqual(result.get("title"), "Clams Casino - Tunnel Speed")
106 | self.assertEqual(result.get("duration"), 138)
107 | self.assertIsNotNone(result.get("url"))
108 | self.assertEqual(result.get("channel"), "Clams Casino")
109 |
110 | result = results.pop(0)
111 | self.assertEqual(len(results), 3)
112 | self.assertEqual(result.get("title"), "Clams Casino - Pine")
113 | self.assertEqual(result.get("duration"), 127)
114 | self.assertIsNotNone(result.get("url"))
115 | self.assertEqual(result.get("channel"), "Clams Casino")
116 |
117 | result = results.pop(0)
118 | self.assertEqual(len(results), 2)
119 | self.assertEqual(result.get("title"), "Clams Casino - Emblem")
120 | self.assertEqual(result.get("duration"), 110)
121 | self.assertIsNotNone(result.get("url"))
122 | self.assertEqual(result.get("channel"), "Clams Casino")
123 |
124 | result = results.pop(0)
125 | self.assertEqual(len(results), 1)
126 | self.assertEqual(result.get("title"), "Clams Casino - Unknown")
127 | self.assertEqual(result.get("duration"), 81)
128 | self.assertIsNotNone(result.get("url"))
129 | self.assertEqual(result.get("channel"), "Clams Casino")
130 |
131 | result = results.pop(0)
132 | self.assertEqual(len(results), 0)
133 | self.assertEqual(result.get("title"), "Clams Casino - Winter Flower")
134 | self.assertEqual(result.get("duration"), 174)
135 | self.assertIsNotNone(result.get("url"))
136 | self.assertEqual(result.get("channel"), "Clams Casino")
137 |
138 | async def test_music_request_playlist_soundcloud_url(self):
139 | with disable_logging():
140 | async with MusicRequest("https://soundcloud.com/dysta/sets/vanished-ep-by-evryn/s-HkTM3QuDGiW") as req:
141 | await req.execute()
142 |
143 | self.assertTrue(req.success)
144 | self.assertIsNotNone(req.result)
145 | self.assertEqual(req.type, MusicRequest.ResultType.PLAYLIST)
146 |
147 | results: list = req.result
148 |
149 | self.assertTrue(isinstance(results, list))
150 | self.assertEqual(len(results), 9)
151 |
152 | # ? SoundCloud API doesn't return any title/channel or duration
153 | # ? only a stream link that will be used in StreamRequest
154 | # test each result
155 | result = results.pop(0)
156 | self.assertEqual(len(results), 8)
157 | self.assertIsNotNone(result.get("url"))
158 |
159 | # test each result
160 | result = results.pop(0)
161 | self.assertEqual(len(results), 7)
162 | self.assertIsNotNone(result.get("url"))
163 |
164 | # test each result
165 | result = results.pop(0)
166 | self.assertEqual(len(results), 6)
167 | self.assertIsNotNone(result.get("url"))
168 |
169 | # test each result
170 | result = results.pop(0)
171 | self.assertEqual(len(results), 5)
172 | self.assertIsNotNone(result.get("url"))
173 |
174 | # test each result
175 | result = results.pop(0)
176 | self.assertEqual(len(results), 4)
177 | self.assertIsNotNone(result.get("url"))
178 |
179 | # test each result
180 | result = results.pop(0)
181 | self.assertEqual(len(results), 3)
182 | self.assertIsNotNone(result.get("url"))
183 |
184 | # test each result
185 | result = results.pop(0)
186 | self.assertEqual(len(results), 2)
187 | self.assertIsNotNone(result.get("url"))
188 |
189 | # test each result
190 | result = results.pop(0)
191 | self.assertEqual(len(results), 1)
192 | self.assertIsNotNone(result.get("url"))
193 |
194 | # test each result
195 | result = results.pop(0)
196 | self.assertEqual(len(results), 0)
197 | self.assertIsNotNone(result.get("url"))
198 |
199 | async def test_music_request_success_youtube_query(self):
200 | with disable_logging():
201 | async with MusicRequest("Home - Resonance") as req:
202 | await req.execute()
203 |
204 | self.assertTrue(req.success)
205 | self.assertIsNotNone(req.result)
206 | self.assertEqual(req.type, MusicRequest.ResultType.TRACK)
207 |
208 | result: dict = req.result
209 |
210 | self.assertEqual(result.get("title"), "HOME - Resonance")
211 | self.assertEqual(result.get("channel"), "Electronic Gems")
212 | self.assertEqual(result.get("url"), "https://www.youtube.com/watch?v=8GW6sLrK40k")
213 | self.assertEqual(result.get("duration"), 213)
214 |
215 | async def test_music_request_failed_youtube_query(self):
216 | with disable_logging():
217 | async with MusicRequest("khfdkjshdfglmdsjfgtlkdjsfgkjshdfkgljhdskfjghkdljfhgkldsjfhg") as req:
218 | await req.execute()
219 |
220 | self.assertFalse(req.success)
221 | self.assertIsNone(req.result)
222 | self.assertEqual(req.type, MusicRequest.ResultType.UNKNOWN)
223 |
224 | async def test_music_request_failed_youtube_empty_query(self):
225 | with disable_logging():
226 | async with MusicRequest("") as req:
227 | await req.execute()
228 |
229 | self.assertFalse(req.success)
230 | self.assertIsNone(req.result)
231 | self.assertEqual(req.type, MusicRequest.ResultType.UNKNOWN)
232 |
233 | async def test_music_request_failed_youtube_invalid_url(self):
234 | with disable_logging():
235 | async with MusicRequest("https://www.youtube.com/watch?v=8GW7sLrK40k") as req:
236 | await req.execute()
237 |
238 | self.assertFalse(req.success)
239 | self.assertIsNone(req.result)
240 | self.assertEqual(req.type, MusicRequest.ResultType.UNKNOWN)
241 |
242 | async def test_music_request_failed_soundcloud_invalid_url(self):
243 | with disable_logging():
244 | async with MusicRequest("https://soundcloud.com/dysta/loopshit-plz-dont-plz-dont") as req:
245 | await req.execute()
246 |
247 | self.assertFalse(req.success)
248 | self.assertIsNone(req.result)
249 | self.assertEqual(req.type, MusicRequest.ResultType.UNKNOWN)
250 |
251 | async def test_music_request_youtube_url_convert_to_result(self):
252 | with disable_logging():
253 | async with MusicRequest("https://www.youtube.com/watch?v=8GW6sLrK40k") as req:
254 | await req.execute()
255 |
256 | self.assertTrue(req.success)
257 | self.assertIsNotNone(req.result)
258 | self.assertEqual(req.type, MusicRequest.ResultType.TRACK)
259 |
260 | result: Result = Result(req.result)
261 |
262 | self.assertEqual(result.title, "HOME - Resonance")
263 | self.assertEqual(result.channel, "Electronic Gems")
264 | self.assertEqual(result.web_url, "https://www.youtube.com/watch?v=8GW6sLrK40k")
265 | self.assertEqual(result.duration, 213)
266 | self.assertEqual(result.fmt_duration, "3:33")
267 | self.assertFalse(result.live)
268 | self.assertIsNone(result.requester)
269 |
270 | async def test_music_request_youtube_query_convert_to_result(self):
271 | with disable_logging():
272 | async with MusicRequest("Home - Resonance") as req:
273 | await req.execute()
274 |
275 | self.assertTrue(req.success)
276 | self.assertIsNotNone(req.result)
277 | self.assertEqual(req.type, MusicRequest.ResultType.TRACK)
278 |
279 | result: Result = Result(req.result)
280 |
281 | self.assertEqual(result.title, "HOME - Resonance")
282 | self.assertEqual(result.channel, "Electronic Gems")
283 | self.assertEqual(result.web_url, "https://www.youtube.com/watch?v=8GW6sLrK40k")
284 | self.assertEqual(result.duration, 213)
285 | self.assertEqual(result.fmt_duration, "3:33")
286 | self.assertFalse(result.live)
287 | self.assertIsNone(result.requester)
288 |
289 | async def test_music_request_success_soundcloud_url_convert_to_result(self):
290 | with disable_logging():
291 | async with MusicRequest(
292 | "https://soundcloud.com/wstd7331/skychaser-1045-sky-fm-2nd-part-slowed-and-reverb"
293 | ) as req:
294 | await req.execute()
295 |
296 | self.assertTrue(req.success)
297 | self.assertIsNotNone(req.result)
298 | self.assertEqual(req.type, MusicRequest.ResultType.TRACK)
299 |
300 | result: Result = Result(req.result)
301 |
302 | self.assertEqual(result.title, "Skychaser - 104.5 Sky FM (2nd Part, Slowed And Reverb)")
303 | self.assertEqual(result.channel, "[wstd7331]")
304 | self.assertEqual(
305 | result.web_url,
306 | "https://soundcloud.com/wstd7331/skychaser-1045-sky-fm-2nd-part-slowed-and-reverb",
307 | )
308 | self.assertEqual(result.duration, 201)
309 | self.assertEqual(result.fmt_duration, "3:21")
310 | self.assertFalse(result.live)
311 | self.assertIsNone(result.requester)
312 |
313 | async def test_music_request_youtube_live_url_convert_to_result(self):
314 | with disable_logging():
315 | async with MusicRequest("https://www.youtube.com/watch?v=4xDzrJKXOOY") as req:
316 | await req.execute()
317 |
318 | self.assertTrue(req.success)
319 | self.assertIsNotNone(req.result)
320 | self.assertEqual(req.type, MusicRequest.ResultType.TRACK)
321 |
322 | result: Result = Result(req.result)
323 |
324 | self.assertIn("synthwave radio 🌌 beats to chill/game to", result.title)
325 | self.assertEqual(result.channel, "Lofi Girl")
326 | self.assertEqual(result.web_url, "https://www.youtube.com/watch?v=4xDzrJKXOOY")
327 | self.assertEqual(result.duration, 0)
328 | self.assertEqual(result.fmt_duration, "ထ")
329 | self.assertTrue(result.live)
330 |
331 | async def test_music_request_playlist_youtube_url_convert_to_resultset(self):
332 | with disable_logging():
333 | async with MusicRequest("https://www.youtube.com/playlist?list=PLjnOFoOKDEU9rzMtOaKGLABN7QhG19Nl0") as req:
334 | await req.execute()
335 |
336 | self.assertTrue(req.success)
337 | self.assertIsNotNone(req.result)
338 | self.assertEqual(req.type, MusicRequest.ResultType.PLAYLIST)
339 | self.assertTrue(isinstance(req.result, list))
340 | self.assertEqual(len(req.result), 8)
341 |
342 | results: ResultSet = ResultSet.from_result(req.result)
343 | self.assertEqual(len(results), 8)
344 |
345 | # test each result
346 | result: Result = results.get()
347 | self.assertEqual(len(results), 7)
348 | self.assertEqual(result.title, "Clams Casino - Water Theme")
349 | self.assertEqual(result.channel, "Clams Casino")
350 | self.assertEqual(result.duration, 110)
351 | self.assertEqual(result.fmt_duration, "1:50")
352 | self.assertEqual(result.web_url, "https://www.youtube.com/watch?v=CCXHU3AjVPs")
353 | self.assertFalse(result.live)
354 |
355 | result = results.get()
356 | self.assertEqual(len(results), 6)
357 | self.assertEqual(result.title, "Clams Casino - Water Theme 2")
358 | self.assertEqual(result.channel, "Clams Casino")
359 | self.assertEqual(result.duration, 122)
360 | self.assertEqual(result.fmt_duration, "2:02")
361 | self.assertEqual(result.web_url, "https://www.youtube.com/watch?v=F-NH1cxEVz8")
362 | self.assertFalse(result.live)
363 |
364 | result = results.get()
365 | self.assertEqual(len(results), 5)
366 | self.assertEqual(result.title, "Clams Casino - Misty")
367 | self.assertEqual(result.channel, "Clams Casino")
368 | self.assertEqual(result.duration, 145)
369 | self.assertEqual(result.fmt_duration, "2:25")
370 | self.assertEqual(result.web_url, "https://www.youtube.com/watch?v=e2ho4sQm_sU")
371 | self.assertFalse(result.live)
372 |
373 | result = results.get()
374 | self.assertEqual(len(results), 4)
375 | self.assertEqual(result.title, "Clams Casino - Tunnel Speed")
376 | self.assertEqual(result.channel, "Clams Casino")
377 | self.assertEqual(result.duration, 138)
378 | self.assertEqual(result.fmt_duration, "2:18")
379 | self.assertEqual(result.web_url, "https://www.youtube.com/watch?v=x2-LawcTIVk")
380 | self.assertFalse(result.live)
381 |
382 | result = results.get()
383 | self.assertEqual(len(results), 3)
384 | self.assertEqual(result.title, "Clams Casino - Pine")
385 | self.assertEqual(result.channel, "Clams Casino")
386 | self.assertEqual(result.duration, 127)
387 | self.assertEqual(result.fmt_duration, "2:07")
388 | self.assertEqual(result.web_url, "https://www.youtube.com/watch?v=X1WLnqN4oPI")
389 | self.assertFalse(result.live)
390 |
391 | result = results.get()
392 | self.assertEqual(len(results), 2)
393 | self.assertEqual(result.title, "Clams Casino - Emblem")
394 | self.assertEqual(result.channel, "Clams Casino")
395 | self.assertEqual(result.duration, 110)
396 | self.assertEqual(result.fmt_duration, "1:50")
397 | self.assertEqual(result.web_url, "https://www.youtube.com/watch?v=41qlOmiRrLM")
398 | self.assertFalse(result.live)
399 |
400 | result = results.get()
401 | self.assertEqual(len(results), 1)
402 | self.assertEqual(result.title, "Clams Casino - Unknown")
403 | self.assertEqual(result.channel, "Clams Casino")
404 | self.assertEqual(result.duration, 81)
405 | self.assertEqual(result.fmt_duration, "1:21")
406 | self.assertEqual(result.web_url, "https://www.youtube.com/watch?v=4SEcDxD9QGg")
407 | self.assertFalse(result.live)
408 |
409 | result = results.get()
410 | self.assertEqual(len(results), 0)
411 | self.assertEqual(result.title, "Clams Casino - Winter Flower")
412 | self.assertEqual(result.channel, "Clams Casino")
413 | self.assertEqual(result.duration, 174)
414 | self.assertEqual(result.fmt_duration, "2:54")
415 | self.assertEqual(result.web_url, "https://www.youtube.com/watch?v=P45rOK-SlDY")
416 | self.assertFalse(result.live)
417 |
418 | async def test_music_request_playlist_soundcloud_url_convert_to_resultset(self):
419 | with disable_logging():
420 | async with MusicRequest("https://soundcloud.com/dysta/sets/vanished-ep-by-evryn/s-HkTM3QuDGiW") as req:
421 | await req.execute()
422 |
423 | self.assertTrue(req.success)
424 | self.assertIsNotNone(req.result)
425 | self.assertEqual(req.type, MusicRequest.ResultType.PLAYLIST)
426 | self.assertTrue(isinstance(req.result, list))
427 | self.assertEqual(len(req.result), 9)
428 |
429 | results: ResultSet = ResultSet.from_result(req.result)
430 | self.assertEqual(len(results), 9)
431 |
432 | # ? SoundCloud API doesn't return any title/channel or duration
433 | # ? only a stream link that will be used in StreamRequest
434 | # test each result
435 | result = results.get()
436 | self.assertEqual(len(results), 8)
437 | self.assertEqual(
438 | result.web_url,
439 | "https://soundcloud.com/dysta/evryn-no-more-bad-days/s-vTqs4UGlKkS",
440 | )
441 | self.assertEqual(result.title, "Evryn No More Bad Days")
442 | self.assertEqual(result.channel, "Dysta")
443 | self.assertEqual(result.duration, 0)
444 | self.assertEqual(result.fmt_duration, "ထ")
445 | self.assertTrue(result.live)
446 |
447 | # test each result
448 | result = results.get()
449 | self.assertEqual(len(results), 7)
450 | self.assertEqual(result.web_url, "https://soundcloud.com/dysta/evryn-can-you-see-me/s-HDLAfyVjqfb")
451 | self.assertEqual(result.title, "Evryn Can You See Me")
452 | self.assertEqual(result.channel, "Dysta")
453 | self.assertEqual(result.duration, 0)
454 | self.assertEqual(result.fmt_duration, "ထ")
455 | self.assertTrue(result.live)
456 |
457 | # test each result
458 | result = results.get()
459 | self.assertEqual(len(results), 6)
460 | self.assertEqual(result.web_url, "https://soundcloud.com/dysta/evryn-tears-in-the-rain/s-0b7fcsuBibx")
461 | self.assertEqual(result.title, "Evryn Tears In The Rain")
462 | self.assertEqual(result.channel, "Dysta")
463 | self.assertEqual(result.duration, 0)
464 | self.assertEqual(result.fmt_duration, "ထ")
465 | self.assertTrue(result.live)
466 |
467 | # test each result
468 | result = results.get()
469 | self.assertEqual(len(results), 5)
470 | self.assertEqual(result.web_url, "https://soundcloud.com/dysta/evryn-reset/s-hsygXrF0aId")
471 | self.assertEqual(result.title, "Evryn Reset")
472 | self.assertEqual(result.channel, "Dysta")
473 | self.assertEqual(result.duration, 0)
474 | self.assertEqual(result.fmt_duration, "ထ")
475 | self.assertTrue(result.live)
476 |
477 | # test each result
478 | result = results.get()
479 | self.assertEqual(len(results), 4)
480 | self.assertEqual(result.web_url, "https://soundcloud.com/dysta/evryn-colored-elements/s-9QNPeHetkTz")
481 | self.assertEqual(result.title, "Evryn Colored Elements")
482 | self.assertEqual(result.channel, "Dysta")
483 | self.assertEqual(result.duration, 0)
484 | self.assertEqual(result.fmt_duration, "ထ")
485 | self.assertTrue(result.live)
486 |
487 | # test each result
488 | result = results.get()
489 | self.assertEqual(len(results), 3)
490 | self.assertEqual(result.web_url, "https://soundcloud.com/dysta/evryn-everything-cyclic/s-A0HhrlySRy1")
491 | self.assertEqual(result.title, "Evryn Everything Cyclic")
492 | self.assertEqual(result.channel, "Dysta")
493 | self.assertEqual(result.duration, 0)
494 | self.assertEqual(result.fmt_duration, "ထ")
495 | self.assertTrue(result.live)
496 |
497 | # test each result
498 | result = results.get()
499 | self.assertEqual(len(results), 2)
500 | self.assertEqual(result.web_url, "https://soundcloud.com/dysta/evryn-love-you-forever/s-KL22UIGotke")
501 | self.assertEqual(result.title, "Evryn Love You Forever")
502 | self.assertEqual(result.channel, "Dysta")
503 | self.assertEqual(result.duration, 0)
504 | self.assertEqual(result.fmt_duration, "ထ")
505 | self.assertTrue(result.live)
506 |
507 | # test each result
508 | result = results.get()
509 | self.assertEqual(len(results), 1)
510 | self.assertEqual(result.web_url, "https://soundcloud.com/dysta/evryn-promising-future/s-DDl6TFrf919")
511 | self.assertEqual(result.title, "Evryn Promising Future")
512 | self.assertEqual(result.channel, "Dysta")
513 | self.assertEqual(result.duration, 0)
514 | self.assertEqual(result.fmt_duration, "ထ")
515 | self.assertTrue(result.live)
516 |
517 | # test each result
518 | result = results.get()
519 | self.assertEqual(len(results), 0)
520 | self.assertEqual(result.web_url, "https://soundcloud.com/dysta/evryn-ups-and-downs/s-rcgMdEnFnBz")
521 | self.assertEqual(result.title, "Evryn Ups And Downs")
522 | self.assertEqual(result.channel, "Dysta")
523 | self.assertEqual(result.duration, 0)
524 | self.assertEqual(result.fmt_duration, "ထ")
525 | self.assertTrue(result.live)
526 |
527 | async def test_music_request_success_soundcloud_shorted_url_convert_to_result(self):
528 | with disable_logging():
529 | async with MusicRequest("https://on.soundcloud.com/Gsdzc") as req:
530 | await req.execute()
531 |
532 | self.assertTrue(req.success)
533 | self.assertIsNotNone(req.result)
534 | self.assertEqual(req.type, MusicRequest.ResultType.TRACK)
535 |
536 | result: Result = Result(req.result)
537 |
538 | self.assertEqual(result.title, "Headband Andy Vito Bad Boy")
539 | self.assertEqual(result.channel, "Minecraft Pukaj 009 Sk")
540 | self.assertEqual(
541 | result.web_url,
542 | "https://soundcloud.com/minecraft-pukaj-009-sk/headband-andy-vito-bad-boy",
543 | )
544 | self.assertEqual(result.duration, 0)
545 | self.assertEqual(result.fmt_duration, "ထ")
546 | self.assertTrue(result.live)
547 | self.assertIsNone(result.requester)
548 |
549 | @unittest.skip("not working due to soundcloud private link update")
550 | async def test_music_request_success_soundcloud_shorted_private_url_convert_to_result(self):
551 | async with MusicRequest("https://on.soundcloud.com/gA4Ca") as req:
552 | await req.execute()
553 |
554 | self.assertTrue(req.success)
555 | self.assertIsNotNone(req.result)
556 | self.assertEqual(req.type, MusicRequest.ResultType.TRACK)
557 |
558 | result: Result = Result(req.result)
559 |
560 | self.assertEqual(result.title, "Empty")
561 | self.assertEqual(result.channel, "Dysta")
562 | self.assertEqual(
563 | result.web_url,
564 | "https://soundcloud.com/dysta/empty/s-wEHdWGqgDdf",
565 | )
566 | self.assertEqual(result.duration, 0)
567 | self.assertEqual(result.fmt_duration, "ထ")
568 | self.assertTrue(result.live)
569 | self.assertIsNone(result.requester)
570 |
--------------------------------------------------------------------------------
/tests/test_radios.py:
--------------------------------------------------------------------------------
1 | import unittest
2 |
3 | from jukebot.components.requests import MusicRequest
4 | from jukebot.utils import converter
5 | from jukebot.utils.logging import disable_logging
6 |
7 |
8 | class TestRadios(unittest.IsolatedAsyncioTestCase):
9 | @classmethod
10 | def setUpClass(cls):
11 | cls._radios: dict = converter.radios_yaml_to_dict()
12 |
13 | @unittest.skip("not working even if links are correct")
14 | async def test_radio_available(self):
15 | with disable_logging():
16 | for k, v in self._radios.items():
17 | for link in v:
18 | with self.subTest(f"Test link {link} for radio {k}"):
19 | async with MusicRequest(link) as req:
20 | await req.execute()
21 | self.assertTrue(req.success)
22 |
--------------------------------------------------------------------------------
/tests/test_search_request.py:
--------------------------------------------------------------------------------
1 | import unittest
2 |
3 | from jukebot.components import ResultSet
4 | from jukebot.components.requests import SearchRequest
5 | from jukebot.utils.logging import disable_logging
6 |
7 |
8 | class TestSearchRequestComponent(unittest.IsolatedAsyncioTestCase):
9 | async def test_search_request_url_song_youtube_raise_exception(self):
10 | with disable_logging():
11 | with self.assertRaises(ValueError):
12 | async with SearchRequest(
13 | "https://www.youtube.com/watch?v=YZ2WJ1krQss",
14 | SearchRequest.Engine.Youtube,
15 | ) as req:
16 | await req.execute()
17 |
18 | async def test_search_request_url_song_Soundcloud_raise_exception(self):
19 | with disable_logging():
20 | with self.assertRaises(ValueError):
21 | async with SearchRequest(
22 | "https://SoundCloud.com/gee_baller/playboi-carti-cult-classic",
23 | SearchRequest.Engine.SoundCloud,
24 | ) as req:
25 | await req.execute()
26 |
27 | async def test_search_request_url_playlist_youtube_raise_exception(self):
28 | with disable_logging():
29 | with self.assertRaises(ValueError):
30 | async with SearchRequest(
31 | "https://www.youtube.com/playlist?list=PLjnOFoOKDEU9rzMtOaKGLABN7QhG19Nl0",
32 | SearchRequest.Engine.Youtube,
33 | ) as req:
34 | await req.execute()
35 |
36 | async def test_search_request_url_playlist_Soundcloud_raise_exception(self):
37 | with disable_logging():
38 | with self.assertRaises(ValueError):
39 | async with SearchRequest(
40 | "https://SoundCloud.com/dysta/sets/breakcore", SearchRequest.Engine.SoundCloud
41 | ) as req:
42 | await req.execute()
43 |
44 | async def test_search_request_query_song_youtube_success_using_engine(self):
45 | with disable_logging():
46 | async with SearchRequest("Slowdive - sleep", SearchRequest.Engine.Youtube) as req:
47 | await req.execute()
48 |
49 | self.assertTrue(req.result)
50 | self.assertIsNotNone(req.result)
51 | self.assertIsInstance(req.result, list)
52 |
53 | result: list = req.result
54 |
55 | self.assertLessEqual(len(result), 10)
56 |
57 | async def test_search_request_query_song_youtube_success_convert_resultset_using_engine(self):
58 | with disable_logging():
59 | async with SearchRequest("Slowdive - sleep", SearchRequest.Engine.Youtube) as req:
60 | await req.execute()
61 |
62 | self.assertTrue(req.result)
63 | self.assertIsNotNone(req.result)
64 | self.assertIsInstance(req.result, list)
65 |
66 | result: list = req.result
67 |
68 | self.assertLessEqual(len(result), 10)
69 |
70 | set = ResultSet.from_result(result)
71 |
72 | self.assertLessEqual(len(set), 10)
73 |
74 | async def test_search_request_query_song_Soundcloud_success_using_engine(self):
75 | with disable_logging():
76 | async with SearchRequest("Slowdive - sleep", SearchRequest.Engine.SoundCloud) as req:
77 | await req.execute()
78 |
79 | self.assertTrue(req.result)
80 | self.assertIsNotNone(req.result)
81 | self.assertIsInstance(req.result, list)
82 |
83 | result: list = req.result
84 |
85 | self.assertLessEqual(len(result), 10)
86 |
87 | async def test_search_request_query_song_Soundcloud_convert_resultset_using_engine(self):
88 | with disable_logging():
89 | async with SearchRequest("Slowdive - sleep", SearchRequest.Engine.SoundCloud) as req:
90 | await req.execute()
91 |
92 | self.assertTrue(req.result)
93 | self.assertIsNotNone(req.result)
94 | self.assertIsInstance(req.result, list)
95 |
96 | result: list = req.result
97 |
98 | self.assertLessEqual(len(result), 10)
99 |
100 | set = ResultSet.from_result(result)
101 |
102 | self.assertLessEqual(len(set), 10)
103 |
104 | async def test_search_request_query_song_youtube_success_using_strengine(self):
105 | with disable_logging():
106 | async with SearchRequest("Slowdive - sleep", "ytsearch10:") as req:
107 | await req.execute()
108 |
109 | self.assertTrue(req.result)
110 | self.assertIsNotNone(req.result)
111 | self.assertIsInstance(req.result, list)
112 |
113 | result: list = req.result
114 |
115 | self.assertLessEqual(len(result), 10)
116 |
117 | async def test_search_request_query_song_youtube_convert_resultset_using_strengine(self):
118 | with disable_logging():
119 | async with SearchRequest("Slowdive - sleep", "ytsearch10:") as req:
120 | await req.execute()
121 |
122 | self.assertTrue(req.result)
123 | self.assertIsNotNone(req.result)
124 | self.assertIsInstance(req.result, list)
125 |
126 | result: list = req.result
127 |
128 | self.assertLessEqual(len(result), 10)
129 |
130 | set = ResultSet.from_result(result)
131 |
132 | self.assertLessEqual(len(set), 10)
133 |
134 | async def test_search_request_query_song_Soundcloud_success_using_strengine(self):
135 | with disable_logging():
136 | async with SearchRequest("Slowdive - sleep", "scsearch10:") as req:
137 | await req.execute()
138 |
139 | self.assertTrue(req.result)
140 | self.assertIsNotNone(req.result)
141 | self.assertIsInstance(req.result, list)
142 |
143 | result: list = req.result
144 |
145 | self.assertLessEqual(len(result), 10)
146 |
147 | async def test_search_request_query_song_Soundcloud_convert_resultset_using_strengine(self):
148 | with disable_logging():
149 | async with SearchRequest("Slowdive - sleep", "scsearch10:") as req:
150 | await req.execute()
151 |
152 | self.assertTrue(req.result)
153 | self.assertIsNotNone(req.result)
154 | self.assertIsInstance(req.result, list)
155 |
156 | result: list = req.result
157 |
158 | self.assertLessEqual(len(result), 10)
159 |
160 | set = ResultSet.from_result(result)
161 |
162 | self.assertLessEqual(len(set), 10)
163 |
--------------------------------------------------------------------------------
/tests/test_shazam_request.py:
--------------------------------------------------------------------------------
1 | import unittest
2 |
3 | from jukebot.components.requests import ShazamRequest
4 | from jukebot.utils.logging import disable_logging
5 |
6 |
7 | class TestShazamRequestComponent(unittest.IsolatedAsyncioTestCase):
8 | @unittest.skip("not working on CI due to ffprobe not found")
9 | async def test_shazam_request_success(self):
10 | with disable_logging():
11 | async with ShazamRequest("https://twitter.com/LaCienegaBlvdss/status/1501975048202166283") as req:
12 | await req.execute()
13 |
14 | self.assertTrue(req.success)
15 | result: dict = req.result
16 |
17 | self.assertEqual(result.get("title"), "Enya - The Humming (Official Lyric Video)")
18 | self.assertEqual(result.get("url"), "https://youtu.be/FOP_PPavoLA?autoplay=1")
19 | self.assertEqual(result.get("image_url"), "https://i.ytimg.com/vi/FOP_PPavoLA/maxresdefault.jpg")
20 |
21 | @unittest.skip("not working on CI due to ffprobe not found")
22 | async def test_shazam_request_failed(self):
23 | with disable_logging():
24 | async with ShazamRequest("https://www.instagram.com/p/Cqk4Vh0MVYo/") as req:
25 | await req.execute()
26 |
27 | self.assertFalse(req.success)
28 | self.assertIsNone(req.result)
29 |
--------------------------------------------------------------------------------
/tests/test_stream_request.py:
--------------------------------------------------------------------------------
1 | import unittest
2 |
3 | from jukebot.components import Song
4 | from jukebot.components.requests import StreamRequest
5 | from jukebot.utils.logging import disable_logging
6 |
7 |
8 | class TestStreamRequestComponent(unittest.IsolatedAsyncioTestCase):
9 | async def test_stream_request_success_youtube(self):
10 | with disable_logging():
11 | async with StreamRequest("https://www.youtube.com/watch?v=8GW6sLrK40k") as req:
12 | await req.execute()
13 |
14 | self.assertTrue(req.success)
15 | self.assertIsNotNone(req.result)
16 | self.assertIsInstance(req.result, dict)
17 |
18 | result: dict = req.result
19 |
20 | self.assertEqual(result.get("title"), "HOME - Resonance")
21 | self.assertEqual(result.get("uploader"), "Electronic Gems")
22 | self.assertEqual(result.get("webpage_url"), "https://www.youtube.com/watch?v=8GW6sLrK40k")
23 | self.assertEqual(result.get("duration"), 213)
24 |
25 | self.assertIsNotNone(result.get("thumbnail", None))
26 | self.assertIsNotNone(result.get("url", None))
27 |
28 | self.assertFalse(result.get("is_live"))
29 |
30 | async def test_stream_request_success_soundcloud(self):
31 | with disable_logging():
32 | async with StreamRequest(
33 | "https://soundcloud.com/wstd7331/skychaser-1045-sky-fm-2nd-part-slowed-and-reverb"
34 | ) as req:
35 | await req.execute()
36 |
37 | self.assertTrue(req.success)
38 | self.assertIsNotNone(req.result)
39 | self.assertIsInstance(req.result, dict)
40 |
41 | result: dict = req.result
42 |
43 | self.assertEqual(result.get("title"), "Skychaser - 104.5 Sky FM (2nd Part, Slowed And Reverb)")
44 | self.assertEqual(result.get("uploader"), "[wstd7331]")
45 | self.assertEqual(
46 | result.get("webpage_url"),
47 | "https://soundcloud.com/wstd7331/skychaser-1045-sky-fm-2nd-part-slowed-and-reverb",
48 | )
49 | self.assertEqual(round(result.get("duration")), 201)
50 |
51 | self.assertIsNotNone(result.get("thumbnail", None))
52 | self.assertIsNotNone(result.get("url", None))
53 |
54 | async def test_stream_request_playlist_youtube(self):
55 | with disable_logging():
56 | async with StreamRequest("https://www.youtube.com/playlist?list=PLjnOFoOKDEU9rzMtOaKGLABN7QhG19Nl0") as req:
57 | await req.execute()
58 | self.assertFalse(req.success)
59 | self.assertIsNone(req.result)
60 |
61 | async def test_stream_request_playlist_soundcloud(self):
62 | with disable_logging():
63 | async with StreamRequest("https://soundcloud.com/dysta/sets/breakcore") as req:
64 | await req.execute()
65 |
66 | self.assertFalse(req.success)
67 | self.assertIsNone(req.result)
68 |
69 | async def test_stream_request_success_youtube_query(self):
70 | with disable_logging():
71 | async with StreamRequest("home resonance") as req:
72 | await req.execute()
73 |
74 | self.assertFalse(req.success)
75 | self.assertIsNone(req.result)
76 |
77 | async def test_stream_request_success_youtube_convert_to_song(self):
78 | with disable_logging():
79 | async with StreamRequest("https://www.youtube.com/watch?v=8GW6sLrK40k") as req:
80 | await req.execute()
81 |
82 | self.assertTrue(req.success)
83 | self.assertIsNotNone(req.result)
84 | self.assertIsInstance(req.result, dict)
85 |
86 | result: Song = Song(req.result)
87 |
88 | self.assertEqual(result.title, "HOME - Resonance")
89 | self.assertEqual(result.channel, "Electronic Gems")
90 | self.assertEqual(result.web_url, "https://www.youtube.com/watch?v=8GW6sLrK40k")
91 | self.assertEqual(result.duration, 213)
92 | self.assertEqual(result.fmt_duration, "3:33")
93 |
94 | self.assertIsNotNone(result.thumbnail)
95 | self.assertIsNotNone(result.stream_url)
96 |
97 | self.assertFalse(result.live)
98 |
99 | async def test_stream_request_success_youtube_live_convert_to_song(self):
100 | with disable_logging():
101 | async with StreamRequest("https://www.youtube.com/watch?v=4xDzrJKXOOY") as req:
102 | await req.execute()
103 |
104 | self.assertTrue(req.success)
105 | self.assertIsNotNone(req.result)
106 | self.assertIsInstance(req.result, dict)
107 |
108 | result: Song = Song(req.result)
109 |
110 | self.assertIn("synthwave radio 🌌 beats to chill/game to", result.title)
111 | self.assertEqual(result.channel, "Lofi Girl")
112 | self.assertEqual(result.web_url, "https://www.youtube.com/watch?v=4xDzrJKXOOY")
113 | self.assertEqual(result.duration, 0)
114 | self.assertEqual(result.fmt_duration, "ထ")
115 |
116 | self.assertIsNotNone(result.thumbnail)
117 | self.assertIsNotNone(result.stream_url)
118 |
119 | self.assertTrue(result.live)
120 |
121 | async def test_stream_request_success_soundcloud_convert_to_song(self):
122 | with disable_logging():
123 | async with StreamRequest(
124 | "https://soundcloud.com/wstd7331/skychaser-1045-sky-fm-2nd-part-slowed-and-reverb"
125 | ) as req:
126 | await req.execute()
127 |
128 | self.assertTrue(req.success)
129 | self.assertIsNotNone(req.result)
130 | self.assertIsInstance(req.result, dict)
131 |
132 | result: Song = Song(req.result)
133 |
134 | self.assertEqual(result.title, "Skychaser - 104.5 Sky FM (2nd Part, Slowed And Reverb)")
135 | self.assertEqual(result.channel, "[wstd7331]")
136 | self.assertEqual(
137 | result.web_url,
138 | "https://soundcloud.com/wstd7331/skychaser-1045-sky-fm-2nd-part-slowed-and-reverb",
139 | )
140 | self.assertEqual(result.duration, 201)
141 | self.assertEqual(result.fmt_duration, "3:21")
142 |
143 | self.assertIsNotNone(result.thumbnail)
144 | self.assertIsNotNone(result.stream_url)
145 |
--------------------------------------------------------------------------------