├── .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 | [![juke-banner](https://cdn.discordapp.com/attachments/829356508696412231/948936347752747038/juke-banner.png)](#readme) 4 |
5 | 6 | # JukeBot 7 | [![Powered by Disnake](https://custom-icon-badges.herokuapp.com/badge/-Powered%20by%20Disnake-0d1620?logo=nextcord)](https://github.com/DisnakeDev/disnake "Powered by Disnake") 8 | [![Powered by Poetry](https://custom-icon-badges.herokuapp.com/badge/-Powered%20by%20Poetry-0d1620?logo=poetry)](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 | --------------------------------------------------------------------------------