diff --git a/.dive-ci b/.dive-ci new file mode 100644 index 000000000..2cdb78474 --- /dev/null +++ b/.dive-ci @@ -0,0 +1,4 @@ +rules: + lowestEfficiency: 0.97 # ratio between 0-1 + highestWastedBytes: 20MB # B, KB, MB, and GB + highestUserWastedPercent: 0.20 # ratio between 0-1 diff --git a/.dockerignore b/.dockerignore index 3af66447e..af8cd8e1c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,12 @@ +# ETC +.dive-ci +.editorconfig +.env +.hadolint.yaml +**/*.example +**/*.md +LICENSE + # Runtime audio_cache/ bin/ @@ -8,13 +17,16 @@ musicbot/lib/__pycache__/ # Docker .dockerignore +docker-compose.example.yml +docker-compose.yml Dockerfile # Git .git/ .gitattributes +.github/ .gitignore # IDE .idea/ -.vscode/ \ No newline at end of file +.vscode/ diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..5c9ab2cb8 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +COMPOSE_FILE=docker-compose.yml +COMPOSE_REMOVE_ORPHANS=true +# * Options: linux/amd64 / linux/arm64/v8 +PLATFORM=linux/arm64/v8 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 000000000..0eabbc4d7 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,102 @@ +name: CI + +on: + push: + branches: + - 'main' + - 'master' + tags: + - '*.*.*' + paths: + - 'Dockerfile*' + - 'pyproject.toml' + - 'poetry.lock' + - 'requirements.txt' + - '**.py' + - '**.sh' + - '.dockerignore' + - '.env.example' + - '.github/workflows/**' + workflow_dispatch: + +env: + REGISTRY_URL: ${{ vars.REGISTRY_URL }} + REGISTRY_USER: ${{ vars.REGISTRY_USER }} + +jobs: + build: + name: Build and push Docker image + runs-on: ubuntu-latest + strategy: + fail-fast: true + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set password by container registry + run: | + case "${{ env.REGISTRY_URL }}" in + "ghcr.io") + echo "REGISTRY_PASS=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV + ;; + *) + if [ -n "${{ secrets.REGISTRY_PASS }}" ]; then + echo "REGISTRY_PASS=${{ secrets.REGISTRY_PASS }}" >> $GITHUB_ENV + else + echo "REGISTRY_PASS secret is not set and registry is not recognized. Exiting..." + exit 1 + fi + ;; + esac + + - name: Log into container registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY_URL }} + username: ${{ env.REGISTRY_USER }} + password: ${{ env.REGISTRY_PASS }} + + - name: Set image name + id: image_name + run: | + if [ -n "${{ env.IMAGE }}" ]; then + IMAGE="${{ env.IMAGE }}" + else + IMAGE=$(grep "LABEL org.opencontainers.image.title" Dockerfile | cut -d'"' -f2) + fi + echo "IMAGE=$IMAGE" >> $GITHUB_OUTPUT + echo "IMAGE=$IMAGE" >> $GITHUB_ENV + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ${{ env.REGISTRY_URL }}/${{ env.REGISTRY_USER }}/${{ steps.image_name.outputs.IMAGE }} + tags: | + type=schedule + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha + type=raw,value=latest,enable={{is_default_branch}} + + - name: Setup QEMU + uses: docker/setup-qemu-action@v3 + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64/v8 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=${{ env.REGISTRY_URL }}/${{ env.REGISTRY_USER }}/${{ steps.image_name.outputs.IMAGE }}:buildcache + cache-to: type=registry,ref=${{ env.REGISTRY_URL }}/${{ env.REGISTRY_USER }}/${{ steps.image_name.outputs.IMAGE }}:buildcache,mode=max diff --git a/.gitignore b/.gitignore index ec0145e5f..02d8bb91d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,24 @@ +# editor settings .idea/ +.vscode/ + +# python bytecode *.pyc + +# temp files ~*/ -.vscode/ -*.service +# directories audio_cache/ dectalk/ - -discord.log logs/ data/ +media/ + +# logs +discord.log + +# configs config/options.ini config/permissions.ini config/aliases.json @@ -21,6 +30,10 @@ config/blacklist.txt config/blocklist_users.txt config/blocklist_songs.txt config/playlists/ -media/ +config/autoplaylist.cachemap.json +# docker docker-compose.yml + +# inclusions (has to be declared last) +!**/*.example diff --git a/.hadolint.yaml b/.hadolint.yaml new file mode 100644 index 000000000..44549a02a --- /dev/null +++ b/.hadolint.yaml @@ -0,0 +1,12 @@ +failure-threshold: info # error|warning|info|style|ignore|none + +ignored: + - DL3008 # pin versions in apt + - DL3013 # pin versions in pip + - DL3018 # pin versions in apk + - DL3042 # pip --no-cache-dir + +trustedRegistries: + - docker.io + - "*.gcr.io" + - localhost:32000 diff --git a/Dockerfile b/Dockerfile index d2e022da4..286f059d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,6 @@ -FROM python:3.8-alpine +# syntax=docker/dockerfile:1.7.0 + +FROM python:3.8-alpine3.20 # Add project source WORKDIR /musicbot @@ -7,22 +9,33 @@ COPY ./config sample_config # Install build dependencies RUN apk update && apk add --no-cache --virtual .build-deps \ - build-base \ - libffi-dev \ - libsodium-dev + build-base \ + libffi-dev \ + libsodium-dev \ + && rm -rf /var/cache/apk/* # Install dependencies RUN apk update && apk add --no-cache \ - ca-certificates \ - ffmpeg \ - opus-dev \ - libffi \ - libsodium \ - gcc \ - git + ca-certificates \ + ffmpeg \ + gcc \ + git \ + libffi \ + libsodium \ + opus-dev \ + && rm -rf /var/cache/apk/* + +# pip env vars +ENV PIP_NO_CACHE_DIR=off +ENV PIP_DISABLE_PIP_VERSION_CHECK=on +ENV PIP_DEFAULT_TIMEOUT=100 + +# don't generate .pyc, enable tracebacks on seg faults +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONFAULTHANDLER=1 # Install pip dependencies -RUN pip3 install --no-cache-dir -r requirements.txt +RUN python -m pip install --no-cache-dir -r requirements.txt # Clean up build dependencies RUN apk del .build-deps @@ -33,3 +46,5 @@ VOLUME ["/musicbot/audio_cache", "/musicbot/config", "/musicbot/data", "/musicbo ENV APP_ENV=docker ENTRYPOINT ["/bin/sh", "docker-entrypoint.sh"] + +LABEL org.opencontainers.image.title="musicbot" diff --git a/config/example_options.ini b/config/example_options.ini index 147de3d0f..dbecd87eb 100644 --- a/config/example_options.ini +++ b/config/example_options.ini @@ -12,15 +12,6 @@ Token = bot_token Spotify_ClientID = Spotify_ClientSecret = -# Sets the YouTube API Client ID, used by Yt-dlp OAuth2 plugin. -# Optional, unless built-in credentials are not working. -YtdlpOAuth2ClientID = - -# Sets the YouTube API Client Secret key, used by Yt-dlp OAuth2 plugin. -# Optional, unless YtdlpOAuth2ClientID is set. -YtdlpOAuth2ClientSecret = - - [Permissions] # This option determines which user has full permissions and control of the bot. # Only one user can be the bot's owner. You can generally leave this as "auto". @@ -141,10 +132,6 @@ DeleteInvoking = no # resume from where it left off. PersistentQueue = yes -# Enable MusicBot to download the next song in the queue while a song is playing. -# Currently this option does not apply to auto-playlist or songs added to an empty queue. -PreDownloadNextSong = yes - # Determines what messages are logged to the console. The default level is INFO, which is # everything an average user would need. Other levels include CRITICAL, ERROR, WARNING, # DEBUG, VOICEDEBUG, FFMPEG, NOISY, and EVERYTHING. You should only change this if you @@ -165,9 +152,6 @@ DebugLevel = INFO # {p0_url} = The track url for the currently playing track. StatusMessage = -# If enabled, status message updates will count and report paused players. -StatusIncludePaused = no - # Write what the bot is currently playing to the data//current.txt FILE. # This can then be used with OBS and anything else that takes a dynamic input. WriteCurrentSong = no @@ -268,34 +252,6 @@ SavePlayedHistoryGuilds = no # to play files from the local MediaFileDirectory path. EnableLocalMedia = no -# Allow MusicBot to automatically unpause when play commands are used. -UnpausePlayerOnPlay = no - -# Experimental, HTTP/HTTPS proxy settings to use with ytdlp media downloader. -# The value set here is passed to `ytdlp --proxy` and aiohttp header checking. -# Leave blank to disable. -YtdlpProxy = - -# Experimental option to set a static User-Agent header in yt-dlp. -# It is not typically recommended by yt-dlp to change the UA string. -# For examples of what you might put here, check the following two links: -# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent -# https://www.useragents.me/ -# Leave blank to use default, dynamically generated UA strings. -YtdlpUserAgent = - -# Experimental option to enable yt-dlp to use a YouTube account via OAuth2. -# When enabled, you must use the generated URL and code to authorize an account. -# The authorization token is then stored in the `data/auth.token` file. -# This option should not be used when cookies are enabled. -# Using a personal account may not be recommended. -YtdlpUseOAuth2 = no - -# Optional youtube URL used at start-up for triggering OAuth2 authorization. -# This starts the OAuth2 prompt early, rather than waiting for a song request. -# Authorization must be completed before start-up will continue when this is set. -YtdlpOAuth2URL = - [Files] # Configure automatic log file rotation at restart, and limit the number of files kept. diff --git a/config/example_permissions.ini b/config/example_permissions.ini index 9aa404beb..a7332571c 100644 --- a/config/example_permissions.ini +++ b/config/example_permissions.ini @@ -73,18 +73,14 @@ ; that the bot is already joined in the server. It is also expected that the user have ability to invoke summon command to ; use this option. ; -; Extractors = spotify:musicbot youtube generic soundcloud Bandcamp +; Extractors = spotify:musicbot youtube youtube:playlist youtube:tab youtube:search ; Specify yt-dlp extractor names that MusicBot will allow users to play media from. -; Each extractor name should be separated by spaces or commas. -; If left empty, hard-coded defaults will be allowed. -; The yt-dlp project has a list of supported services / extractor names here: +; Each extractor name should be separated by spaces. +; If left empty, all services will be allowed. Including porn services. +; The yt-dlp project has a list of supported services here: ; https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md ; -; The extractor `spotify:musicbot` is provided by MusicBot, not by yt-dlp. -; To allow ALL services, including porn services, add "__" to the list, without quotes. -; Example to allow all: -; -; Extractors = __ +; The extractor `spotify:musicbot` is provided by MusicBot, not yt-dlp. ; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -134,7 +130,7 @@ SkipWhenAbsent = no BypassKaraokeMode = no SummonNoVoice = no SkipLooped = no -Extractors = generic youtube spotify:musicbot Bandcamp soundcloud +Extractors = generic youtube youtube:playlist youtube:tab youtube:search spotify:musicbot ; This group has full permissions. [MusicMaster] diff --git a/config/i18n/en.json b/config/i18n/en.json index 526bc3d8d..52fb7ab39 100644 --- a/config/i18n/en.json +++ b/config/i18n/en.json @@ -14,15 +14,12 @@ "cmd-save-exists": "This song is already in the autoplaylist.", "cmd-save-invalid": "There is no valid song playing.", "cmd-save-success": "Added <{0}> to the autoplaylist.", - "cmd-save-success-multiple": "Added {0} songs to the autoplaylist.", "cmd-unsave-does-not-exist": "This song is not yet in the autoplaylist.", "cmd-unsave-success": "Removed <{0}> from the autoplaylist.", "cmd-autoplaylist-does-not-exist": "This song is not yet in the autoplaylist.", "cmd-autoplaylist-invalid": "The supplied song link is invalid.", "cmd-autoplaylist-option-invalid": "Invalid option \"{0}\" specified, use +, -, add, or remove", "cmd-autoplaylist-success": "Removed <{0}> from the autoplaylist.", - "cmd-autoplaylist-add-all-empty-queue": "The queue is empty. Add some songs with `{0}play`!", - "cmd-save-all-exist": "All songs in the queue are already in the autoplaylist.", "cmd-joinserver-response": "Click here to add me to a server: \n{}", "cmd-play-spotify-album-process": "Processing album `{0}` (`{1}`)", "cmd-play-spotify-album-queued": "Enqueued `{0}` with **{1}** songs.", @@ -84,7 +81,7 @@ "cmd-resume-reply": "Resumed music in `{0.name}`", "cmd-resume-none": "Player is not paused.", "cmd-shuffle-reply": "Shuffled `{0}`'s queue.", - "cmd-clear-reply": "Cleared `{0}'s` queue", + "cmd-clear-reply": "Cleared `{0}`'s queue", "cmd-remove-none": "There's nothing to remove!", "cmd-remove-reply": "Removed `{0}` added by `{1}`", "cmd-remove-missing": "Nothing found in the queue from user `%s`", diff --git a/docker-compose.example.yml b/docker-compose.example.yml index b957eb17d..657a0e84f 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -1,8 +1,23 @@ services: musicbot: - build: . + container_name: musicbot + build: + context: . + dockerfile: Dockerfile + hostname: musicbot + platform: ${PLATFORM:-linux/amd64} + environment: + - APP_ENV=docker volumes: - - ./config:/musicbot/config/ + - ./config:/musicbot/config - ./audio_cache:/musicbot/audio_cache - ./data:/musicbot/data - ./logs:/musicbot/logs + networks: + - musicbot + working_dir: /musicbot + +networks: + musicbot: + name: musicbot + driver: bridge diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 0a308e40b..b6e7488b0 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,7 +1,7 @@ -#!/bin/sh +#!/usr/bin/env sh -if [[ ! -f "/musicbot/config/example_options.ini" ]]; then +if [ ! -f "/musicbot/config/example_options.ini" ]; then cp -r /musicbot/sample_config/* /musicbot/config fi -exec python3 run.py $@ +exec python3 run.py "$@" diff --git a/install.bat b/install.bat index d78d89529..3a3dfc647 100644 --- a/install.bat +++ b/install.bat @@ -9,7 +9,7 @@ CD "%~dp0" SET InstFile="%~dp0%\install.ps1" IF exist %InstFile% ( - powershell.exe -noprofile -executionpolicy bypass -file "%InstFile%" %* + powershell.exe -noprofile -executionpolicy bypass -file "%InstFile%" ) ELSE ( echo Could not locate install.ps1 echo Please ensure it is available to continue the automatic install. diff --git a/install.ps1 b/install.ps1 index ec8b76bf0..475fc7e5a 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,6 +1,6 @@ # This script is designed to be used without pulling the repository first! # You can simply download and run it to have MusicBot installed for you. -# Currently the script only supports one installation per user account. +# Current the script only supports one installation per user account. # # Notice: # If you want to run this .ps1 script without setting execution policy in PowerShell, @@ -8,17 +8,6 @@ # # powershell.exe -noprofile -executionpolicy bypass -file install.ps1 # -# Last tested: -# Win 10 Home 22H2 x64 - 2024/09/26 -# --------------------------------------------------CLI Parameters----------------------------------------------------- -param ( - # -anybranch Enables the use of any named branch, if it exists on repo. - [switch]$anybranch = $false -) -# Where to put MusicBot by default. Updated by repo detection. -# prolly should be param, but someone who cares about windows can code for it. -$Install_Dir = (pwd).Path + '\MusicBot\' - # ---------------------------------------------Install notice and prompt----------------------------------------------- "MusicBot Installer" "" @@ -45,71 +34,23 @@ if($iagree -ne "Y" -and $iagree -ne "y") Return } -# First, unhide file extensions... -$FERegPath = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' -$HideExt = (Get-ItemProperty -Path $FERegPath -Name "HideFileExt").HideFileExt -if ($HideExt -eq 1) { - "" - "Microsoft hates you and hides file extensions by default." - "We're going to un-hide them to make things less confusing." - Set-ItemProperty -Name "HideFileExt" -Value 0 -Path $FERegPath -Force -} - -# If no winget, try to download and install. if (-Not (Get-Command winget -ErrorAction SilentlyContinue) ) { "" - "Microsoft WinGet tool is required to continue installing." - "It will be downloaded from:" + "Sorry, you must install WinGet to use this installer." + "Supposedly included with Windows, but we couldn't find it." + "You can get it via Microsoft Store, the Official repo on github, or " + "use the following link to quickly download an installer for it:" " https://aka.ms/getwinget " "" - "Please complete the Windows installer when prompted." - "" - - # download and run the installer. - $ProgressPreference = 'SilentlyContinue' - Invoke-WebRequest -Uri "https://aka.ms/getwinget" -OutFile "winget.msixbundle" - $ProgressPreference = 'Continue' - Start-Process "winget.msixbundle" - - # wait for user to finish installing winget... - $ready = Read-Host "Is WinGet installed and ready to continue? [y/n]" - if ($ready -ne "Y" -and $ready -ne "y") { - # exit if not ready. - Return - } - - # check if winget is available post-install. - if (-Not (Get-Command winget -ErrorAction SilentlyContinue) ) { - "WinGet is not available. Installer cannot continue." - Return - } + Return } -# -"" -"Checking WinGet can be used..." -"If prompted, you must agree to the MS terms to continue installing." -"" -winget list -q Git.Git -"" - -# since windows is silly with certificates and certifi may not always work, -# we queitly spawn some requests that -may- populate the certificate store. -# this isn't a sustainable approach, but it seems to work... -$ProgressPreference = 'SilentlyContinue' -Invoke-WebRequest -Uri "https://discord.com" -OutFile "cert.fetch" 2>&1 | Out-Null -Invoke-WebRequest -Uri "https://spotify.com" -OutFile "cert.fetch" 2>&1 | Out-Null -$ProgressPreference = 'Continue' -Remove-Item "cert.fetch" - # -----------------------------------------------------CONSTANTS------------------------------------------------------- $DEFAULT_URL_BASE = "https://discordapp.com/api" -$MB_RepoURL = "https://github.com/Just-Some-Bots/MusicBot.git" # ----------------------------------------------INSTALLING DEPENDENCIES------------------------------------------------ -$NeedsEnvReload = 0 # Check if git is installed "Checking if git is already installed..." @@ -119,7 +60,6 @@ if (!($LastExitCode -eq 0)) # install git "Installing git..." Invoke-Expression "winget install Git.Git" - $NeedsEnvReload = 1 "Done." } else @@ -129,31 +69,29 @@ else "" # Check if Any python 3 is installed -"Checking if python 3 is already installed..." +"Checking if python is already installed..." Invoke-Expression "winget list -q Python.Python.3" | Out-Null if (!($LastExitCode -eq 0)) { # install python version 3.11 with the py.exe launcher. "Installing python..." Invoke-Expression "winget install Python.Python.3.11 --custom \`"/passive Include_launcher=1\`"" - $NeedsEnvReload = 1 "Done." } else { - "Python 3 already installed." + "Python already installed." } "" # Check if ffmpeg is installed "Checking if FFmpeg is already installed..." -Invoke-Expression "winget list -q ffmpeg" | Out-Null +Invoke-Expression "winget list -q Gyan.FFmpeg" | Out-Null if (!($LastExitCode -eq 0)) { # install FFmpeg "Installing FFmpeg..." - Invoke-Expression "winget install ffmpeg" - $NeedsEnvReload = 1 + Invoke-Expression "winget install Gyan.FFmpeg" "Done." } else @@ -162,11 +100,10 @@ else } "" -# try to reload environment variables... -if ($NeedsEnvReload -eq 1) -{ - $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") -} +# NOTE: if we need to refresh the environment vars (Path, etc.) after installing +# the above packages, we may need to add some other dependency which provides +# RefreshEnv.bat or manually manage paths to newly installed exes. +# Users should be able to get around this by restarting the powershell script. # --------------------------------------------------PULLING THE BOT---------------------------------------------------- @@ -179,44 +116,32 @@ if((Test-Path $MB_Reqs_File) -and (Test-Path $MB_Module_Dir) -and (Test-Path $MB "" "Installer detected an existing clone, and will continue installing with the current source." "" - $Install_Dir = (pwd).Path } else { "" "MusicBot currently has three branches available." - " master - Stable MusicBot, least updates and may at times be out-of-date." + " master - An older MusicBot, for older discord.py. May not work without tweaks!" " review - Newer MusicBot, usually stable with less updates than the dev branch." " dev - The newest MusicBot, latest features and changes which may need testing." - if($anybranch) { - " * - WARNING: Any branch name is allowed, if it exists on github." - } "" $experimental = Read-Host "Enter the branch name you want to install" - $experimental = $experimental.Trim() - switch($experimental) { - "dev" { - "Installing dev branch..." - $branch = "dev" - } - "review" { - "Installing review branch..." - $branch = "review" - } - default { - if($anybranch -and $experimental -and $experimental -ne "master") - { - "Installing with $experimental branch, if it exists..." - $branch = $experimental - } - else - { - "Installing master branch..." - $branch = "master" - } - } + if($experimental -eq "dev") + { + "Installing dev branch..." + $branch = "dev" + } + if($experimental -eq "review") + { + "Installing review branch..." + $branch = "review" + } + else + { + "Installing master branch..." + $branch = "master" } - Invoke-Expression "git clone $MB_RepoURL '$Install_Dir' -b $branch" - Invoke-Expression "cd '$Install_Dir'" + Invoke-Expression "git clone https://github.com/Just-Some-Bots/MusicBot.git MusicBot -b $branch" + Invoke-Expression "cd MusicBot" "" } @@ -235,7 +160,7 @@ $versionArray = "3.8", "3.9", "3.10", "3.11", "3.12" foreach ($version in $versionArray) { - Invoke-Expression "py -$version -c 'exit()' 2>&1" | Out-Null + Invoke-Expression "py -$version -c 'exit()'" 2>$null if($LastExitCode -eq 0) { $PYTHON = "py -$version" @@ -243,7 +168,6 @@ foreach ($version in $versionArray) } "Using $PYTHON to install and run MusicBot..." -"" Invoke-Expression "$PYTHON -m pip install --upgrade -r requirements.txt" # -------------------------------------------------CONFIGURE THE BOT--------------------------------------------------- @@ -257,9 +181,7 @@ if($iagree -ne "Y" -and $iagree -ne "y") { "All done!" "Remember to configure your bot token and other options before you start." - "You must open a new command prompt before using run.bat to start the MusicBot." - "MusicBot was installed to:" - " $Install_Dir" + "You can use run.bat to start the MusicBot." Return } @@ -334,7 +256,4 @@ else "Saving your config..." Set-Content -Path ".\config\options.ini" -Value $config -"You can use run.bat to run the bot." -"Restart your command prompt first!" -"MusicBot was installed to:" -" $Install_Dir" +"You can now use run.bat to run the bot" diff --git a/install.sh b/install.sh index 512ad69d3..4052ed9f3 100644 --- a/install.sh +++ b/install.sh @@ -10,9 +10,7 @@ #-----------------------------------------------Configs-----------------------------------------------# MusicBotGitURL="https://github.com/Just-Some-Bots/MusicBot.git" CloneDir="MusicBot" -VenvDir="MusicBotVenv" -InstallDir="" -ServiceName="musicbot" +VenvDir="${CloneDir}Venv" EnableUnlistedBranches=0 DEBUG=0 @@ -56,72 +54,6 @@ else fi #----------------------------------------------Functions----------------------------------------------# -function get_supported() { - # Search this file and extract names from the supported cases below. - # We control which cases we grab based on the space at the end of each - # case pattern, before ) or | characters. - # This allows adding complex cases which will be excluded from the list. - Avail=$(grep -oh '\*"[[:alnum:] _!\./]*"\*[|)]' "$0" ) - Avail="${Avail//\*\"/}" - Avail="${Avail//\"\*/}" - Avail="${Avail//[|)]/}" - echo "$Avail" -} - -function distro_supported() { - # Loops over "supported" distros and color-codes the current distro. - OIFS=$IFS - IFS=$'\n' - for dist in $(get_supported) ; do - debug "Testing '$dist' in '$DISTRO_NAME'" - if [[ "$DISTRO_NAME" == *"$dist"* ]] ; then - echo -e "\e[1;32m${DISTRO_NAME}\e[0m" - IFS=$OIFS - return 0 - fi - done - IFS=$OIFS - echo -e "\e[1;33m${DISTRO_NAME}\e[0m" - return 1 -} - -function list_supported() { - # List off "supported" linux distro/versions if asked to and exit. - echo "We detected your OS is: $(distro_supported)" - echo "" - echo "The MusicBot installer might have support for these flavors of Linux:" - get_supported - echo "" - exit 0 -} - -function show_help() { - # provide help text for the installer and exit. - echo "MusicBot Installer script usage:" - echo " $0 [OPTIONS]" - echo "" - echo "By default, the installer script installs as the user who runs the script." - echo "The user should have permission to install system packages using sudo." - echo "Do NOT run this script with sudo, you will be prompted when it is needed!" - echo "To bypass steps that use sudo, use --no-sudo or --no-sys as desired." - echo " Note: Your system admin must install the packages before hand, by using:" - echo " $0 --sys-only" - echo "" - echo "Available Options:" - echo "" - echo " --list List potentially supported versions and exits." - echo " --help Show this help text and exit." - echo " --sys-only Install only system packages, no bot or pip libraries." - echo " --service Install only the system service for MusicBot." - echo " --no-sys Bypass system packages, install bot and pip libraries." - echo " --no-sudo Skip all steps that use sudo. This implies --no-sys." - echo " --debug Enter debug mode, with extra output. (for developers)" - echo " --any-branch Allow any existing branch to be given at the branch prompt. (for developers)" - echo " --dir [PATH] Directory into which MusicBot will be installed. Default is user Home directory." - echo "" - exit 0 -} - function exit_err() { echo "$@" exit 1 @@ -183,45 +115,19 @@ function find_python() { return 1 } -function find_python_venv() { - # activates venv, locates python bin, deactivates venv. - # shellcheck disable=SC1091 - source "../bin/activate" - find_python - deactivate -} - -function in_existing_repo() { - # check the current working directory is a MusicBot repo clone. - GitDir="${PWD}/.git" - BotDir="${PWD}/musicbot" - ReqFile="${PWD}/requirements.txt" - RunFile="${PWD}/run.py" - if [ -d "$GitDir" ] && [ -d "$BotDir" ] && [ -f "$ReqFile" ] && [ -f "$RunFile" ]; then - return 0 - fi - return 1 -} - -function in_venv() { - # Check if the current directory is inside a Venv, does not activate. - # Assumes the current directory is a MusicBot clone. - if [ -f "../bin/activate" ] ; then - return 0 - fi - return 1 -} - function pull_musicbot_git() { echo "" # Check if we're running inside a previously pulled repo. - # ignore this if InstallDir is set. - if in_existing_repo && [ "$InstallDir" == "" ]; then + GitDir="${PWD}/.git" + BotDir="${PWD}/musicbot" + ReqFile="${PWD}/requirements.txt" + if [ -d "$GitDir" ] && [ -d "$BotDir" ] && [ -f "$ReqFile" ] ; then echo "Existing MusicBot repo detected." read -rp "Would you like to install using the current repo? [Y/n]" UsePwd if [ "${UsePwd,,}" == "y" ] || [ "${UsePwd,,}" == "yes" ] ; then echo "" CloneDir="${PWD}" + VenvDir="${CloneDir}/Venv" $PyBin -m pip install --upgrade -r requirements.txt echo "" @@ -232,18 +138,11 @@ function pull_musicbot_git() { echo "Installer will attempt to create a new directory for MusicBot." fi - # test if we install at home-directory or a specified path. - if [ "$InstallDir" == "" ] ; then - cd ~ || exit_err "Fatal: Could not change into home directory." - if [ -d "${CloneDir}" ] ; then - echo "Error: A directory named ${CloneDir} already exists in your home directory." - exit_err "Delete the ${CloneDir} directory and try again, or complete the install manually." - fi - else - cd "$InstallDir" || exit_err "Fatal: Could not change into install directory: ${InstallDir}" - if [ "$InstalledViaVenv" != "1" ] ; then - CloneDir="${InstallDir}" - fi + cd ~ || exit_err "Fatal: Could not change to home directory." + + if [ -d "${CloneDir}" ] ; then + echo "Error: A directory named ${CloneDir} already exists in your home directory." + exit_err "Delete the ${CloneDir} directory and try again, or complete the install manually." fi echo "" @@ -251,9 +150,6 @@ function pull_musicbot_git() { echo " master - An older MusicBot, for older discord.py. May not work without tweaks!" echo " review - Newer MusicBot, usually stable with less updates than the dev branch." echo " dev - The newest MusicBot, latest features and changes which may need testing." - if [ "$EnableUnlistedBranches" == "1" ] ; then - echo " * - WARNING: Any branch name is allowed, if it exists on github." - fi echo "" read -rp "Enter the branch name you want to install: " BRANCH case ${BRANCH,,} in @@ -283,250 +179,79 @@ function pull_musicbot_git() { $PyBin -m pip install --upgrade -r requirements.txt echo "" - if ! [ -f ./config/options.ini ] ; then - echo "Creating empty options.ini file from example_options.ini file." - echo "" - cp ./config/example_options.ini ./config/options.ini - fi -} - -function install_as_venv() { - # Create and activate a venv using python that is installed. - find_python - $PyBin -m venv "${VenvDir}" - InstalledViaVenv=1 - CloneDir="${VenvDir}/${CloneDir}" - # shellcheck disable=SC1091 - source "${VenvDir}/bin/activate" - find_python - - pull_musicbot_git - - # exit venv - deactivate -} - -function issue_root_warning() { - echo "Just like my opinion, but root and MusicBot shouldn't mix." - echo "The installer will prevent this for the benefit of us all." -} - -function ask_for_user() { - # ask the user to supply a valid username. It must exist already. - while :; do - echo "" - read -rp "Please enter an existing User name: " Inst_User - if id -u "$Inst_User" >/dev/null 2>&1; then - if [ "${Inst_User,,}" == "root" ] ; then - issue_root_warning - echo "Try again." - Inst_User="" - else - return 0 - fi - else - echo "Username does not exist! Try again." - Inst_User="$(id -un)" - fi - done -} - -function ask_for_group() { - # ask the user to supply a valid group name. It must exist already. - while :; do - echo "" - read -rp "Please enter an existing Group name: " Inst_Group - if id -g "$Inst_Group" >/dev/null 2>&1 ; then - if [ "${Inst_Group,,}" == "root" ] ; then - issue_root_warning - echo "Try again." - Inst_Group="" - else - return 0 - fi - else - echo "Group does not exist! Try again." - Inst_Group="$(id -gn)" - fi - done -} - -function ask_change_user_group() { - User_Group="${Inst_User} / ${Inst_Group}" - echo "" - echo "The installer is currently running as: ${User_Group}" - read -rp "Set a different User / Group to run the service? [N/y]: " MakeChange - case $MakeChange in - [Yy]*) - ask_for_user - ask_for_group - ;; - esac -} - -function ask_change_service_name() { - echo "" - echo "The service will be installed as: $ServiceName" - read -rp "Would you like to change the name? [N/y]: " ChangeSrvName - case $ChangeSrvName in - [Yy]*) - while :; do - # ASCII letters, digits, ":", "-", "_", ".", and "\" - # but I know \ will complicate shit. so no thanks, sysD. - echo "" - echo "Service names may use only letters, numbers, and the listed special characters." - echo "Spaces are not allowed. Special characters: -_.:" - read -rp "Provide a name for the service: " ServiceName - # validate service name is allowed. - if [[ "$ServiceName" =~ ^[a-zA-Z0-9:-_\.]+$ ]] ; then - # attempt to avoid conflicting service names... - if ! systemctl list-unit-files "$ServiceName" &>/dev/null ; then - return 0 - else - echo "A service by this name already exists, try another." - fi - else - echo "Invalid service name, try again." - fi - done - ;; - esac -} - -function generate_service_file() { - # generate a .service file in the current directory. - cat << EOSF > "$1" -[Unit] -Description=Just-Some-Bots/MusicBot a discord.py bot that plays music. - -# Only start this service after networking is ready. -After=network.target - - -[Service] -# If you do not set these, MusicBot may run as root! You've been warned! -User=${Inst_User} -Group=${Inst_Group} - -# Replace with a path where MusicBot was cloned into. -WorkingDirectory=${PWD} - -# Here you need to use both the correct python path and a path to run.py -ExecStart=${PyBinPath} ${PWD}/run.py --no-checks - -# Set the condition under which the service should be restarted. -# Using on-failure allows the bot's shutdown command to actually stop the service. -# Using always will require you to stop the service via the service manager. -Restart=on-failure - -# Time to wait between restarts. Useful to avoid rate limits. -RestartSec=8 - - -[Install] -WantedBy=default.target - -EOSF - + cp ./config/example_options.ini ./config/options.ini } function setup_as_service() { - # Provide steps to generate and install a .service file - - # check for existing repo or install --dir option. - if ! in_existing_repo ; then - if [ "$InstallDir" != "" ]; then - cd "$InstallDir" || { exit_err "Could not cd to the supplied install directory."; } - - # if we still aren't in a valid repo, warn the user but continue on. - if ! in_existing_repo ; then - echo "WARNING:" - echo " Installer is generating a service file without a valid install!" - echo " The generated file may not contain the correct paths to python or run.py" - echo " Manually edit the service file or re-run using a valid install directory." - echo "" - fi - else - echo "The installer cannot generate a service file without an existing installation." - echo "Please add the --dir option or install the MusicBot first." - echo "" - return 1 - fi - fi - - # check if we're in a venv install. - if in_venv ; then - debug "Detected a Venv install" - find_python_venv - else - find_python - fi - - # TODO: should we assume systemd is all? perhaps check for it first... - - Inst_User="$(id -un)" - Inst_Group="$(id -gn)" echo "" - echo "The installer can also install MusicBot as a system service." - echo "This starts the MusicBot at boot and restarts after failures." + echo "The installer can also install MusicBot as a system service file." + echo "This starts the MusicBot at boot and after failures." + echo "You must specify a User and Group which the service will run as." read -rp "Install the musicbot system service? [N/y] " SERVICE case $SERVICE in [Yy]*) - ask_change_service_name - ask_change_user_group + # Because running this service as root is really not a good idea, + # a user and group is required here. + echo "Please provide an existing User name and Group name for the service to use." + read -rp "Enter an existing User name: " BotSysUserName + echo "" + read -rp "Enter an existing Group name: " BotSysGroupName + echo "" + # TODO: maybe check if the given values are valid, or create the user/group... - if [ "$Inst_User" == "" ] ; then + if [ "$BotSysUserName" == "" ] ; then echo "Cannot set up the service with a blank User name." - return 1 + return fi - if [ "$Inst_Group" == "" ] ; then + if [ "$BotSysGroupName" == "" ] ; then echo "Cannot set up the service with a blank Group name." - return 1 + return fi - SrvCpyFile="./${ServiceName}.service" - SrvInstFile="/etc/systemd/system/${ServiceName}.service" - - echo "" - echo "Setting up MusicBot as a service named: ${ServiceName}" - echo "Generated File: ${SrvCpyFile}" + echo "Setting up the bot as a service" + # Replace parts of musicbot.service with proper values. + sed -i "s,#User=mbuser,User=${BotSysUserName},g" ./musicbot.service + sed -i "s,#Group=mbusergroup,Group=${BotSysGroupName},g" ./musicbot.service + sed -i "s,/usr/bin/pythonversionnum,${PyBinPath},g" ./musicbot.service + sed -i "s,mbdirectory,${PWD},g" ./musicbot.service - generate_service_file "${SrvCpyFile}" - - if [ "$SKIP_ALL_SUDO" == "0" ] ; then - # Copy the service file into place and enable it. - sudo cp "${SrvCpyFile}" "${SrvInstFile}" - sudo chown root:root "$SrvInstFile" - sudo chmod 644 "$SrvInstFile" - # TODO: maybe we need to reload the daemon... - # sudo systemctl daemon-reload - sudo systemctl enable "$ServiceName" + # Copy the service file into place and enable it. + sudo cp ~/${CloneDir}/musicbot.service /etc/systemd/system/ + sudo chown root:root /etc/systemd/system/musicbot.service + sudo chmod 644 /etc/systemd/system/musicbot.service + sudo systemctl enable musicbot + sudo systemctl start musicbot - echo "Installed File: ${SrvInstFile}" + echo "Bot setup as a service and started" + ask_setup_aliases + ;; + esac - echo "" - echo "MusicBot will start automatically after the next reboot." - read -rp "Would you like to start MusicBot now? [N/y]" StartService - case $StartService in - [Yy]*) - echo "Running: sudo systemctl start $ServiceName" - sudo systemctl start "$ServiceName" - ;; - esac - else - echo "Installing of generated service skipped, sudo is required to install it." - echo "The file was left on disk so you can manually install it." - fi +} + +function ask_setup_aliases() { + echo " " + # TODO: ADD LINK TO WIKI + read -rp "Would you like to set up a command to manage the service? [N/y] " SERVICE + case $SERVICE in + [Yy]*) + echo "Setting up command..." + sudo cp ~/${CloneDir}/musicbotcmd /usr/bin/musicbot + sudo chown root:root /usr/bin/musicbot + sudo chmod 644 /usr/bin/musicbot + sudo chmod +x /usr/bin/musicbot echo "" + echo "Command created!" + echo "Information regarding how the bot can now be managed found by running:" + echo "musicbot --help" ;; esac - return 0 } function debug() { local msg=$1 if [[ $DEBUG == '1' ]]; then - echo -e "\e[1;36m[DEBUG]\e[0m $msg" 1>&2 + echo "[DEBUG] $msg" 1>&2 fi } @@ -647,85 +372,24 @@ function configure_bot() { esac } -#------------------------------------------CLI Arguments----------------------------------------------# -INSTALL_SYS_PKGS="1" -INSTALL_BOT_BITS="1" -SERVICE_ONLY="0" -SKIP_ALL_SUDO="0" - -while [[ $# -gt 0 ]]; do - case ${1,,} in - --list ) - shift - list_supported - ;; - --help ) - shift - show_help - ;; - - --no-sys ) - INSTALL_SYS_PKGS="0" - shift - ;; - - --no-sudo ) - INSTALL_SYS_PKGS="0" - SKIP_ALL_SUDO="1" - shift - ;; - - --sys-only ) - INSTALL_BOT_BITS="0" - shift - ;; - - --service ) - SERVICE_ONLY="1" - shift - ;; - - --any-branch ) - EnableUnlistedBranches=1 - shift - ;; - - --debug ) - DEBUG=1 - shift - echo "DEBUG MODE IS ENABLED!" - ;; - - "--dir" ) - InstallDir="$2" - shift - shift - if [ "${InstallDir:0-1}" != "/" ] ; then - InstallDir="${InstallDir}/" - fi - if ! [ -d "$InstallDir" ] ; then - exit_err "The install directory given does not exist: '$InstallDir'" - fi - VenvDir="${InstallDir}${VenvDir}" - ;; - - * ) - exit_err "Unknown option $1" - ;; - esac -done - -if [ "${INSTALL_SYS_PKGS}${INSTALL_BOT_BITS}" == "00" ] ; then - exit_err "The options --no-sys and --sys-only cannot be used together." -fi - #------------------------------------------------Logic------------------------------------------------# -if [ "$SERVICE_ONLY" = "1" ] ; then - setup_as_service +# list off "supported" linux distro/versions if asked to and exit. +if [[ "${1,,}" == "--list" ]] ; then + # We search this file and extract names from the supported cases below. + # We control which cases we grab based on the space at the end of each + # case pattern, before ) or | characters. + # This allows adding complex cases which will be excluded from the list. + Avail=$(grep -oh '\*"[[:alnum:] _!\.]*"\*[|)]' "$0" ) + Avail="${Avail//\*\"/}" + Avail="${Avail//\"\*/}" + Avail="${Avail//[|)]/}" + + echo "The MusicBot installer might have support for these flavors of Linux:" + echo "$Avail" + echo "" exit 0 fi -# display preamble cat << EOF MusicBot Installer @@ -751,197 +415,106 @@ For a list of potentially supported OS, run the command: EOF -echo "We detected your OS is: $(distro_supported)" +echo "We detected your OS is: ${DISTRO_NAME}" read -rp "Would you like to continue with the installer? [Y/n]: " iagree if [[ "${iagree,,}" != "y" && "${iagree,,}" != "yes" ]] ; then exit 2 fi -# check if we are running as root, and if so make a more informed choice. -if [ "$(id -u)" -eq "0" ] && [ "$INSTALL_BOT_BITS" == "1" ] ; then - # in theory, we could prompt for a user and do all the setup. - # better that folks learn to admin their own systems though. - echo "" - echo -e "\e[1;37m\e[41m Warning \e[0m You are using root and installing MusicBot." - echo " This can break python permissions and will create MusicBot files as root." - echo " Meaning, little or no support and you have to fix stuff manually." - echo " Running MuiscBot as root is not recommended. You have been warned." - echo "" - read -rp "Type 'I understand' (without quotes) to continue installing:" iunderstand - if [[ "${iunderstand,,}" != "i understand" ]] ; then - echo "" - exit_err "Try again with --sys-only or change to a non-root user and use --no-sys and/or --no-sudo" - fi -fi - -# check if we can sudo or not -if [ "$SKIP_ALL_SUDO" == "0" ] ; then - echo "Checking if user can sudo..." - if ! sudo -v ; then - if [ "$INSTALL_SYS_PKGS" == "1" ] ; then - echo -e "\e[1;31mThe current user cannot run sudo to install system packages.\e[0m" - echo "If you have already installed system dependencies, try again with:" - echo " $0 --no-sys" - echo "" - echo "To install system dependencies, switch to root and run:" - echo " $0 --sys-only" - exit 1 - fi - echo "Will skip all sudo steps." - SKIP_ALL_SUDO="1" - fi -fi - -# attempt to change the working directory to where this installer is. -# if nothing is moved this location might be a clone repo... -if [ "$InstallDir" == "" ] ; then - cd "$(dirname "${BASH_SOURCE[0]}")" || { exit_err "Could not change directory for MusicBot installer."; } -fi - echo "" -if [ "${INSTALL_SYS_PKGS}${INSTALL_BOT_BITS}" == "11" ] ; then - echo "Attempting to install required system packages & MusicBot software..." -else - if [ "${INSTALL_SYS_PKGS}${INSTALL_BOT_BITS}" == "10" ] ; then - echo "Attempting to install only required system packages..." - else - echo "Attempting to install only MusicBot and pip libraries..." - fi -fi +echo "Attempting to install required system packages..." echo "" case $DISTRO_NAME in *"Arch Linux"*) # Tested working 2024.03.01 @ 2024/03/31 - if [ "$INSTALL_SYS_PKGS" == "1" ] ; then - # NOTE: Arch now uses system managed python packages, so venv is required. - sudo pacman -Syu - sudo pacman -S curl ffmpeg git jq python python-pip - fi + # NOTE: Arch now uses system managed python packages, so venv is required. + sudo pacman -Syu + sudo pacman -S curl ffmpeg git jq python python-pip - if [ "$INSTALL_BOT_BITS" == "1" ] ; then - install_as_venv - fi - ;; + # Make sure newly install python is used. + find_python -*"Pop!_OS"* ) - case $DISTRO_NAME in + # create a venv to install MusicBot into and activate it. + $PyBin -m venv "${VenvDir}" + InstalledViaVenv=1 + CloneDir="${VenvDir}/${CloneDir}" + # shellcheck disable=SC1091 + source "${VenvDir}/bin/activate" - # Tested working 22.04 @ 2024/03/29 - *"Pop!_OS 22.04"*) - if [ "$INSTALL_SYS_PKGS" == "1" ] ; then - sudo apt-get update -y - sudo apt-get upgrade -y - sudo apt-get install build-essential software-properties-common \ - unzip curl git ffmpeg libopus-dev libffi-dev libsodium-dev \ - python3-pip python3-dev jq -y - fi + # Update python to use venv path. + find_python - if [ "$INSTALL_BOT_BITS" == "1" ] ; then - pull_musicbot_git - fi - ;; + pull_musicbot_git - *"Pop!_OS 24.04"*) - if [ "$INSTALL_SYS_PKGS" == "1" ] ; then - sudo apt-get update -y - sudo apt-get upgrade -y - sudo apt-get install build-essential software-properties-common \ - unzip curl git ffmpeg libopus-dev libffi-dev libsodium-dev \ - python3-full python3-pip python3-venv python3-dev jq -y - fi + deactivate + ;; - if [ "$INSTALL_BOT_BITS" == "1" ] ; then - install_as_venv - fi - ;; +*"Pop!_OS"*) # Tested working 22.04 @ 2024/03/29 + sudo apt-get update -y + sudo apt-get upgrade -y + sudo apt-get install build-essential software-properties-common \ + unzip curl git ffmpeg libopus-dev libffi-dev libsodium-dev \ + python3-pip python3-dev jq -y - *) - echo "Unsupported version of Pop! OS." - exit 1 - ;; - esac + pull_musicbot_git ;; *"Ubuntu"* ) # Some cases only use major version number to allow for both .04 and .10 minor versions. case $DISTRO_NAME in *"Ubuntu 18.04"*) # Tested working 18.04 @ 2024/03/29 - if [ "$INSTALL_SYS_PKGS" == "1" ] ; then - sudo apt-get update -y - sudo apt-get upgrade -y - # 18.04 needs to build a newer version from source. - sudo apt-get install build-essential software-properties-common \ - libopus-dev libffi-dev libsodium-dev libssl-dev \ - zlib1g-dev libncurses5-dev libgdbm-dev libnss3-dev \ - libreadline-dev libsqlite3-dev libbz2-dev \ - unzip curl git jq ffmpeg -y - - # Ask if we should build python - echo "We need to build python from source for your system. It will be installed using altinstall target." - read -rp "Would you like to continue ? [N/y]" BuildPython - if [ "${BuildPython,,}" == "y" ] || [ "${BuildPython,,}" == "yes" ] ; then - # Build python. - PyBuildVer="3.10.14" - PySrcDir="Python-${PyBuildVer}" - PySrcFile="${PySrcDir}.tgz" - - curl -o "$PySrcFile" "https://www.python.org/ftp/python/${PyBuildVer}/${PySrcFile}" - tar -xzf "$PySrcFile" - cd "${PySrcDir}" || exit_err "Fatal: Could not change to python source directory." - - ./configure --enable-optimizations - sudo make altinstall - - # Ensure python bin is updated with altinstall name. - find_python - RetVal=$? - if [ "$RetVal" == "0" ] ; then - # manually install pip package for current user. - $PyBin <(curl -s https://bootstrap.pypa.io/get-pip.py) - else - echo "Error: Could not find python on the PATH after installing it." - exit 1 - fi + sudo apt-get update -y + sudo apt-get upgrade -y + # 18.04 needs to build a newer version from source. + sudo apt-get install build-essential software-properties-common \ + libopus-dev libffi-dev libsodium-dev libssl-dev \ + zlib1g-dev libncurses5-dev libgdbm-dev libnss3-dev \ + libreadline-dev libsqlite3-dev libbz2-dev \ + unzip curl git jq ffmpeg -y + + # Ask if we should build python + echo "We need to build python from source for your system. It will be installed using altinstall target." + read -rp "Would you like to continue ? [N/y]" BuildPython + if [ "${BuildPython,,}" == "y" ] || [ "${BuildPython,,}" == "yes" ] ; then + # Build python. + PyBuildVer="3.10.14" + PySrcDir="Python-${PyBuildVer}" + PySrcFile="${PySrcDir}.tgz" + + curl -o "$PySrcFile" "https://www.python.org/ftp/python/${PyBuildVer}/${PySrcFile}" + tar -xzf "$PySrcFile" + cd "${PySrcDir}" || exit_err "Fatal: Could not change to python source directory." + + ./configure --enable-optimizations + sudo make altinstall + + # Ensure python bin is updated with altinstall name. + find_python + RetVal=$? + if [ "$RetVal" == "0" ] ; then + # manually install pip package for current user. + $PyBin <(curl -s https://bootstrap.pypa.io/get-pip.py) + else + echo "Error: Could not find python on the PATH after installing it." + exit 1 fi fi - if [ "$INSTALL_BOT_BITS" == "1" ] ; then - pull_musicbot_git - fi + pull_musicbot_git ;; # Tested working: # 20.04 @ 2024/03/28 # 22.04 @ 2024/03/30 *"Ubuntu 20"*|*"Ubuntu 22"*) - if [ "$INSTALL_SYS_PKGS" == "1" ] ; then - sudo apt-get update -y - sudo apt-get upgrade -y - sudo apt-get install build-essential software-properties-common \ - unzip curl git ffmpeg libopus-dev libffi-dev libsodium-dev \ - python3-pip python3-dev jq -y - fi - - if [ "$INSTALL_BOT_BITS" == "1" ] ; then - pull_musicbot_git - fi - ;; - - # Tested working: - # 24.04 @ 2024/09/04 - *"Ubuntu 24"*) - if [ "$INSTALL_SYS_PKGS" == "1" ] ; then - sudo apt-get update -y - sudo apt-get upgrade -y - sudo apt-get install build-essential software-properties-common \ - unzip curl git ffmpeg libopus-dev libffi-dev libsodium-dev \ - python3-full python3-pip python3-venv python3-dev jq -y - fi + sudo apt-get update -y + sudo apt-get upgrade -y + sudo apt-get install build-essential software-properties-common \ + unzip curl git ffmpeg libopus-dev libffi-dev libsodium-dev \ + python3-pip python3-dev jq -y - if [ "$INSTALL_BOT_BITS" == "1" ] ; then - install_as_venv - fi + pull_musicbot_git ;; # Ubuntu version 17 and under is not supported. @@ -954,40 +527,41 @@ case $DISTRO_NAME in ;; # NOTE: Raspberry Pi OS 11, i386 arch, returns Debian as distro name. -*"Debian"* ) +*"Debian"*) case $DISTRO_NAME in # Tested working: # R-Pi OS 11 @ 2024/03/29 # Debian 11.3 @ 2024/03/29 *"Debian GNU/Linux 11"*) - if [ "$INSTALL_SYS_PKGS" == "1" ] ; then - sudo apt-get update -y - sudo apt-get upgrade -y - sudo apt-get install git libopus-dev libffi-dev libsodium-dev ffmpeg \ - build-essential libncursesw5-dev libgdbm-dev libc6-dev zlib1g-dev \ - libsqlite3-dev tk-dev libssl-dev openssl python3 python3-pip curl jq -y - fi + sudo apt-get update -y + sudo apt-get upgrade -y + sudo apt-get install git libopus-dev libffi-dev libsodium-dev ffmpeg \ + build-essential libncursesw5-dev libgdbm-dev libc6-dev zlib1g-dev \ + libsqlite3-dev tk-dev libssl-dev openssl python3 python3-pip curl jq -y - if [ "$INSTALL_BOT_BITS" == "1" ] ; then - pull_musicbot_git - fi + pull_musicbot_git ;; - # Tested working 12.5 @ 2024/03/31 - # Tested working 12.7 @ 2024/09/05 - # Tested working trixie @ 2024/09/05 - *"Debian GNU/Linux 12"*|*"Debian GNU/Linux trixie"*|*"Debian GNU/Linux sid"*) + *"Debian GNU/Linux 12"*) # Tested working 12.5 @ 2024/03/31 # Debian 12 uses system controlled python packages. - if [ "$INSTALL_SYS_PKGS" == "1" ] ; then - sudo apt-get update -y - sudo apt-get upgrade -y - sudo apt-get install build-essential libopus-dev libffi-dev libsodium-dev \ - python3-full python3-dev python3-venv python3-pip git ffmpeg curl - fi + sudo apt-get update -y + sudo apt-get upgrade -y + sudo apt-get install build-essential libopus-dev libffi-dev libsodium-dev \ + python3-full python3-dev python3-pip git ffmpeg curl - if [ "$INSTALL_BOT_BITS" == "1" ] ; then - install_as_venv - fi + # Create and activate a venv using python that was just installed. + find_python + $PyBin -m venv "${VenvDir}" + InstalledViaVenv=1 + CloneDir="${VenvDir}/${CloneDir}" + # shellcheck disable=SC1091 + source "${VenvDir}/bin/activate" + find_python + + pull_musicbot_git + + # exit venv + deactiveate ;; *) @@ -999,23 +573,19 @@ case $DISTRO_NAME in # Legacy install, needs testing. # Modern Raspberry Pi OS does not return "Raspbian" *"Raspbian"*) - if [ "$INSTALL_SYS_PKGS" == "1" ] ; then - sudo apt-get update -y - sudo apt-get upgrade -y - sudo apt install python3-pip git libopus-dev ffmpeg curl - curl -o jq.tar.gz https://github.com/stedolan/jq/releases/download/jq-1.5/jq-1.5.tar.gz - tar -zxvf jq.tar.gz - cd jq-1.5 || exit_err "Fatal: Could not change directory to jq-1.5" - ./configure && make && sudo make install - cd .. && rm -rf ./jq-1.5 - fi - if [ "$INSTALL_BOT_BITS" == "1" ] ; then - pull_musicbot_git - fi + sudo apt-get update -y + sudo apt-get upgrade -y + sudo apt install python3-pip git libopus-dev ffmpeg curl + curl -o jq.tar.gz https://github.com/stedolan/jq/releases/download/jq-1.5/jq-1.5.tar.gz + tar -zxvf jq.tar.gz + cd jq-1.5 || exit_err "Fatal: Could not change directory to jq-1.5" + ./configure && make && sudo make install + cd .. && rm -rf ./jq-1.5 + pull_musicbot_git ;; *"CentOS"* ) - # Get the full release name and version for CentOS + # Get the full release name and version if [ -f "/etc/redhat-release" ]; then DISTRO_NAME=$(cat /etc/redhat-release) fi @@ -1034,66 +604,59 @@ case $DISTRO_NAME in # Supported versions. *"CentOS 7"*) # Tested 7.9 @ 2024/03/28 # TODO: CentOS 7 reaches EOL June 2024. - if [ "$INSTALL_SYS_PKGS" == "1" ] ; then - # Enable extra repos, as required for ffmpeg - # We DO NOT use the -y flag here. - sudo yum install epel-release - sudo yum localinstall --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-7.noarch.rpm - - # Install available packages and libraries for building python 3.8+ - sudo yum -y groupinstall "Development Tools" - sudo yum -y install opus-devel libffi-devel openssl-devel bzip2-devel \ - git curl jq ffmpeg - - # Ask if we should build python - echo "We need to build python from source for your system. It will be installed using altinstall target." - read -rp "Would you like to continue ? [N/y]" BuildPython - if [ "${BuildPython,,}" == "y" ] || [ "${BuildPython,,}" == "yes" ] ; then - # Build python. - PyBuildVer="3.10.14" - PySrcDir="Python-${PyBuildVer}" - PySrcFile="${PySrcDir}.tgz" - - curl -o "$PySrcFile" "https://www.python.org/ftp/python/${PyBuildVer}/${PySrcFile}" - tar -xzf "$PySrcFile" - cd "${PySrcDir}" || exit_err "Fatal: Could not change to python source directory." - - ./configure --enable-optimizations - sudo make altinstall - - # Ensure python bin is updated with altinstall name. - find_python - RetVal=$? - if [ "$RetVal" == "0" ] ; then - # manually install pip package for the current user. - $PyBin <(curl -s https://bootstrap.pypa.io/get-pip.py) - else - echo "Error: Could not find python on the PATH after installing it." - exit 1 - fi + + # Enable extra repos, as required for ffmpeg + # We DO NOT use the -y flag here. + sudo yum install epel-release + sudo yum localinstall --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-7.noarch.rpm + + # Install available packages and libraries for building python 3.8+ + sudo yum -y groupinstall "Development Tools" + sudo yum -y install opus-devel libffi-devel openssl-devel bzip2-devel \ + git curl jq ffmpeg + + # Ask if we should build python + echo "We need to build python from source for your system. It will be installed using altinstall target." + read -rp "Would you like to continue ? [N/y]" BuildPython + if [ "${BuildPython,,}" == "y" ] || [ "${BuildPython,,}" == "yes" ] ; then + # Build python. + PyBuildVer="3.10.14" + PySrcDir="Python-${PyBuildVer}" + PySrcFile="${PySrcDir}.tgz" + + curl -o "$PySrcFile" "https://www.python.org/ftp/python/${PyBuildVer}/${PySrcFile}" + tar -xzf "$PySrcFile" + cd "${PySrcDir}" || exit_err "Fatal: Could not change to python source directory." + + ./configure --enable-optimizations + sudo make altinstall + + # Ensure python bin is updated with altinstall name. + find_python + RetVal=$? + if [ "$RetVal" == "0" ] ; then + # manually install pip package for the current user. + $PyBin <(curl -s https://bootstrap.pypa.io/get-pip.py) + else + echo "Error: Could not find python on the PATH after installing it." + exit 1 fi fi - if [ "$INSTALL_BOT_BITS" == "1" ] ; then - pull_musicbot_git - fi + pull_musicbot_git ;; *"CentOS Stream 8"*) # Tested 2024/03/28 - if [ "$INSTALL_SYS_PKGS" == "1" ] ; then - # Install extra repos, needed for ffmpeg. - # Do not use -y flag here. - sudo dnf install epel-release - sudo dnf install --nogpgcheck https://mirrors.rpmfusion.org/free/el/rpmfusion-free-release-8.noarch.rpm - sudo dnf config-manager --enable powertools - - # Install available packages. - sudo yum -y install opus-devel libffi-devel git curl jq ffmpeg python39 python39-devel - fi + # Install extra repos, needed for ffmpeg. + # Do not use -y flag here. + sudo dnf install epel-release + sudo dnf install --nogpgcheck https://mirrors.rpmfusion.org/free/el/rpmfusion-free-release-8.noarch.rpm + sudo dnf config-manager --enable powertools - if [ "$INSTALL_BOT_BITS" == "1" ] ; then - pull_musicbot_git - fi + # Install available packages. + sudo yum -y install opus-devel libffi-devel git curl jq ffmpeg python39 python39-devel + + pull_musicbot_git ;; # Currently unsupported. @@ -1106,23 +669,18 @@ case $DISTRO_NAME in # Legacy installer, needs testing. *"Darwin"*) - if [ "$INSTALL_SYS_PKGS" == "1" ] ; then - /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" - brew update - xcode-select --install - brew install python - brew install git - brew install ffmpeg - brew install opus - brew install libffi - brew install libsodium - brew install curl - brew install jq - fi - - if [ "$INSTALL_BOT_BITS" == "1" ] ; then - pull_musicbot_git - fi + /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" + brew update + xcode-select --install + brew install python + brew install git + brew install ffmpeg + brew install opus + brew install libffi + brew install libsodium + brew install curl + brew install jq + pull_musicbot_git ;; *) @@ -1132,10 +690,8 @@ case $DISTRO_NAME in esac if ! [[ $DISTRO_NAME == *"Darwin"* ]]; then - if [ "$INSTALL_BOT_BITS" == "1" ] ; then - configure_bot - setup_as_service - fi + configure_bot + setup_as_service else echo "The bot has been successfully installed to your user directory" echo "You can configure the bot by navigating to the config folder, and modifying the contents of the options.ini and permissions.ini files" @@ -1145,13 +701,12 @@ fi if [ "$InstalledViaVenv" == "1" ] ; then echo "" echo "Notice:" - echo " This system required MusicBot to be installed inside a Python venv." - echo " Shell scripts included with MusicBot should detect and use the venv automatically." - echo " If you do not use the included scripts, you must manually activate instead." - echo " To manually activate the venv, run the following command: " - echo " source ${VenvDir}/bin/activate" + echo "This system required MusicBot to be installed inside a Python venv." + echo "In order to run or update MusicBot, you must use the venv or binaries stored within it." + echo "To activate the venv, run the following command: " + echo " source ${VenvDir}/bin/activate" echo "" - echo " The venv module is bundled with python 3.3+, for more info about venv, see here:" - echo " https://docs.python.org/3/library/venv.html" + echo "The venv module is bundled with python 3.3+, for more info about venv, see here:" + echo " https://docs.python.org/3/library/venv.html" echo "" fi diff --git a/musicbot/aliases.py b/musicbot/aliases.py index edfbbd504..22069e455 100644 --- a/musicbot/aliases.py +++ b/musicbot/aliases.py @@ -2,28 +2,16 @@ import logging import shutil from pathlib import Path -from typing import Any, Dict, List, Tuple +from typing import Any, Dict from .constants import DEFAULT_COMMAND_ALIAS_FILE, EXAMPLE_COMMAND_ALIAS_FILE from .exceptions import HelpfulError log = logging.getLogger(__name__) -RawAliasJSON = Dict[str, Any] -ComplexAliases = Dict[str, Tuple[str, str]] - class Aliases: - """ - Aliases class provides a method of duplicating commands under different names or - providing reduction in keystrokes for multi-argument commands. - Command alias with conflicting names will overload each other, it is up to - the user to avoid configuring aliases with conflicts. - """ - - # TODO: add a method to query aliases a natural command has. - - def __init__(self, aliases_file: Path, nat_cmds: List[str]) -> None: + def __init__(self, aliases_file: Path) -> None: """ Handle locating, initializing, loading, and validation of command aliases. If given `aliases_file` is not found, examples will be copied to the location. @@ -31,14 +19,9 @@ def __init__(self, aliases_file: Path, nat_cmds: List[str]) -> None: :raises: musicbot.exceptions.HelpfulError if loading fails in some known way. """ - # List of "natural" commands to allow. - self.nat_cmds: List[str] = nat_cmds - # File Path used to locate and load the alias json. - self.aliases_file: Path = aliases_file - # "raw" dict from json file. - self.aliases_seed: RawAliasJSON = AliasesDefault.aliases_seed - # Simple aliases - self.aliases: ComplexAliases = AliasesDefault.complex_aliases + self.aliases_file = aliases_file + self.aliases_seed = AliasesDefault.aliases_seed + self.aliases = AliasesDefault.aliases # find aliases file if not self.aliases_file.is_file(): @@ -54,96 +37,32 @@ def __init__(self, aliases_file: Path, nat_cmds: List[str]) -> None: ) # parse json - self.load() - - def load(self) -> None: - """ - Attempt to load/decode JSON and determine which version of aliases we have. - """ - # parse json - try: - with self.aliases_file.open() as f: + with self.aliases_file.open() as f: + try: self.aliases_seed = json.load(f) - except OSError as e: - log.error( - "Failed to load aliases file: %s", - self.aliases_file, - exc_info=e, - ) - self.aliases_seed = AliasesDefault.aliases_seed - return - except json.JSONDecodeError as e: - log.error( - "Failed to parse aliases file: %s\n" - "Ensure the file contains valid JSON and restart the bot.", - self.aliases_file, - exc_info=e, - ) - self.aliases_seed = AliasesDefault.aliases_seed - return + except json.JSONDecodeError as e: + raise HelpfulError( + f"Failed to parse aliases file: {str(self.aliases_file)}", + "Ensure your alias file contains valid JSON and restart the bot.", + ) from e - # Create an alias-to-command map from the JSON. + # construct for cmd, aliases in self.aliases_seed.items(): - # ignore comments - if cmd.lower() in ["--comment", "--comments"]: - continue - - # check for spaces, and handle args in cmd alias if they exist. - cmd_args = "" - if " " in cmd: - cmd_bits = cmd.split(" ", maxsplit=1) - if len(cmd_bits) > 1: - cmd = cmd_bits[0] - cmd_args = cmd_bits[1].strip() - cmd = cmd.strip() - - # ensure command name is valid. - if cmd not in self.nat_cmds: - log.error( - "Aliases skipped for non-existent command: %s -> %s", - cmd, - aliases, - ) - continue - - # ensure alias data uses valid types. if not isinstance(cmd, str) or not isinstance(aliases, list): - log.error( - "Alias(es) skipped for invalid alias data: %s -> %s", - cmd, - aliases, + raise HelpfulError( + "Failed to load aliases file due to invalid format.", + "Make sure your aliases conform to the format given in the example file.", ) - continue + self.aliases.update({alias.lower(): cmd.lower() for alias in aliases}) - # Loop over given aliases and associate them. - for alias in aliases: - alias = alias.lower() - if alias in self.aliases: - log.error( - "Alias `%s` skipped as already exists on command: %s", - alias, - self.aliases[alias], - ) - continue - - self.aliases.update({alias: (cmd, cmd_args)}) - - def get(self, alias_name: str) -> Tuple[str, str]: + def get(self, alias: str) -> str: """ - Get the command name the given `aliase_name` refers to. - Returns a two-member tuple containing the command name and any args for - the command alias in the case of complex aliases. + Return cmd name that given `alias` points to or an empty string. """ - cmd_name, cmd_args = self.aliases.get(alias_name, ("", "")) - - # If no alias at all, return nothing. - if not cmd_name: - return ("", "") - - return (cmd_name, cmd_args) + return self.aliases.get(alias, "") class AliasesDefault: aliases_file: Path = Path(DEFAULT_COMMAND_ALIAS_FILE) - aliases_seed: RawAliasJSON = {} - complex_aliases: ComplexAliases = {} + aliases_seed: Dict[str, Any] = {} + aliases: Dict[str, str] = {} diff --git a/musicbot/bot.py b/musicbot/bot.py index 0a43ee87e..645b8cd30 100644 --- a/musicbot/bot.py +++ b/musicbot/bot.py @@ -17,7 +17,7 @@ from collections import defaultdict from io import BytesIO, StringIO from textwrap import dedent -from typing import TYPE_CHECKING, Any, DefaultDict, Dict, List, Optional, Set, Union +from typing import Any, DefaultDict, Dict, List, Optional, Set, Union import aiohttp import certifi # type: ignore[import-untyped, unused-ignore] @@ -32,7 +32,6 @@ DATA_FILE_SERVERS, DATA_GUILD_FILE_CUR_SONG, DATA_GUILD_FILE_QUEUE, - DEFAULT_BOT_NAME, DEFAULT_OWNER_GROUP_NAME, DEFAULT_PERMS_GROUP_NAME, DEFAULT_PING_HTTP_URI, @@ -48,7 +47,6 @@ EMOJI_STOP_SIGN, FALLBACK_PING_SLEEP, FALLBACK_PING_TIMEOUT, - MUSICBOT_USER_AGENT_AIOHTTP, ) from .constants import VERSION as BOTVERSION from .constants import VOICE_CLIENT_MAX_RETRY_CONNECT, VOICE_CLIENT_RECONNECT_TIMEOUT @@ -82,14 +80,6 @@ objgraph = None -if TYPE_CHECKING: - from collections.abc import Coroutine - from contextvars import Context as CtxVars - - AsyncTask = asyncio.Task[Any] -else: - AsyncTask = asyncio.Task - # Type aliases ExitSignals = Union[None, exceptions.RestartSignal, exceptions.TerminateSignal] # Channels that MusicBot Can message. @@ -124,6 +114,9 @@ log = logging.getLogger(__name__) +# TODO: add an aliases command to manage command aliases. +# TODO: maybe allow aliases to contain whole/partial commands. + class MusicBot(discord.Client): def __init__( @@ -162,7 +155,6 @@ def __init__( self.cached_app_info: Optional[discord.AppInfo] = None self.last_status: Optional[discord.BaseActivity] = None self.players: Dict[int, MusicPlayer] = {} - self.task_pool: Set[AsyncTask] = set() self.config = Config(self._config_file) @@ -172,12 +164,7 @@ def __init__( self.str = Json(self.config.i18n_file) if self.config.usealias: - # get a list of natural command names. - nat_cmds = [ - x.replace("cmd_", "") for x in dir(self) if x.startswith("cmd_") - ] - # load the aliases file. - self.aliases = Aliases(aliases_file, nat_cmds) + self.aliases = Aliases(aliases_file) self.playlist_mgr = AutoPlaylistManager(self) @@ -202,59 +189,12 @@ def server_factory() -> GuildSpecificData: intents.presences = False super().__init__(intents=intents) - def create_task( - self, - coro: "Coroutine[Any, Any, Any]", - *, - name: Optional[str] = None, - ctx: Optional["CtxVars"] = None, - ) -> None: - """ - Same as asyncio.create_task() but manages the task reference. - This prevents garbage collection of tasks until they are finished. - """ - if not self.loop: - log.error("Loop is closed, cannot create task for: %r", coro) - return - - # context was not added until python 3.11 - if sys.version_info >= (3, 11): - t = self.loop.create_task(coro, name=name, context=ctx) - else: # assume 3.8 + - t = self.loop.create_task(coro, name=name) - self.task_pool.add(t) - - def discard_task(task: AsyncTask) -> None: - """Clean up the spawned task and handle its exceptions.""" - ex = task.exception() - if ex: - if log.getEffectiveLevel() <= logging.DEBUG: - log.exception( - "Unhandled exception for task: %r", task, exc_info=ex - ) - else: - log.error( - "Unhandled exception for task: %r -- %s", - task, - str(ex), - ) - - self.task_pool.discard(task) - - t.add_done_callback(discard_task) - async def setup_hook(self) -> None: """async init phase that is called by d.py before login.""" if self.config.enable_queue_history_global: await self.playlist_mgr.global_history.load() - # TODO: testing is needed to see if this would be required. - # See also: https://github.com/aio-libs/aiohttp/discussions/6044 - # aiohttp version must be at least 3.8.0 for the following to potentially work. - # Python 3.11+ might also be a requirement if CPython does not support start_tls. - # setattr(asyncio.sslproto._SSLProtocolTransport, "_start_tls_compatible", True) - - self.http.user_agent = MUSICBOT_USER_AGENT_AIOHTTP + self.http.user_agent = f"MusicBot/{BOTVERSION}" if self.use_certifi: ssl_ctx = ssl.create_default_context(cafile=certifi.where()) tcp_connector = aiohttp.TCPConnector(ssl_context=ssl_ctx) @@ -315,20 +255,10 @@ async def setup_hook(self) -> None: ) self.config.spotify_enabled = False - # trigger yt tv oauth2 authorization. - if self.config.ytdlp_use_oauth2 and self.config.ytdlp_oauth2_url: - log.warning( - "Experimental Yt-dlp OAuth2 plugin is enabled. This might break at any point!" - ) - # could probably do this with items from an auto-playlist but meh. - await self.downloader.extract_info( - self.config.ytdlp_oauth2_url, download=False, process=True - ) - log.info("Initialized, now connecting to discord.") # this creates an output similar to a progress indicator. muffle_discord_console_log() - self.create_task(self._test_network(), name="MB_PingTest") + self.loop.create_task(self._test_network(), name="MB_PingTest") async def _test_network(self) -> None: """ @@ -385,8 +315,8 @@ async def _test_network(self) -> None: return # set up the next ping task if possible. - if not self.logout_called: - self.create_task(self._test_network(), name="MB_PingTest") + if self.loop and not self.logout_called: + self.loop.create_task(self._test_network(), name="MB_PingTest") async def _test_network_via_http(self, ping_target: str) -> int: """ @@ -401,10 +331,7 @@ async def _test_network_via_http(self, ping_target: str) -> int: try: ping_host = f"http://{ping_target}{DEFAULT_PING_HTTP_URI}" - async with self.session.head( - ping_host, - timeout=FALLBACK_PING_TIMEOUT, # type: ignore[arg-type,unused-ignore] - ): + async with self.session.head(ping_host, timeout=FALLBACK_PING_TIMEOUT): return 0 except (aiohttp.ClientError, asyncio.exceptions.TimeoutError, OSError): return 1 @@ -796,8 +723,10 @@ async def get_voice_client(self, channel: VoiceableChannel) -> discord.VoiceClie # Otherwise we need to connect to the given channel. max_timeout = VOICE_CLIENT_RECONNECT_TIMEOUT * VOICE_CLIENT_MAX_RETRY_CONNECT - for attempt in range(1, (VOICE_CLIENT_MAX_RETRY_CONNECT + 1)): - timeout = attempt * VOICE_CLIENT_RECONNECT_TIMEOUT + attempts = 0 + while True: + attempts += 1 + timeout = attempts * VOICE_CLIENT_RECONNECT_TIMEOUT if timeout > max_timeout: log.critical( "MusicBot is unable to connect to the channel right now: %s", @@ -820,7 +749,7 @@ async def get_voice_client(self, channel: VoiceableChannel) -> discord.VoiceClie except asyncio.exceptions.TimeoutError: log.warning( "Retrying connection after a timeout error (%s) while trying to connect to: %s", - attempt, + attempts, channel, ) except asyncio.exceptions.CancelledError as e: @@ -870,7 +799,7 @@ async def disconnect_voice_client(self, guild: discord.Guild) -> None: asyncio.exceptions.TimeoutError, ): if self.config.debug_mode: - log.warning("The disconnect failed or was cancelled.") + log.warning("The disconnect failed or was cancelledd.") # ensure the player is dead and gone. player.kill() @@ -1230,9 +1159,7 @@ async def on_player_pause( if player.session_progress > 1: await self.serialize_queue(player.voice_client.channel.guild) - self.create_task( - self.handle_player_inactivity(player), name="MB_HandleInactivePlayer" - ) + self.loop.create_task(self.handle_player_inactivity(player)) async def on_player_stop(self, player: MusicPlayer, **_: Any) -> None: """ @@ -1241,9 +1168,7 @@ async def on_player_stop(self, player: MusicPlayer, **_: Any) -> None: """ log.debug("Running on_player_stop") await self.update_now_playing_status() - self.create_task( - self.handle_player_inactivity(player), name="MB_HandleInactivePlayer" - ) + self.loop.create_task(self.handle_player_inactivity(player)) async def on_player_finished_playing(self, player: MusicPlayer, **_: Any) -> None: """ @@ -1291,19 +1216,7 @@ async def on_player_finished_playing(self, player: MusicPlayer, **_: Any) -> Non # avoid downloading the next entries if the user is absent and we are configured to skip. notice_sent = False # set a flag to avoid message spam. - while len(player.playlist): - log.everything( # type: ignore[attr-defined] - "Looping over queue to expunge songs with missing author..." - ) - - if not self.loop or (self.loop and self.loop.is_closed()): - log.debug("Event loop is closed, nothing else to do here.") - return - - if self.logout_called: - log.debug("Logout under way, ignoring this event.") - return - + while True: next_entry = player.playlist.peek() if not next_entry: @@ -1362,18 +1275,6 @@ async def on_player_finished_playing(self, player: MusicPlayer, **_: Any) -> Non ) while player.autoplaylist: - log.everything( # type: ignore[attr-defined] - "Looping over player autoplaylist..." - ) - - if not self.loop or (self.loop and self.loop.is_closed()): - log.debug("Event loop is closed, nothing else to do here.") - return - - if self.logout_called: - log.debug("Logout under way, ignoring this event.") - return - if self.config.auto_playlist_random: random.shuffle(player.autoplaylist) song_url = random.choice(player.autoplaylist) @@ -1401,7 +1302,7 @@ async def on_player_finished_playing(self, player: MusicPlayer, **_: Any) -> Non except youtube_dl.utils.DownloadError as e: log.error( - 'Error while processing song "%s": %s', + 'Error while downloading song "%s": %s', song_url, e, ) @@ -1469,7 +1370,6 @@ async def on_player_finished_playing(self, player: MusicPlayer, **_: Any) -> Non log.debug("Exception data for above error:", exc_info=True) continue break - # end of autoplaylist loop. if not self.server_data[guild.id].autoplaylist: log.warning("No playable songs in the autoplaylist, disabling.") @@ -1478,7 +1378,7 @@ async def on_player_finished_playing(self, player: MusicPlayer, **_: Any) -> Non else: # Don't serialize for autoplaylist events await self.serialize_queue(guild) - if not player.is_dead and not player.current_entry and len(player.playlist): + if not player.is_stopped and not player.is_dead: player.play(_continue=True) async def on_player_entry_added( @@ -1511,7 +1411,7 @@ async def on_player_entry_added( async def on_player_error( self, - player: MusicPlayer, + player: MusicPlayer, # pylint: disable=unused-argument entry: Optional[EntryTypes], ex: Optional[Exception], **_: Any, @@ -1519,43 +1419,15 @@ async def on_player_error( """ Event called by MusicPlayer when an entry throws an error. """ - # Log the exception according to entry or bare error. - if entry is not None: - log.exception( - "MusicPlayer exception for entry: %r", - entry, - exc_info=ex, - ) - else: - log.exception( - "MusicPlayer exception.", - exc_info=ex, - ) - - # Send a message to the calling channel if we can. if entry and entry.channel: song = entry.title or entry.url await self.safe_send_message( entry.channel, # TODO: i18n / UI stuff f"Playback failed for song: `{song}` due to error:\n```\n{ex}\n```", - expire_in=90, ) - - # Take care of auto-playlist related issues. - if entry and entry.from_auto_playlist: - log.info("Auto playlist track could not be played: %r", entry) - guild = player.voice_client.guild - await self.server_data[guild.id].autoplaylist.remove_track( - entry.info.input_subject, ex=ex, delete_from_ap=self.config.remove_ap - ) - - # If the party isn't rockin', don't bother knockin on my door. - if not player.is_dead: - if len(player.playlist): - player.play(_continue=True) - elif self.config.auto_playlist: - await self.on_player_finished_playing(player) + else: + log.exception("Player error", exc_info=ex) async def update_now_playing_status(self, set_offline: bool = False) -> None: """Inspects available players and ultimately fire change_presence()""" @@ -1587,12 +1459,8 @@ async def update_now_playing_status(self, set_offline: bool = False) -> None: return playing = sum(1 for p in self.players.values() if p.is_playing) - if self.config.status_include_paused: - paused = sum(1 for p in self.players.values() if p.is_paused) - total = len(self.players) - else: - paused = 0 - total = playing + paused = sum(1 for p in self.players.values() if p.is_paused) + total = len(self.players) def format_status_msg(player: Optional[MusicPlayer]) -> str: msg = self.config.status_message @@ -1633,12 +1501,8 @@ def format_status_msg(player: Optional[MusicPlayer]) -> str: # only 1 server is playing. elif playing: - player = None - for p in self.players.values(): - if p.is_playing: - player = p - break - if player and player.current_entry: + player = list(self.players.values())[0] + if player.current_entry: text = player.current_entry.title.strip()[:128] if self.config.status_message: text = format_status_msg(player) @@ -1651,12 +1515,8 @@ def format_status_msg(player: Optional[MusicPlayer]) -> str: # only 1 server is paused. elif paused: - player = None - for p in self.players.values(): - if p.is_paused: - player = p - break - if player and player.current_entry: + player = list(self.players.values())[0] + if player.current_entry: text = player.current_entry.title.strip()[:128] if self.config.status_message: text = format_status_msg(player) @@ -1855,17 +1715,14 @@ async def safe_send_message( "Got HTTPException trying to send message to %s: %s", dest, content ) - except aiohttp.client_exceptions.ClientError: - lfunc("Failed to send due to an HTTP error.") - finally: if not retry_after and self.config.delete_messages: if msg and expire_in: - self.create_task(self._wait_delete_msg(msg, expire_in)) + asyncio.ensure_future(self._wait_delete_msg(msg, expire_in)) if not retry_after and self.config.delete_invoking: if also_delete and isinstance(also_delete, discord.Message): - self.create_task(self._wait_delete_msg(also_delete, expire_in)) + asyncio.ensure_future(self._wait_delete_msg(also_delete, expire_in)) return msg @@ -1911,7 +1768,7 @@ async def safe_delete_message( "Rate limited message delete, retrying in %s seconds.", retry_after, ) - self.create_task(self._wait_delete_msg(message, retry_after)) + asyncio.ensure_future(self._wait_delete_msg(message, retry_after)) else: log.error("Rate limited message delete, but cannot retry!") @@ -1921,9 +1778,6 @@ async def safe_delete_message( "Got HTTPException trying to delete message: %s", message ) - except aiohttp.client_exceptions.ClientError: - lfunc("Failed to send due to an HTTP error.") - return None async def safe_edit_message( @@ -1992,9 +1846,6 @@ async def safe_edit_message( "Got HTTPException trying to edit message %s to: %s", message, new ) - except aiohttp.client_exceptions.ClientError: - lfunc("Failed to send due to an HTTP error.") - return None def _setup_windows_signal_handler(self) -> None: @@ -2013,9 +1864,9 @@ def set_windows_signal(sig: int, _frame: Any) -> None: # method used to periodically check for a signal, and process it. async def check_windows_signal() -> None: while True: - if self.logout_called: break + if self._os_signal is None: try: await asyncio.sleep(1) @@ -2028,10 +1879,7 @@ async def check_windows_signal() -> None: # register interrupt signal Ctrl+C to be trapped. signal.signal(signal.SIGINT, set_windows_signal) # and start the signal checking loop. - task_ref = asyncio.create_task( - check_windows_signal(), name="MB_WinInteruptChecker" - ) - setattr(self, "_mb_win_sig_checker_task", task_ref) + asyncio.create_task(check_windows_signal()) async def on_os_signal( self, sig: signal.Signals, _loop: asyncio.AbstractEventLoop @@ -2086,16 +1934,6 @@ async def run_musicbot(self) -> None: ) from e finally: - # Shut down the thread pool executor. - log.info("Waiting for download threads to finish up...") - # We can't kill the threads in ThreadPoolExecutor. User can Ctrl+C though. - # We can pass `wait=False` and carry on with "shutdown" but threads - # will stay until they're done. We wait to keep it clean... - tps_args: Dict[str, Any] = {} - if sys.version_info >= (3, 9): - tps_args["cancel_futures"] = True - self.downloader.thread_pool.shutdown(**tps_args) - # Inspect all waiting tasks and either cancel them or let them finish. pending_tasks = [] for task in asyncio.all_tasks(loop=self.loop): @@ -2109,8 +1947,9 @@ async def run_musicbot(self) -> None: if coro and hasattr(coro, "__qualname__"): coro_name = getattr(coro, "__qualname__", "[unknown]") - if tname.startswith("Signal_SIG") or coro_name.startswith( - "Client.close." + if ( + tname.startswith("Signal_SIG") + or coro_name == "URLPlaylistEntry._download" ): log.debug("Will wait for task: %s (%s)", tname, coro_name) pending_tasks.append(task) @@ -2403,7 +2242,7 @@ async def _on_ready_always(self) -> None: """ if self.on_ready_count > 0: log.debug("Event on_ready has fired %s times", self.on_ready_count) - self.create_task(self._on_ready_call_later(), name="MB_PostOnReady") + self.loop.create_task(self._on_ready_call_later()) async def _on_ready_call_later(self) -> None: """ @@ -2617,7 +2456,7 @@ async def handle_vc_inactivity(self, guild: discord.Guild) -> None: Manage a server-specific event timer when MusicBot's voice channel becomes idle, if the bot is configured to do so. """ - if not guild.voice_client or not guild.voice_client.channel: + if not guild.me.voice or not guild.me.voice.channel: log.warning( "Attempted to handle Voice Channel inactivity, but Bot is not in voice..." ) @@ -2632,8 +2471,8 @@ async def handle_vc_inactivity(self, guild: discord.Guild) -> None: try: chname = "Unknown" - if hasattr(guild.voice_client.channel, "name"): - chname = guild.voice_client.channel.name + if guild.me.voice.channel: + chname = guild.me.voice.channel.name log.info( "Channel activity waiting %d seconds to leave channel: %s", @@ -2645,18 +2484,16 @@ async def handle_vc_inactivity(self, guild: discord.Guild) -> None: ) except asyncio.TimeoutError: # could timeout after a disconnect. - if guild.voice_client and isinstance( - guild.voice_client.channel, (discord.VoiceChannel, discord.StageChannel) - ): + if guild.me.voice and guild.me.voice.channel: log.info( "Channel activity timer for %s has expired. Disconnecting.", guild.name, ) - await self.on_inactivity_timeout_expired(guild.voice_client.channel) + await self.on_inactivity_timeout_expired(guild.me.voice.channel) else: log.info( "Channel activity timer canceled for: %s in %s", - getattr(guild.voice_client.channel, "name", guild.voice_client.channel), + guild.me.voice.channel.name, guild.name, ) finally: @@ -2668,9 +2505,6 @@ async def handle_player_inactivity(self, player: MusicPlayer) -> None: Manage a server-specific event timer when it's MusicPlayer becomes idle, if the bot is configured to do so. """ - if self.logout_called: - return - if not self.config.leave_player_inactive_for: return channel = player.voice_client.channel @@ -2702,18 +2536,11 @@ async def handle_player_inactivity(self, player: MusicPlayer) -> None: [event.wait()], timeout=self.config.leave_player_inactive_for ) except asyncio.TimeoutError: - if not player.is_playing and player.voice_client.is_connected(): - log.info( - "Player activity timer for %s has expired. Disconnecting.", - guild.name, - ) - await self.on_inactivity_timeout_expired(channel) - else: - log.info( - "Player activity timer canceled for: %s in %s", - channel.name, - guild.name, - ) + log.info( + "Player activity timer for %s has expired. Disconnecting.", + guild.name, + ) + await self.on_inactivity_timeout_expired(channel) else: log.info( "Player activity timer canceled for: %s in %s", @@ -3100,18 +2927,14 @@ async def cmd_autoplaylist( guild: discord.Guild, author: discord.Member, _player: Optional[MusicPlayer], - player: MusicPlayer, option: str, opt_url: str = "", ) -> CommandResponse: """ Usage: - {command_prefix}autoplaylist [+ | - | add | remove] [url] + {command_prefix}autoplaylist [ + | - | add | remove] [url] Adds or removes the specified song or currently playing song to/from the current playlist. - {command_prefix}autoplaylist [+ all | add all] - Adds the entire queue to the guilds playlist. - {command_prefix}autoplaylist show Show a list of existing playlist files. @@ -3137,39 +2960,6 @@ def _get_url() -> str: ) return url - if option in ["+", "add"] and opt_url.lower() == "all": - if not player.playlist.entries: - raise exceptions.CommandError( - self.str.get( - "cmd-autoplaylist-add-all-empty-queue", - "The queue is empty. Add some songs with `{0}play`!", - ).format(self.server_data[guild.id].command_prefix), - expire_in=30, - ) - - added_songs = set() - for e in player.playlist.entries: - if e.url not in self.server_data[guild.id].autoplaylist: - await self.server_data[guild.id].autoplaylist.add_track(e.url) - added_songs.add(e.url) - - if not added_songs: - return Response( - self.str.get( - "cmd-save-all-exist", - "All songs in the queue are already in the autoplaylist.", - ), - delete_after=20, - ) - - return Response( - self.str.get( - "cmd-save-success-multiple", - "Added {0} songs to the autoplaylist.", - ).format(len(added_songs)), - delete_after=30, - ) - if option in ["+", "add"]: url = _get_url() self._do_song_blocklist_check(url) @@ -3406,9 +3196,8 @@ async def _handle_guild_auto_pause(self, player: MusicPlayer, _lc: int = 0) -> N return if f_player is not None: - self.create_task( - self._handle_guild_auto_pause(f_player, _lc=_lc), - name="MB_HandleGuildAutoPause", + self.loop.create_task( + self._handle_guild_auto_pause(f_player, _lc=_lc) ) return @@ -3441,9 +3230,6 @@ async def _do_cmd_unpause_check( This function should not be called from _cmd_play(). """ - if not self.config.auto_unpause_on_play: - return - if not player or not player.voice_client or not player.voice_client.channel: return @@ -3628,11 +3414,7 @@ async def cmd_playnow( ) async def cmd_seek( - self, - guild: discord.Guild, - _player: Optional[MusicPlayer], - leftover_args: List[str], - seek_time: str = "", + self, guild: discord.Guild, _player: Optional[MusicPlayer], seek_time: str = "" ) -> CommandResponse: """ Usage: @@ -3643,7 +3425,6 @@ async def cmd_seek( Time should be given in seconds, fractional seconds are accepted. Due to codec specifics in ffmpeg, this may not be accurate. """ - # TODO: perhaps a means of listing chapters and seeking to them. like `seek ch1` & `seek list` if not _player or not _player.current_entry: raise exceptions.CommandError( "Cannot use seek if there is nothing playing.", @@ -3664,12 +3445,6 @@ async def cmd_seek( expire_in=30, ) - # take in all potential arguments. - if leftover_args: - args = leftover_args - args.insert(0, seek_time) - seek_time = " ".join(args) - if not seek_time: raise exceptions.CommandError( "Cannot use seek without a time to position playback.", @@ -3678,13 +3453,13 @@ async def cmd_seek( relative_seek: int = 0 f_seek_time: float = 0 - if seek_time.startswith("-"): - relative_seek = -1 - if seek_time.startswith("+"): - relative_seek = 1 - if "." in seek_time: try: + if seek_time.startswith("-"): + relative_seek = -1 + if seek_time.startswith("+"): + relative_seek = 1 + p1, p2 = seek_time.rsplit(".", maxsplit=1) i_seek_time = format_time_to_seconds(p1) f_seek_time = float(f"0.{p2}") @@ -3702,9 +3477,8 @@ async def cmd_seek( if f_seek_time > _player.current_entry.duration or f_seek_time < 0: td = format_song_duration(_player.current_entry.duration_td) - prog = format_song_duration(_player.progress) raise exceptions.CommandError( - f"Cannot seek to `{seek_time}` (`{f_seek_time:.2f}` seconds) in the current track with a length of `{prog} / {td}`", + f"Cannot seek to `{seek_time}` in the current track with a length of `{td}`", expire_in=30, ) @@ -3722,7 +3496,7 @@ async def cmd_seek( _player.skip() return Response( - f"Seeking to time `{seek_time}` (`{f_seek_time:.2f}` seconds) in the current song.", + f"Seeking to time `{seek_time}` (`{f_seek_time}` seconds) in the current song.", delete_after=30, ) @@ -3734,7 +3508,8 @@ async def cmd_repeat( {command_prefix}repeat [all | playlist | song | on | off] Toggles playlist or song looping. - If no option is provided the bot will toggle through playlist looping, song looping, and looping off. + If no option is provided the current song will be repeated. + If no option is provided and the song is already repeating, repeating will be turned off. """ # TODO: this command needs TLC. @@ -3992,7 +3767,7 @@ def _check_react(reaction: discord.Reaction, user: discord.Member) -> bool: if matches: pl_url = "https://www.youtube.com/playlist?" + matches.group(2) ignore_vid = matches.group(1) - self.create_task( + asyncio.ensure_future( _prompt_for_playing( # TODO: i18n / UI stuff f"This link contains a Playlist ID:\n`{song_url}`\n\nDo you want to queue the playlist too?", @@ -4155,11 +3930,18 @@ async def _cmd_play( ) # ensure the extractor has been allowed via permissions. - permissions.can_use_extractor(info.extractor) + if info.extractor not in permissions.extractors and permissions.extractors: + raise exceptions.PermissionsError( + self.str.get( + "cmd-play-badextractor", + "You do not have permission to play the requested media. Service `{}` is not permitted.", + ).format(info.extractor), + expire_in=30, + ) # if the result has "entries" but it's empty, it might be a failed search. if "entries" in info and not info.entry_count: - if info.extractor.startswith("youtube:search"): + if info.extractor == "youtube:search": # TOOD: UI, i18n stuff raise exceptions.CommandError( f"Youtube search returned no results for: {song_url}" @@ -4181,7 +3963,7 @@ async def _cmd_play( info, channel=channel, author=author, - head=head, + head=False, ignore_video_id=ignore_video_id, ) @@ -4216,7 +3998,7 @@ async def _cmd_play( else: # youtube:playlist extractor but it's actually an entry # ^ wish I had a URL for this one. - if info.get("extractor", "").startswith("youtube:playlist"): + if info.get("extractor", "") == "youtube:playlist": log.noise( # type: ignore[attr-defined] "Extracted an entry with youtube:playlist as extractor key" ) @@ -4251,7 +4033,6 @@ async def _cmd_play( if position == 1 and player.is_stopped: position = self.str.get("cmd-play-next", "Up next!") reply_text %= (btext, position) - player.play() # shift the playing track to the end of queue and skip current playback. elif skip_playing and player.is_playing and player.current_entry: @@ -4313,7 +4094,9 @@ async def cmd_stream( await self._do_cmd_unpause_check(_player, channel, author, message) - if permissions.summonplay and not _player: + if _player: + player = _player + elif permissions.summonplay: response = await self.cmd_summon(guild, author, message) if response: if self.config.embeds: @@ -4337,9 +4120,9 @@ async def cmd_stream( ) p = self.get_player_in(guild) if p: - _player = p + player = p - if not _player: + if not player: prefix = self.server_data[guild.id].command_prefix raise exceptions.CommandError( "The bot is not in a voice channel. " @@ -4348,7 +4131,7 @@ async def cmd_stream( if ( permissions.max_songs - and _player.playlist.count_for_user(author) >= permissions.max_songs + and player.playlist.count_for_user(author) >= permissions.max_songs ): raise exceptions.PermissionsError( self.str.get( @@ -4358,7 +4141,7 @@ async def cmd_stream( expire_in=30, ) - if _player.karaoke_mode and not permissions.bypass_karaoke_mode: + if player.karaoke_mode and not permissions.bypass_karaoke_mode: raise exceptions.PermissionsError( self.str.get( "karaoke-enabled", @@ -4392,13 +4175,10 @@ async def cmd_stream( if info.url != info.title: self._do_song_blocklist_check(info.title) - await _player.playlist.add_stream_from_info( + await player.playlist.add_stream_from_info( info, channel=channel, author=author, head=False ) - if _player.is_stopped: - _player.play() - return Response( self.str.get("cmd-stream-success", "Streaming."), delete_after=6 ) @@ -4856,55 +4636,49 @@ async def cmd_summon( Call the bot to the summoner's voice channel. """ - lock_key = f"summon:{guild.id}" + # @TheerapakG: Maybe summon should have async lock? - if self.aiolocks[lock_key].locked(): - log.debug("Waiting for summon lock: %s", lock_key) - - async with self.aiolocks[lock_key]: - log.debug("Summon lock acquired for: %s", lock_key) - - if not author.voice or not author.voice.channel: - raise exceptions.CommandError( - self.str.get( - "cmd-summon-novc", - "You are not connected to voice. Try joining a voice channel!", - ) + if not author.voice or not author.voice.channel: + raise exceptions.CommandError( + self.str.get( + "cmd-summon-novc", + "You are not connected to voice. Try joining a voice channel!", ) + ) - player = self.get_player_in(guild) - if player and player.voice_client and guild == author.voice.channel.guild: - # NOTE: .move_to() does not support setting self-deafen flag, - # nor respect flags set in initial connect call. - # await player.voice_client.move_to(author.voice.channel) - await guild.change_voice_state( - channel=author.voice.channel, - self_deaf=self.config.self_deafen, - ) - else: - player = await self.get_player( - author.voice.channel, - create=True, - deserialize=self.config.persistent_queue, - ) + player = self.get_player_in(guild) + if player and player.voice_client and guild == author.voice.channel.guild: + # NOTE: .move_to() does not support setting self-deafen flag, + # nor respect flags set in initial connect call. + # await player.voice_client.move_to(author.voice.channel) + await guild.change_voice_state( + channel=author.voice.channel, + self_deaf=self.config.self_deafen, + ) + else: + player = await self.get_player( + author.voice.channel, + create=True, + deserialize=self.config.persistent_queue, + ) - if player.is_stopped: - player.play() + if player.is_stopped: + player.play() - log.info( - "Joining %s/%s", - author.voice.channel.guild.name, - author.voice.channel.name, - ) + log.info( + "Joining %s/%s", + author.voice.channel.guild.name, + author.voice.channel.name, + ) - self.server_data[guild.id].last_np_msg = message + self.server_data[guild.id].last_np_msg = message - return Response( - self.str.get("cmd-summon-reply", "Connected to `{0.name}`").format( - author.voice.channel - ), - delete_after=30, - ) + return Response( + self.str.get("cmd-summon-reply", "Connected to `{0.name}`").format( + author.voice.channel + ), + delete_after=30, + ) async def cmd_follow( self, @@ -5053,7 +4827,7 @@ async def cmd_clear( player.playlist.clear() return Response( - self.str.get("cmd-clear-reply", "Cleared `{0}'s` queue").format( + self.str.get("cmd-clear-reply", "Cleared `{0}`'s queue").format( player.voice_client.channel.guild ), delete_after=20, @@ -5556,7 +5330,6 @@ async def cmd_config( "show", "set", "reload", - "reset", ] if option not in valid_options: raise exceptions.CommandError( @@ -5638,7 +5411,7 @@ async def cmd_config( ) from e # sub commands beyond here need 2 leftover_args - if option in ["help", "show", "save", "set", "reset"]: + if option in ["help", "show", "save", "set"]: largs = len(leftover_args) if ( self.config.register.resolver_available @@ -5768,35 +5541,6 @@ async def cmd_config( delete_after=30, ) - # reset an option to default value as defined in ConfigDefaults - if option == "reset": - if not opt.editable: - raise exceptions.CommandError( - f"Option `{opt}` is not editable. Cannot reset to default.", - expire_in=30, - ) - - # Use the default value from the option object - default_value = self.config.register.to_ini(opt, use_default=True) - - # Prepare a user-friendly message for the reset operation - # TODO look into option registry display code for use here - reset_value_display = default_value if default_value else "an empty set" - - log.debug("Resetting %s to default %s", opt, default_value) - async with self.aiolocks["config_update"]: - updated = self.config.update_option(opt, default_value) - if not updated: - raise exceptions.CommandError( - f"Option `{opt}` was not reset to default!", - expire_in=30, - ) - return Response( - f"Option `{opt}` was reset to its default value `{reset_value_display}`.\n" - f"To save the change use `config save {opt.section} {opt.option}`", - delete_after=30, - ) - return None @owner_only @@ -5809,7 +5553,7 @@ async def cmd_option( Changes a config option without restarting the bot. Changes aren't permanent and only last until the bot is restarted. To make permanent changes, edit the - config file or use the config set and save commands. + config file. Valid options: autoplaylist, save_videos, now_playing_mentions, auto_playlist_random, auto_pause, @@ -6391,9 +6135,8 @@ async def cmd_perms( Sends the user a list of their permissions, or the permissions of the user specified. """ - user: Optional[MessageAuthor] = None if user_mentions: - user = user_mentions[0] + user = user_mentions[0] # type: Union[discord.User, discord.Member] if not user_mentions and not target: user = author @@ -6403,20 +6146,15 @@ async def cmd_perms( if getuser is None: try: user = await self.fetch_user(int(target)) - except (discord.NotFound, ValueError) as e: - raise exceptions.CommandError( - "Invalid user ID or server nickname, please double check the ID and try again.", - expire_in=30, - ) from e + except (discord.NotFound, ValueError): + return Response( + "Invalid user ID or server nickname, please double check all typing and try again.", + reply=False, + delete_after=30, + ) else: user = getuser - if not user: - raise exceptions.CommandError( - "Could not determine the discord User. Try again.", - expire_in=30, - ) - permissions = self.permissions.for_user(user) if user == author: @@ -7096,30 +6834,10 @@ async def cmd_objgraph( @dev_only async def cmd_debug( - self, - _player: Optional[MusicPlayer], - message: discord.Message, # pylint: disable=unused-argument - channel: GuildMessageableChannels, # pylint: disable=unused-argument - guild: discord.Guild, # pylint: disable=unused-argument - author: discord.Member, # pylint: disable=unused-argument - permissions: PermissionGroup, # pylint: disable=unused-argument - *, - data: str, + self, _player: Optional[MusicPlayer], *, data: str ) -> CommandResponse: """ - Usage: - {command_prefix}debug [one line of code] - OR - {command_prefix}debug ` ` `py - many lines - of python code. - ` ` ` - - This command will execute python code in the commands scope. - First eval() is attempted, if exceptions are thrown exec() is tried. - If eval is successful, its return value is displayed. - If exec is successful, a value can be set to local variable `result` - and that value will be returned. + Evaluate or otherwise execute the python code in `data` """ codeblock = "```py\n{}\n```" result = None @@ -7128,85 +6846,24 @@ async def cmd_debug( data = "\n".join(data.rstrip("`\n").split("\n")[1:]) code = data.strip("` \n") + + scope = globals().copy() + scope.update({"self": self}) + try: - run_type = "eval" - result = eval(code) # pylint: disable=eval-used - log.debug("Debug code ran with eval().") + result = eval(code, scope) # pylint: disable=eval-used except Exception: # pylint: disable=broad-exception-caught try: - run_type = "exec" - # exec needs a fake locals so we can get `result` from it. - lscope: Dict[str, Any] = {} - # exec also needs locals() to be in globals() for access to work. - gscope = globals().copy() - gscope.update(locals().copy()) - exec(code, gscope, lscope) # pylint: disable=exec-used - log.debug("Debug code ran with exec().") - result = lscope.get("result", result) - except Exception as e: - log.exception("Debug code failed to execute.") - raise exceptions.CommandError( - f"Failed to execute debug code.\n{codeblock.format(code)}\n" - f"Exception: ```\n{type(e).__name__}:\n{str(e)}```" - ) from e + exec(code, scope) # pylint: disable=exec-used + except Exception as e: # pylint: disable=broad-exception-caught + traceback.print_exc(chain=False) + type_name = type(e).__name__ + return Response(f"{type_name}: {str(e)}") if asyncio.iscoroutine(result): result = await result - return Response(f"**{run_type}() Result:**\n{codeblock.format(result)}") - - @dev_only - async def cmd_makemarkdown( - self, - channel: MessageableChannel, - author: discord.Member, - cfg: str = "opts", - ) -> CommandResponse: - """ - Command to generate markdown for options and permissions files. - Contents are generated from code and not pulled from the files! - """ - valid_opts = ["opts", "perms"] - if cfg not in valid_opts: - opts = ", ".join([f"`{o}`" for o in valid_opts]) - raise exceptions.CommandError(f"Option must be one of: {opts}") - - filename = "config_options.md" - msg_str = "Config options described in Markdown:\n" - if cfg == "perms": - filename = "config_permissions.md" - msg_str = "Permissions described in Markdown:\n" - config_md = self.permissions.register.export_markdown() - else: - config_md = self.config.register.export_markdown() - - sent_to_channel = None - - # TODO: refactor this in favor of safe_send_message doing it all. - with BytesIO() as fcontent: - fcontent.write(config_md.encode("utf8")) - fcontent.seek(0) - datafile = discord.File(fcontent, filename=filename) - - try: - # try to DM. this could fail for users with strict privacy settings. - # or users who just can't get direct messages. - await author.send(msg_str, file=datafile) - - except discord.errors.HTTPException as e: - if e.code == 50007: # cannot send to this user. - log.debug("DM failed, sending in channel instead.") - sent_to_channel = await channel.send( - msg_str, - file=datafile, - ) - else: - raise - if not sent_to_channel: - return Response( - "Sent a message with the requested config markdown.", delete_after=20 - ) - return None + return Response(codeblock.format(result)) @owner_only async def cmd_checkupdates(self, channel: MessageableChannel) -> CommandResponse: @@ -7215,6 +6872,7 @@ async def cmd_checkupdates(self, channel: MessageableChannel) -> CommandResponse {command_prefix}checkupdates Display the current bot version and check for updates to MusicBot or dependencies. + The option `GitUpdatesBranch` must be set to check for updates to MusicBot. """ git_status = "" pip_status = "" @@ -7333,12 +6991,8 @@ async def cmd_uptime(self) -> CommandResponse: """ uptime = time.time() - self._init_time delta = format_song_duration(uptime) - name = DEFAULT_BOT_NAME - if self.user: - name = self.user.name return Response( - f"{name} has been up for `{delta}`", - delete_after=30, + f"MusicBot has been up for `{delta}`", ) @owner_only @@ -7407,101 +7061,6 @@ async def cmd_botversion(self) -> CommandResponse: delete_after=30, ) - @owner_only - async def cmd_setcookies( - self, message: discord.Message, opt: str = "" - ) -> CommandResponse: - """ - Usage: - {command_prefix}setcookies [ off | on ] - Disable or enable cookies.txt file without deleting it. - - {command_prefix}setcookies - Update the cookies.txt file using a supplied attachment. - - Note: - When updating cookies, you must upload a file named cookies.txt - If cookies are disabled, uploading will enable the feature. - Uploads will delete existing cookies, including disabled cookies file. - - WARNING: - Copying cookies can risk exposing your personal information or accounts, - and may result in account bans or theft if you are not careful. - It is not recommended due to these risks, and you should not use this - feature if you do not understand how to avoid the risks. - """ - opt = opt.lower() - if opt == "on": - if self.downloader.cookies_enabled: - raise exceptions.CommandError("Cookies already enabled.") - - if ( - not self.config.disabled_cookies_path.is_file() - and not self.config.cookies_path.is_file() - ): - raise exceptions.CommandError( - "Cookies must be uploaded to be enabled. (Missing cookies file.)" - ) - - # check for cookies file and use it. - if self.config.cookies_path.is_file(): - self.downloader.enable_ytdl_cookies() - else: - # or rename the file as needed. - try: - self.config.disabled_cookies_path.rename(self.config.cookies_path) - self.downloader.enable_ytdl_cookies() - except OSError as e: - raise exceptions.CommandError( - f"Could not enable cookies due to error: {str(e)}" - ) from e - return Response("Cookies have been enabled.") - - if opt == "off": - if self.downloader.cookies_enabled: - self.downloader.disable_ytdl_cookies() - - if self.config.cookies_path.is_file(): - try: - self.config.cookies_path.rename(self.config.disabled_cookies_path) - except OSError as e: - raise exceptions.CommandError( - f"Could not rename cookies file due to error: {str(e)}\n" - "Cookies temporarily disabled and will be re-enabled on next restart." - ) from e - return Response("Cookies have been disabled.") - - # check for attached files and inspect them for use. - if not message.attachments: - raise exceptions.CommandError( - "No attached uploads were found, try again while uploading a cookie file." - ) - - # check for a disabled cookies file and remove it. - if self.config.disabled_cookies_path.is_file(): - try: - self.config.disabled_cookies_path.unlink() - except OSError as e: - log.warning("Could not remove old, disabled cookies file: %s", str(e)) - - # simply save the uploaded file in attachment 1 as cookies.txt. - try: - await message.attachments[0].save(self.config.cookies_path) - except discord.HTTPException as e: - raise exceptions.CommandError( - f"Error downloading the cookies file from discord: {str(e)}" - ) from e - except OSError as e: - raise exceptions.CommandError( - f"Could not save cookies to disk: {str(e)}" - ) from e - - # enable cookies if it is not already. - if not self.downloader.cookies_enabled: - self.downloader.enable_ytdl_cookies() - - return Response("Cookies uploaded and enabled.") - async def on_message(self, message: discord.Message) -> None: """ Event called by discord.py when any message is sent to/around the bot. @@ -7581,22 +7140,14 @@ async def on_message(self, message: discord.Message) -> None: else: args = [] - # Check if the incomming command is a "natural" command. handler = getattr(self, "cmd_" + command, None) if not handler: - # If no natural command was found, check for aliases when enabled. + # alias handler if self.config.usealias: - # log.debug("Checking for alias with: %s", command) - command, alias_arg_str = self.aliases.get(command) + command = self.aliases.get(command) handler = getattr(self, "cmd_" + command, None) if not handler: return - # log.debug("Alias found: %s %s", command, alias_arg_str) - # Complex aliases may have args of their own. - # We assume the user args go after the alias args. - if alias_arg_str: - args = alias_arg_str.split(" ") + args - # Or ignore aliases, and this non-existent command. else: return @@ -7916,7 +7467,7 @@ async def on_inactivity_timeout_expired( """ guild = voice_channel.guild - if isinstance(voice_channel, (discord.VoiceChannel, discord.StageChannel)): + if voice_channel: last_np_msg = self.server_data[guild.id].last_np_msg if last_np_msg is not None and last_np_msg.channel: channel = last_np_msg.channel @@ -7994,9 +7545,7 @@ async def on_voice_state_update( "%s has been detected as empty. Handling timeouts.", before.channel.name, ) - self.create_task( - self.handle_vc_inactivity(guild), name="MB_HandleInactiveVC" - ) + self.loop.create_task(self.handle_vc_inactivity(guild)) elif after.channel and member != self.user: if self.user in after.channel.members: if event.is_active(): @@ -8018,9 +7567,7 @@ async def on_voice_state_update( "The bot got moved and the voice channel %s is empty. Handling timeouts.", after.channel.name, ) - self.create_task( - self.handle_vc_inactivity(guild), name="MB_HandleInactiveVC" - ) + self.loop.create_task(self.handle_vc_inactivity(guild)) else: if event.is_active(): log.info( diff --git a/musicbot/config.py b/musicbot/config.py index 37c113a32..50ca25f86 100644 --- a/musicbot/config.py +++ b/musicbot/config.py @@ -5,13 +5,11 @@ import pathlib import shutil import sys -import time from typing import ( TYPE_CHECKING, - Any, + Dict, Iterable, List, - Mapping, Optional, Set, Tuple, @@ -22,9 +20,7 @@ import configupdater from .constants import ( - DATA_FILE_COOKIES, DATA_FILE_SERVERS, - DATA_FILE_YTDLP_OAUTH2, DEFAULT_AUDIO_CACHE_DIR, DEFAULT_DATA_DIR, DEFAULT_FOOTER_TEXT, @@ -58,7 +54,7 @@ from .permissions import Permissions # Type for ConfigParser.get(... vars) argument -ConfVars = Optional[Mapping[str, str]] +ConfVars = Optional[Dict[str, str]] # Types considered valid for config options. DebugLevel = Tuple[str, int] RegTypes = Union[str, int, bool, float, Set[int], Set[str], DebugLevel, pathlib.Path] @@ -429,17 +425,6 @@ def __init__(self, config_file: pathlib.Path) -> None: getter="getboolean", comment="Allow MusicBot to save the song queue, so they will survive restarts.", ) - self.pre_download_next_song: bool = self.register.init_option( - section="MusicBot", - option="PreDownloadNextSong", - dest="pre_download_next_song", - default=ConfigDefaults.pre_download_next_song, - getter="getboolean", - comment=( - "Enable MusicBot to download the next song in the queue while a song is playing.\n" - "Currently this option does not apply to auto-playlist or songs added to an empty queue." - ), - ) self.status_message: str = self.register.init_option( section="MusicBot", option="StatusMessage", @@ -460,14 +445,6 @@ def __init__(self, config_file: pathlib.Path) -> None: " {p0_url} = The track url for the currently playing track." ), ) - self.status_include_paused: bool = self.register.init_option( - section="MusicBot", - option="StatusIncludePaused", - dest="status_include_paused", - default=ConfigDefaults.status_include_paused, - getter="getboolean", - comment="If enabled, status messages will report info on paused players.", - ) self.write_current_song: bool = self.register.init_option( section="MusicBot", option="WriteCurrentSong", @@ -678,101 +655,6 @@ def __init__(self, config_file: pathlib.Path) -> None: ), ) - self.auto_unpause_on_play: bool = self.register.init_option( - section="MusicBot", - option="UnpausePlayerOnPlay", - dest="auto_unpause_on_play", - default=ConfigDefaults.auto_unpause_on_play, - getter="getboolean", - comment="Allow MusicBot to automatically unpause when play commands are used.", - ) - - # This is likely to turn into one option for each separate part. - # Due to how the support for protocols differs from part to part. - # ytdlp has its own option that uses requests. - # aiohttp requires per-call proxy parameter be set. - # and ffmpeg with stream mode also makes its own direct connections. - # top it off with proxy for the API. Once we tip the proxy iceberg... - # In some cases, users might get away with setting environment variables, - # HTTP_PROXY, HTTPS_PROXY, and others for ytdlp and ffmpeg. - # While aiohttp would require some other param or config file for that. - self.ytdlp_proxy: str = self.register.init_option( - section="MusicBot", - option="YtdlpProxy", - dest="ytdlp_proxy", - default=ConfigDefaults.ytdlp_proxy, - comment=( - "Experimental, HTTP/HTTPS proxy settings to use with ytdlp media downloader.\n" - "The value set here is passed to `ytdlp --proxy` and aiohttp header checking.\n" - "Leave blank to disable." - ), - ) - self.ytdlp_user_agent: str = self.register.init_option( - section="MusicBot", - option="YtdlpUserAgent", - dest="ytdlp_user_agent", - default=ConfigDefaults.ytdlp_user_agent, - comment=( - "Experimental option to set a static User-Agent header in yt-dlp.\n" - "It is not typically recommended by yt-dlp to change the UA string.\n" - "For examples of what you might put here, check the following two links:\n" - " https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent \n" - " https://www.useragents.me/ \n" - "Leave blank to use default, dynamically generated UA strings." - ), - ) - self.ytdlp_use_oauth2: bool = self.register.init_option( - section="MusicBot", - option="YtdlpUseOAuth2", - dest="ytdlp_use_oauth2", - default=ConfigDefaults.ytdlp_use_oauth2, - getter="getboolean", - comment=( - "Experimental option to enable yt-dlp to use a YouTube account via OAuth2.\n" - "When enabled, you must use the generated URL and code to authorize an account.\n" - "The authorization token is then stored in the " - f"`{DEFAULT_DATA_DIR}/{DATA_FILE_YTDLP_OAUTH2}` file.\n" - "This option should not be used when cookies are enabled.\n" - "Using a personal account may not be recommended.\n" - "Set yes to enable or no to disable." - ), - ) - self.ytdlp_oauth2_client_id: str = self.register.init_option( - section="Credentials", - option="YtdlpOAuth2ClientID", - dest="ytdlp_oauth2_client_id", - getter="getstr", - default=ConfigDefaults.ytdlp_oauth2_client_id, - comment=( - "Sets the YouTube API Client ID, used by Yt-dlp OAuth2 plugin.\n" - "Optional, unless built-in credentials are not working." - ), - ) - self.ytdlp_oauth2_client_secret: str = self.register.init_option( - section="Credentials", - option="YtdlpOAuth2ClientSecret", - dest="ytdlp_oauth2_client_secret", - getter="getstr", - default=ConfigDefaults.ytdlp_oauth2_client_secret, - comment=( - "Sets the YouTube API Client Secret key, used by Yt-dlp OAuth2 plugin.\n" - "Optional, unless YtdlpOAuth2ClientID is set." - ), - ) - self.ytdlp_oauth2_url: str = self.register.init_option( - section="MusicBot", - option="YtdlpOAuth2URL", - dest="ytdlp_oauth2_url", - getter="getstr", - default=ConfigDefaults.ytdlp_oauth2_url, - comment=( - "Optional youtube video URL used at start-up for triggering OAuth2 authorization.\n" - "This starts the OAuth2 prompt early, rather than waiting for a song request.\n" - "The URL set here should be an accessible youtube video URL.\n" - "Authorization must be completed before start-up will continue when this is set." - ), - ) - self.user_blocklist_enabled: bool = self.register.init_option( section="MusicBot", option="EnableUserBlocklist", @@ -789,7 +671,7 @@ def __init__(self, config_file: pathlib.Path) -> None: getter="getpathlike", comment="An optional file path to a text file listing Discord User IDs, one per line.", ) - self.user_blocklist: UserBlocklist = UserBlocklist(self.user_blocklist_file) + self.user_blocklist: "UserBlocklist" = UserBlocklist(self.user_blocklist_file) self.song_blocklist_enabled: bool = self.register.init_option( section="MusicBot", @@ -810,7 +692,7 @@ def __init__(self, config_file: pathlib.Path) -> None: "Any song title or URL that contains any line in the list will be blocked." ), ) - self.song_blocklist: SongBlocklist = SongBlocklist(self.song_blocklist_file) + self.song_blocklist: "SongBlocklist" = SongBlocklist(self.song_blocklist_file) self.auto_playlist_dir: pathlib.Path = self.register.init_option( section="Files", @@ -889,10 +771,6 @@ def __init__(self, config_file: pathlib.Path) -> None: # Convert all path constants into config as pathlib.Path objects. self.data_path = pathlib.Path(DEFAULT_DATA_DIR).resolve() self.server_names_path = self.data_path.joinpath(DATA_FILE_SERVERS) - self.cookies_path = self.data_path.joinpath(DATA_FILE_COOKIES) - self.disabled_cookies_path = self.cookies_path.parent.joinpath( - f"_{self.cookies_path.name}" - ) # Validate the config settings match destination values. self.register.validate_register_destinations() @@ -1031,17 +909,6 @@ def run_checks(self) -> None: if self.enable_local_media and not self.media_file_dir.is_dir(): self.media_file_dir.mkdir(exist_ok=True) - if self.cookies_path.is_file(): - log.warning( - "Cookies TXT file detected. MusicBot will pass them to yt-dlp.\n" - "Cookies are not recommended, may not be supported, and may totally break.\n" - "Copying cookies from your web-browser risks exposing personal data and \n" - "in the best case can result in your accounts being banned!\n\n" - "You have been warned! Good Luck! \U0001F596\n" - ) - # make sure the user sees this. - time.sleep(3) - async def async_validate(self, bot: "MusicBot") -> None: """ Validation logic for bot settings that depends on data from async services. @@ -1273,7 +1140,6 @@ class ConfigDefaults: delete_invoking: bool = False persistent_queue: bool = True status_message: str = "" - status_include_paused: bool = False write_current_song: bool = False allow_author_skip: bool = True use_experimental_equalization: bool = False @@ -1298,21 +1164,6 @@ class ConfigDefaults: enable_local_media: bool = False enable_queue_history_global: bool = False enable_queue_history_guilds: bool = False - auto_unpause_on_play: bool = False - ytdlp_proxy: str = "" - ytdlp_user_agent: str = "" - ytdlp_oauth2_url: str = "" - # These client details are taken from the original plugin code. - # Likely that they wont work forever, should be removed, but testing for now. - # PR #21 to get these from YT-TV seems broken already. Maybe I am stupid. - # TODO: remove these when a working method to reliably extract them is available. - ytdlp_oauth2_client_id: str = ( - "861556708454-d6dlm3lh05idd8npek18k6be8ba3oc68.apps.googleusercontent.com" - ) - ytdlp_oauth2_client_secret: str = "SboVhoG9s0rNafixCSGGKXAT" - - ytdlp_use_oauth2: bool = False - pre_download_next_song: bool = True song_blocklist: Set[str] = set() user_blocklist: Set[int] = set() @@ -1782,54 +1633,12 @@ def _value_to_ini(self, conf_value: RegTypes, getter: str) -> str: if getter == "getpathlike": return str(conf_value) - # NOTE: debug_level is not editable, but can be displayed. - if ( - getter == "getdebuglevel" - and isinstance(conf_value, tuple) - and isinstance(conf_value[0], str) - and isinstance(conf_value[1], int) - ): - return str(logging.getLevelName(conf_value[1])) + # NOTE: Added for completeness but unused as debug_level is not editable. + if getter == "getdebuglevel" and isinstance(conf_value, int): + return str(logging.getLevelName(conf_value)) return str(conf_value) - def export_markdown(self) -> str: - """ - Transform registered config options into markdown. - This is intended to generate documentation from the code. - Currently will print options in order they are registered. - But prints sections in the order ConfigParser loads them. - """ - md_sections = {} - for opt in self.option_list: - dval = self.to_ini(opt, use_default=True) - if dval.strip() == "": - if opt.empty_display_val: - dval = f"`{opt.empty_display_val}`" - else: - dval = "*empty*" - else: - dval = f"`{dval}`" - - # fmt: off - md_option = ( - f"#### {opt.option}\n" - f"{opt.comment} \n" - f"**Default Value:** {dval} \n\n" - ) - # fmt: on - if opt.section not in md_sections: - md_sections[opt.section] = [md_option] - else: - md_sections[opt.section].append(md_option) - - markdown = "" - for sect in self._parser.sections(): - opts = md_sections[sect] - markdown += f"### [{sect}]\n{''.join(opts)}" - - return markdown - class ExtendedConfigParser(configparser.ConfigParser): """ @@ -1863,40 +1672,6 @@ def fetch_all_keys(self) -> List[str]: keys += list(s.keys()) return keys - def getstr( - self, - section: str, - key: str, - raw: bool = False, - vars: ConfVars = None, # pylint: disable=redefined-builtin - fallback: str = "", - ) -> str: - """A version of get which strips spaces and uses fallback / default for empty values.""" - val = self.get(section, key, fallback=fallback, raw=raw, vars=vars).strip() - if not val: - return fallback - return val - - def getboolean( # type: ignore[override] - self, - section: str, - option: str, - *, - raw: bool = False, - vars: ConfVars = None, # pylint: disable=redefined-builtin - fallback: bool = False, - **kwargs: Optional[Mapping[str, Any]], - ) -> bool: - """Make getboolean less bitchy about empty values, so it uses fallback instead.""" - val = self.get(section, option, fallback="", raw=raw, vars=vars).strip() - if not val: - return fallback - - try: - return super().getboolean(section, option, fallback=fallback) - except ValueError: - return fallback - def getownerid( self, section: str, diff --git a/musicbot/constants.py b/musicbot/constants.py index 9d262762b..25b0ae351 100644 --- a/musicbot/constants.py +++ b/musicbot/constants.py @@ -1,31 +1,12 @@ import subprocess -from typing import List -# VERSION is determined by asking the `git` executable about the current repository. -# This fails if not cloned, or if git is not available for some reason. -# VERSION should never be empty though. -# Note this code is duplicated in update.py for stand-alone use. VERSION: str = "" try: - # Get the last release tag, number of commits since, and g{commit_id} as string. - _VERSION_P1 = ( - subprocess.check_output(["git", "describe", "--tags", "--always"]) + VERSION = ( + subprocess.check_output(["git", "describe", "--tags", "--always", "--dirty"]) .decode("ascii") .strip() ) - # Check if any tracked files are modified for -modded version flag. - _VERSION_P2 = ( - subprocess.check_output(["git", "status", "-suno", "--porcelain"]) - .decode("ascii") - .strip() - ) - if _VERSION_P2: - _VERSION_P2 = "-modded" - else: - _VERSION_P2 = "" - - VERSION = f"{_VERSION_P1}{_VERSION_P2}" - except (subprocess.SubprocessError, OSError, ValueError) as e: print(f"Failed setting version constant, reason: {str(e)}") VERSION = "version_unknown" @@ -36,10 +17,6 @@ DEFAULT_BOT_ICON: str = "https://i.imgur.com/gFHBoZA.png" DEFAULT_OWNER_GROUP_NAME: str = "Owner (auto)" DEFAULT_PERMS_GROUP_NAME: str = "Default" -# This UA string is used by MusicBot only for the aiohttp session. -# Meaning discord API and spotify API communications. -# NOT used by ytdlp, they have a dynamic UA selection feature. -MUSICBOT_USER_AGENT_AIOHTTP: str = f"MusicBot/{VERSION}" # File path constants @@ -60,8 +37,6 @@ # File names within the DEFAULT_DATA_DIR or guild folders. DATA_FILE_SERVERS: str = "server_names.txt" DATA_FILE_CACHEMAP: str = "playlist_cachemap.json" -DATA_FILE_COOKIES: str = "cookies.txt" # No support for this, go read yt-dlp docs. -DATA_FILE_YTDLP_OAUTH2: str = "oauth2.token" DATA_GUILD_FILE_QUEUE: str = "queue.json" DATA_GUILD_FILE_CUR_SONG: str = "current.txt" DATA_GUILD_FILE_OPTIONS: str = "options.json" @@ -113,31 +88,6 @@ # Maximum number of seconds to wait for HEAD request on media files. DEFAULT_MAX_INFO_REQUEST_TIMEOUT: int = 10 -# Time to wait before starting pre-download when a new song is playing. -DEFAULT_PRE_DOWNLOAD_DELAY: float = 4.0 - -# Time in seconds to wait before oauth2 authorization fails. -# This provides time to authorize as well as prevent process hang at shutdown. -DEFAULT_YTDLP_OAUTH2_TTL: float = 180.0 - -# Default / fallback scopes used for OAuth2 ytdlp plugin. -DEFAULT_YTDLP_OAUTH2_SCOPES: str = ( - "http://gdata.youtube.com https://www.googleapis.com/auth/youtube" -) -# Info Extractors to exclude from OAuth2 patching, when OAuth2 is enabled. -YTDLP_OAUTH2_EXCLUDED_IES: List[str] = [ - "YoutubeBaseInfoExtractor", - "YoutubeTabBaseInfoExtractor", -] -# Yt-dlp client creators that are not compatible with OAuth2 plugin. -YTDLP_OAUTH2_UNSUPPORTED_CLIENTS: List[str] = [ - "web_creator", - "android_creator", - "ios_creator", -] -# Additional Yt-dlp clients to add to the OAuth2 client list. -YTDLP_OAUTH2_CLIENTS: List[str] = ["mweb"] - # Discord and other API constants DISCORD_MSG_CHAR_LIMIT: int = 2000 diff --git a/musicbot/constructs.py b/musicbot/constructs.py index ff4fc2dae..b41497b91 100644 --- a/musicbot/constructs.py +++ b/musicbot/constructs.py @@ -90,8 +90,9 @@ def __init__(self, bot: "MusicBot") -> None: # create a task to load any persistent guild options. # in theory, this should work out fine. - bot.create_task(self.load_guild_options_file(), name="MB_LoadGuildOptions") - bot.create_task(self.autoplaylist.load(), name="MB_LoadAPL") + if bot.loop: + bot.loop.create_task(self.load_guild_options_file()) + bot.loop.create_task(self.autoplaylist.load()) def is_ready(self) -> bool: """A status indicator for fully loaded server data.""" diff --git a/musicbot/downloader.py b/musicbot/downloader.py index 6ef002536..d54899fa7 100644 --- a/musicbot/downloader.py +++ b/musicbot/downloader.py @@ -1,9 +1,7 @@ -import asyncio import copy import datetime import functools import hashlib -import importlib import logging import os import pathlib @@ -24,7 +22,6 @@ from .constants import DEFAULT_MAX_INFO_DL_THREADS, DEFAULT_MAX_INFO_REQUEST_TIMEOUT from .exceptions import ExtractionError, MusicbotException from .spotify import Spotify -from .ytdlp_oauth2_plugin import enable_ytdlp_oauth2_plugin if TYPE_CHECKING: from multidict import CIMultiDictProxy @@ -82,66 +79,16 @@ def __init__(self, bot: "MusicBot") -> None: self.bot: "MusicBot" = bot self.download_folder: pathlib.Path = bot.config.audio_cache_path # NOTE: this executor may not be good for long-running downloads... - self.thread_pool = ThreadPoolExecutor( - max_workers=DEFAULT_MAX_INFO_DL_THREADS, - thread_name_prefix="MB_Downloader", - ) + self.thread_pool = ThreadPoolExecutor(max_workers=DEFAULT_MAX_INFO_DL_THREADS) # force ytdlp and HEAD requests to use the same UA string. - # If the constant is set, use that, otherwise use dynamic selection. - if bot.config.ytdlp_user_agent: - ua = bot.config.ytdlp_user_agent - log.warning("Forcing YTDLP to use User Agent: %s", ua) - else: - ua = youtube_dl.utils.networking.random_user_agent() - self.http_req_headers = {"User-Agent": ua} + self.http_req_headers = { + "User-Agent": youtube_dl.utils.networking.random_user_agent() + } # Copy immutable dict and use the mutable copy for everything else. ytdl_format_options = ytdl_format_options_immutable.copy() ytdl_format_options["http_headers"] = self.http_req_headers - # check if we should apply a cookies file to ytdlp. - if bot.config.cookies_path.is_file(): - log.info( - "MusicBot will use cookies for yt-dlp from: %s", - bot.config.cookies_path, - ) - ytdl_format_options["cookiefile"] = bot.config.cookies_path - - if bot.config.ytdlp_proxy: - log.info("Yt-dlp will use your configured proxy server.") - ytdl_format_options["proxy"] = bot.config.ytdlp_proxy - - if bot.config.ytdlp_use_oauth2: - # set the login info so oauth2 is prompted. - ytdl_format_options["username"] = "oauth2" - ytdl_format_options["password"] = "" - # ytdl_format_options["extractor_args"] = { - # "youtubetab": {"skip": ["authcheck"]} - # } - - # check if the original plugin is installed, and use it instead of ours. - # It's worth doing this because our version might fail to work, - # even if the original causes infinite loop hangs while auth is pending... - try: - oauth_spec = importlib.util.find_spec( - "yt_dlp_plugins.extractor.youtubeoauth" - ) - except ModuleNotFoundError: - oauth_spec = None - - if oauth_spec is not None: - log.warning( - "Original OAuth2 plugin is installed and will be used instead.\n" - "This may cause MusicBot to not close completely, or hang pending authorization!\n" - "To close MusicBot, you must manually Kill the MusicBot process!\n" - "Yt-dlp is being set to show warnings and other log messages, to show the Auth code.\n" - "Uninstall the yt-dlp-youtube-oauth2 package to use integrated OAuth2 features instead." - ) - ytdl_format_options["quiet"] = False - ytdl_format_options["no_warnings"] = False - else: - enable_ytdlp_oauth2_plugin(self.bot.config) - if self.download_folder: # print("setting template to " + os.path.join(download_folder, otmpl)) otmpl = ytdl_format_options["outtmpl"] @@ -159,43 +106,6 @@ def ytdl(self) -> youtube_dl.YoutubeDL: """Get the Safe (errors ignored) instance of YoutubeDL.""" return self.safe_ytdl - @property - def cookies_enabled(self) -> bool: - """ - Get status of cookiefile option in ytdlp objects. - """ - return all( - "cookiefile" in ytdl.params for ytdl in [self.safe_ytdl, self.unsafe_ytdl] - ) - - def enable_ytdl_cookies(self) -> None: - """ - Set the cookiefile option on the ytdl objects. - """ - self.safe_ytdl.params["cookiefile"] = self.bot.config.cookies_path - self.unsafe_ytdl.params["cookiefile"] = self.bot.config.cookies_path - - def disable_ytdl_cookies(self) -> None: - """ - Remove the cookiefile option on the ytdl objects. - """ - del self.safe_ytdl.params["cookiefile"] - del self.unsafe_ytdl.params["cookiefile"] - - def randomize_user_agent_string(self) -> None: - """ - Uses ytdlp utils functions to re-randomize UA strings in YoutubeDL - objects and header check requests. - """ - # ignore this call if static UA is configured. - if not self.bot.config.ytdlp_user_agent: - return - - new_ua = youtube_dl.utils.networking.random_user_agent() - self.unsafe_ytdl.params["http_headers"]["User-Agent"] = new_ua - self.safe_ytdl.params["http_headers"]["User-Agent"] = new_ua - self.http_req_headers["User-Agent"] = new_ua - def get_url_or_none(self, url: str) -> Optional[str]: """ Uses ytdl.utils.url_or_none() to validate a playable URL. @@ -239,9 +149,6 @@ async def get_url_headers(self, url: str) -> Dict[str, str]: headers[new_key] = values else: headers[new_key] = values.pop() - except asyncio.exceptions.TimeoutError: - log.warning("Checking media headers failed due to timeout.") - headers = {"X-HEAD-REQ-FAILED": "1"} except (ExtractionError, OSError, aiohttp.ClientError): log.warning("Failed HEAD request for: %s", test_url) log.exception("HEAD Request exception: ") @@ -281,7 +188,6 @@ async def _get_headers( # pylint: disable=dangerous-default-value timeout=req_timeout, allow_redirects=allow_redirects, headers=req_headers, - proxy=self.bot.config.ytdlp_proxy, ) as response: return response.headers @@ -388,9 +294,6 @@ async def extract_info( data["__header_data"] = headers or None data["__expected_filename"] = self.ytdl.prepare_filename(data) - # ensure the UA is randomized with each new request if not set static. - self.randomize_user_agent_string() - """ # disabled since it is only needed for working on extractions. # logs data only for debug and higher verbosity levels. @@ -525,7 +428,7 @@ async def _filtered_extract_info( # This prevents single-entry searches being processed like a playlist later. # However we must preserve the list behavior when using cmd_search. if ( - data.get("extractor", "").startswith("youtube:search") + data.get("extractor", "") == "youtube:search" and len(data.get("entries", [])) == 1 and isinstance(data.get("entries", None), list) and data.get("playlist_count", 0) == 1 @@ -668,14 +571,6 @@ def http_header(self, header_name: str, default: Any = None) -> Any: ) return default - @property - def input_subject(self) -> str: - """Get the input subject used to create this data.""" - subject = self.data.get("__input_subject", "") - if isinstance(subject, str): - return subject - return "" - @property def expected_filename(self) -> Optional[str]: """get expected filename for this info data, or None if not available""" @@ -728,7 +623,7 @@ def thumbnail_url(self) -> str: return turl # if all else fails, try to make a URL on our own. - if self.extractor.startswith("youtube"): + if self.extractor == "youtube": if self.video_id: return f"https://i.ytimg.com/vi/{self.video_id}/maxresdefault.jpg" @@ -875,7 +770,7 @@ def is_stream(self) -> bool: return True # Warning: questionable methods from here on. - if self.extractor.startswith("generic"): + if self.extractor == "generic": # check against known streaming service headers. if self.http_header("ICY-NAME") or self.http_header("ICY-URL"): return True diff --git a/musicbot/entry.py b/musicbot/entry.py index 456a6f71e..f77823942 100644 --- a/musicbot/entry.py +++ b/musicbot/entry.py @@ -4,7 +4,7 @@ import os import re import shutil -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Union +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union import discord from yt_dlp.utils import ( # type: ignore[import-untyped] @@ -24,10 +24,8 @@ # Explicit compat with python 3.8 AsyncFuture = asyncio.Future[Any] - AsyncTask = asyncio.Task[Any] else: AsyncFuture = asyncio.Future - AsyncTask = asyncio.Task GuildMessageableChannels = Union[ discord.Thread, @@ -58,7 +56,6 @@ def __init__(self) -> None: self._is_downloading: bool = False self._is_downloaded: bool = False self._waiting_futures: List[AsyncFuture] = [] - self._task_pool: Set[AsyncTask] = set() @property def start_time(self) -> float: @@ -119,7 +116,7 @@ def get_ready_future(self) -> AsyncFuture: The future will either fire with the result (being the entry) or an exception as to why the song download failed. """ - future: AsyncFuture = asyncio.Future() + future = asyncio.Future() # type: AsyncFuture if self.is_downloaded: # In the event that we're downloaded, we're already ready for playback. future.set_result(self) @@ -127,12 +124,10 @@ def get_ready_future(self) -> AsyncFuture: else: # If we request a ready future, let's ensure that it'll actually resolve at one point. self._waiting_futures.append(future) - task = asyncio.create_task(self._download(), name="MB_EntryReadyTask") - # Make sure garbage collection does not delete the task early... - self._task_pool.add(task) - task.add_done_callback(self._task_pool.discard) + asyncio.ensure_future(self._download()) - log.debug("Created future for %r", self) + name = self.title or self.filename or self.url + log.debug("Created future for %s", name) return future def _for_each_future(self, cb: Callable[..., Any]) -> None: @@ -143,15 +138,13 @@ def _for_each_future(self, cb: Callable[..., Any]) -> None: futures = self._waiting_futures self._waiting_futures = [] - log.everything( # type: ignore[attr-defined] - "Completed futures for %r with %r", self, cb - ) for future in futures: if future.cancelled(): continue try: cb(future) + except Exception: # pylint: disable=broad-exception-caught log.exception("Unhandled exception in _for_each_future callback.") @@ -161,9 +154,6 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return id(self) - def __repr__(self) -> str: - return f"<{type(self).__name__}(url='{self.url}', title='{self.title}' file='{self.filename}')>" - async def run_command(command: List[str]) -> bytes: """ @@ -444,7 +434,7 @@ async def _ensure_entry_info(self) -> None: self.info = info else: raise InvalidDataError( - f"Cannot download spotify links, processing error with type: {info.ytdl_type}." + "Cannot download spotify links, these should be extracted before now." ) # if this isn't set this entry is probably from a playlist and needs more info. @@ -455,7 +445,7 @@ async def _ensure_entry_info(self) -> None: async def _download(self) -> None: if self._is_downloading: return - log.debug("Getting ready for entry: %r", self) + log.debug("URLPlaylistEntry is now checking download status.") self._is_downloading = True try: @@ -535,8 +525,7 @@ async def _download(self) -> None: # Flake8 thinks 'e' is never used, and later undefined. Maybe the lambda is too much. except Exception as e: # pylint: disable=broad-exception-caught ex = e - if log.getEffectiveLevel() <= logging.DEBUG: - log.error("Exception while checking entry data.") + log.exception("Exception while checking entry data.") self._for_each_future(lambda future: future.set_exception(ex)) finally: @@ -675,13 +664,11 @@ async def _really_download(self) -> None: """ Actually download the media in this entry into cache. """ - log.info("Download started: %r", self) + log.info("Download started: %s", self.url) + retry = 2 info = None - for attempt in range(1, 4): - log.everything( # type: ignore[attr-defined] - "Download attempt %s of 3...", attempt - ) + while True: try: info = await self.downloader.extract_info(self.url, download=True) break @@ -689,14 +676,12 @@ async def _really_download(self) -> None: # this typically means connection was interrupted, any # download is probably partial. we should definitely do # something about it to prevent broken cached files. - if attempt < 3: - wait_for = 1.5 * attempt + if retry > 0: log.warning( - "Download incomplete, retrying in %.1f seconds. Reason: %s", - wait_for, - str(e), + "Download may have failed, retrying. Reason: %s", str(e) ) - await asyncio.sleep(wait_for) # TODO: backoff timer maybe? + retry -= 1 + await asyncio.sleep(1.5) # TODO: backoff timer maybe? continue # Mark the file I guess, and maintain the default of raising ExtractionError. @@ -708,14 +693,15 @@ async def _really_download(self) -> None: raise ExtractionError(str(e)) from e except Exception as e: - log.error("Extraction encountered an unhandled exception.") + log.exception("Extraction encountered an unhandled exception.") raise MusicbotException(str(e)) from e - if info is None: - log.error("Download failed: %r", self) - raise ExtractionError("Failed to extract data for the requested media.") + log.info("Download complete: %s", self.url) - log.info("Download complete: %r", self) + if info is None: + log.critical("YTDL has failed, everyone panic") + raise ExtractionError("ytdl broke and hell if I know why") + # What the fuck do I do now? self._is_downloaded = True self.filename = info.expected_filename or "" @@ -765,7 +751,7 @@ def url(self) -> str: """get extracted url if available or otherwise return the input subject""" if self.info.extractor and self.info.url: return self.info.url - return self.info.input_subject + return self.info.get("__input_subject", "") @property def title(self) -> str: @@ -804,11 +790,6 @@ def thumbnail_url(self) -> str: """Get available thumbnail from info or an empty string""" return self.info.thumbnail_url - @property - def playback_speed(self) -> float: - """Playback speed for streamed entries cannot typically be adjusted.""" - return 1.0 - def __json__(self) -> Dict[str, Any]: return self._enclose_json( { @@ -905,14 +886,11 @@ def _deserialize( return None async def _download(self) -> None: - log.debug("Getting ready for entry: %r", self) self._is_downloading = True self._is_downloaded = True self.filename = self.url self._is_downloading = False - self._for_each_future(lambda future: future.set_result(self)) - class LocalFilePlaylistEntry(BasePlaylistEntry): SERIAL_VERSION: int = 1 @@ -1155,8 +1133,7 @@ async def _download(self) -> None: """ if self._is_downloading: return - - log.debug("Getting ready for entry: %r", self) + log.debug("LocalFilePlaylistEntry is now extracting media information.") self._is_downloading = True try: @@ -1203,8 +1180,7 @@ async def _download(self) -> None: # Flake8 thinks 'e' is never used, and later undefined. Maybe the lambda is too much. except Exception as e: # pylint: disable=broad-exception-caught ex = e - if log.getEffectiveLevel() <= logging.DEBUG: - log.error("Exception while checking entry data.") + log.exception("Exception while checking entry data.") self._for_each_future(lambda future: future.set_exception(ex)) finally: diff --git a/musicbot/filecache.py b/musicbot/filecache.py index aaf047852..c97567bcc 100644 --- a/musicbot/filecache.py +++ b/musicbot/filecache.py @@ -358,9 +358,7 @@ def add_autoplay_cachemap_entry(self, entry: "BasePlaylistEntry") -> None: change_made = True if change_made: - self.bot.create_task( - self.save_autoplay_cachemap(), name="MB_SaveAutoPlayCachemap" - ) + self.bot.loop.create_task(self.save_autoplay_cachemap()) def remove_autoplay_cachemap_entry(self, entry: "BasePlaylistEntry") -> None: """ @@ -376,9 +374,7 @@ def remove_autoplay_cachemap_entry(self, entry: "BasePlaylistEntry") -> None: filename = pathlib.Path(entry.filename).stem if filename in self.auto_playlist_cachemap: del self.auto_playlist_cachemap[filename] - self.bot.create_task( - self.save_autoplay_cachemap(), name="MB_SaveAutoPlayCachemap" - ) + self.bot.loop.create_task(self.save_autoplay_cachemap()) def remove_autoplay_cachemap_entry_by_url(self, url: str) -> None: """ @@ -400,9 +396,7 @@ def remove_autoplay_cachemap_entry_by_url(self, url: str) -> None: del self.auto_playlist_cachemap[key] if len(to_remove) != 0: - self.bot.create_task( - self.save_autoplay_cachemap(), name="MB_SaveAutoPlayCachemap" - ) + self.bot.loop.create_task(self.save_autoplay_cachemap()) def _check_autoplay_cachemap(self, filename: pathlib.Path) -> bool: """ diff --git a/musicbot/lib/event_emitter.py b/musicbot/lib/event_emitter.py index a2d0c99e0..e4532fc9b 100644 --- a/musicbot/lib/event_emitter.py +++ b/musicbot/lib/event_emitter.py @@ -1,12 +1,7 @@ import asyncio import collections import traceback -from typing import TYPE_CHECKING, Any, Callable, DefaultDict, List, Set - -if TYPE_CHECKING: - AsyncTask = asyncio.Task[Any] -else: - AsyncTask = asyncio.Task +from typing import Any, Callable, DefaultDict, List EventCallback = Callable[..., Any] EventList = List[EventCallback] @@ -21,7 +16,6 @@ def __init__(self) -> None: """ self._events: EventDict = collections.defaultdict(list) self.loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() - self._task_pool: Set[AsyncTask] = set() def emit(self, event: str, *args: Any, **kwargs: Any) -> None: """ @@ -34,14 +28,7 @@ def emit(self, event: str, *args: Any, **kwargs: Any) -> None: for cb in list(self._events[event]): try: if asyncio.iscoroutinefunction(cb): - # Create the task and save a reference to ensure it is not - # garbage collected early. - t = self.loop.create_task( - cb(*args, **kwargs), - name=f"MB_EE_{type(self).__name__}_{cb.__name__}", - ) - self._task_pool.add(t) - t.add_done_callback(self._task_pool.discard) + asyncio.ensure_future(cb(*args, **kwargs), loop=self.loop) else: cb(*args, **kwargs) diff --git a/musicbot/permissions.py b/musicbot/permissions.py index 45cb58811..3b64b70fa 100644 --- a/musicbot/permissions.py +++ b/musicbot/permissions.py @@ -21,10 +21,10 @@ log = logging.getLogger(__name__) -PERMS_ALLOW_ALL_EXTRACTOR_NAME: str = "__" + +# Permissive class define the permissive value of each permissions -# Permissive class define the permissive value of each default permissions class PermissionsDefaults: """ Permissions system and PermissionGroup default values. @@ -56,8 +56,9 @@ class PermissionsDefaults: extractors: Set[str] = { "generic", "youtube", - "soundcloud", - "Bandcamp", + "youtube:tab", + "youtube:search", + "youtube:playlist", "spotify:musicbot", } @@ -181,6 +182,14 @@ async def async_validate(self, bot: "MusicBot") -> None: log.debug("Setting auto OwnerID for owner permissions group.") self.owner_group.user_list = {bot.config.owner_id} + def save(self) -> None: + """ + Currently unused function intended to write permissions back to + its configuration file. + """ + with open(self.perms_file, "w", encoding="utf8") as f: + self.config.write(f) + def for_user(self, user: Union[discord.Member, discord.User]) -> "PermissionGroup": """ Returns the first PermissionGroup a user belongs to @@ -478,10 +487,6 @@ def __init__( default=defaults.extractors, comment=( "List of yt_dlp extractor keys, separated by spaces, that are allowed to be used.\n" - "Extractor names are matched partially, to allow for strict and flexible permissions.\n" - "Example: `youtube:search` allows only search, but `youtube` allows all of youtube extractors.\n" - "When empty, hard-coded defaults are used. If you set this, you may want to add those defaults as well.\n" - f"To allow all extractors, add `{PERMS_ALLOW_ALL_EXTRACTOR_NAME}` to the list of extractors.\n" "Services supported by yt_dlp shown here: https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md \n" "MusicBot also provides one custom service `spotify:musicbot` to enable or disable spotify API extraction.\n" "NOTICE: MusicBot might not support all services available to yt_dlp!\n" @@ -497,10 +502,6 @@ def validate(self) -> None: log.warning("Max search items can't be larger than 100. Setting to 100.") self.max_search_items = 100 - # if extractors contains the all marker, blank out the list to allow all. - if PERMS_ALLOW_ALL_EXTRACTOR_NAME in self.extractors: - self.extractors = set() - def add_user(self, uid: int) -> None: """Add given discord User ID to the user list.""" self.user_list.add(uid) @@ -528,28 +529,6 @@ def can_use_command(self, command: str) -> None: expire_in=20, ) - def can_use_extractor(self, extractor: str) -> None: - """ - Test if this group / user can use the given extractor. - - :raises: PermissionsError if extractor is not allowed. - """ - # empty extractor list will allow all extractors. - if not self.extractors: - return - - # check the list for any partial matches. - for allowed in self.extractors: - if extractor.startswith(allowed): - return - - # the extractor is not allowed. - raise PermissionsError( - "You do not have permission to play the requested media.\n" - f"The yt-dlp extractor `{extractor}` is not permitted in your group.", - expire_in=30, - ) - def format(self, for_user: bool = False) -> str: """ Format the current group values into INI-like text. diff --git a/musicbot/player.py b/musicbot/player.py index be608b7db..ef420b35e 100644 --- a/musicbot/player.py +++ b/musicbot/player.py @@ -4,7 +4,6 @@ import logging import os import sys -import time from enum import Enum from threading import Thread from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union @@ -56,8 +55,6 @@ def __init__( :param: start_time: A time in seconds that was used in ffmpeg -ss flag. """ - # NOTE: PCMVolumeTransformer will let you set any crazy value. - # But internally it limits between 0 and 2.0. self._source = source self._num_reads: int = 0 self._start_time: float = start_time @@ -132,6 +129,7 @@ def __init__( self._stderr_future: Optional[AsyncFuture] = None self._source: Optional[SourcePlaybackCounter] = None + self._pending_call_later: Optional[EntryTypes] = None self.playlist.on("entry-added", self.on_entry_added) self.playlist.on("entry-failed", self.on_entry_failed) @@ -157,6 +155,11 @@ def on_entry_added( """ Event dispatched by Playlist when an entry is added to the queue. """ + if self.is_stopped and not self.current_entry and not self._pending_call_later: + log.noise("calling-later, self.play from player.") # type: ignore[attr-defined] + self._pending_call_later = entry + self.loop.call_later(2, self.play) + self.emit( "entry-added", player=self, @@ -262,13 +265,6 @@ def _playback_finished(self, error: Optional[Exception] = None) -> None: :param: error: An exception, if any, raised by playback. """ - # Ensure the stderr stream reader for ffmpeg is exited. - if ( - isinstance(self._stderr_future, asyncio.Future) - and not self._stderr_future.done() - ): - self._stderr_future.set_result(True) - entry = self._current_entry if entry is None: log.debug("Playback finished, but _current_entry is None.") @@ -279,7 +275,6 @@ def _playback_finished(self, error: Optional[Exception] = None) -> None: elif self.loopqueue: self.playlist.entries.append(entry) - # TODO: investigate if this is cruft code or not. if self._current_player: if hasattr(self._current_player, "after"): self._current_player.after = None @@ -287,14 +282,12 @@ def _playback_finished(self, error: Optional[Exception] = None) -> None: self._current_entry = None self._source = None - self.stop() - # if an error was set, report it and return... if error: + self.stop() self.emit("error", player=self, entry=entry, ex=error) return - # if a exception is found in the ffmpeg stderr stream, report it and return... if ( isinstance(self._stderr_future, asyncio.Future) and self._stderr_future.done() @@ -302,18 +295,15 @@ def _playback_finished(self, error: Optional[Exception] = None) -> None: ): # I'm not sure that this would ever not be done if it gets to this point # unless ffmpeg is doing something highly questionable + self.stop() self.emit( "error", player=self, entry=entry, ex=self._stderr_future.exception() ) return - # ensure file cleanup is handled if nothing was wrong with playback. if not self.bot.config.save_videos and entry: - self.bot.create_task( - self._handle_file_cleanup(entry), name="MB_CacheCleanup" - ) + self.loop.create_task(self._handle_file_cleanup(entry)) - # finally, tell the rest of MusicBot that playback is done. self.emit("finished-playing", player=self, entry=entry) def _kill_current_player(self) -> bool: @@ -347,7 +337,7 @@ def play(self, _continue: bool = False) -> None: log.noise( # type: ignore[attr-defined] "MusicPlayer.play() is called: %s", repr(self) ) - self.bot.create_task(self._play(_continue=_continue), name="MB_Play") + self.loop.create_task(self._play(_continue=_continue)) async def _play(self, _continue: bool = False) -> None: """ @@ -377,17 +367,10 @@ async def _play(self, _continue: bool = False) -> None: async with self._play_lock: if self.is_stopped or _continue: - # Get the entry before we try to ready it, so it can be passed to error callbacks. - entry_up_next = self.playlist.peek() try: entry = await self.playlist.get_next_entry() - except IndexError as e: - log.warning("Failed to get next entry.", exc_info=e) - self.emit("error", player=self, entry=entry_up_next, ex=e) - entry = None - except Exception as e: # pylint: disable=broad-exception-caught - log.warning("Failed to process entry for playback.", exc_info=e) - self.emit("error", player=self, entry=entry_up_next, ex=e) + except IndexError: + log.warning("Failed to get entry.", exc_info=True) entry = None # If nothing left to play, transition to the stopped state. @@ -395,6 +378,9 @@ async def _play(self, _continue: bool = False) -> None: self.stop() return + if self._pending_call_later == entry: + self._pending_call_later = None + # In-case there was a player, kill it. RIP. self._kill_current_player() @@ -446,7 +432,7 @@ async def _play(self, _continue: bool = False) -> None: stderr_thread = Thread( target=filter_stderr, args=(stderr_io, self._stderr_future), - name="MB_FFmpegStdErrReader", + name="stderr reader", ) stderr_thread.start() @@ -619,7 +605,8 @@ def filter_stderr(stderr: io.BytesIO, future: AsyncFuture) -> None: set the future with a successful result. """ last_ex = None - while not future.done(): + + while True: data = stderr.readline() if data: log.ffmpeg( # type: ignore[attr-defined] @@ -636,21 +623,18 @@ def filter_stderr(stderr: io.BytesIO, future: AsyncFuture) -> None: "Error from ffmpeg: %s", str(e).strip() ) last_ex = e - if not future.done(): - future.set_exception(e) except FFmpegWarning as e: log.ffmpeg( # type: ignore[attr-defined] "Warning from ffmpeg: %s", str(e).strip() ) else: - time.sleep(0.5) + break - if not future.done(): - if last_ex: - future.set_exception(last_ex) - else: - future.set_result(True) + if last_ex: + future.set_exception(last_ex) + else: + future.set_result(True) def check_stderr(data: bytes) -> bool: diff --git a/musicbot/playlist.py b/musicbot/playlist.py index a015d4ac3..d1b6f996d 100644 --- a/musicbot/playlist.py +++ b/musicbot/playlist.py @@ -18,7 +18,6 @@ import discord -from .constants import DEFAULT_PRE_DOWNLOAD_DELAY from .constructs import Serializable from .entry import LocalFilePlaylistEntry, StreamPlaylistEntry, URLPlaylistEntry from .exceptions import ExtractionError, InvalidDataError, WrongEntryTypeError @@ -187,7 +186,7 @@ async def add_entry_from_info( ) # TODO: Extract this to its own function - if any(info.extractor.startswith(x) for x in ["generic", "Dropbox"]): + if info.extractor in ["generic", "Dropbox"]: content_type = info.http_header("content-type", None) if content_type: @@ -215,7 +214,7 @@ async def add_entry_from_info( ) log.noise( # type: ignore[attr-defined] - f"Adding URLPlaylistEntry for: {info.input_subject}" + f"Adding URLPlaylistEntry for: {info.get('__input_subject')}" ) entry = URLPlaylistEntry(self, info, author=author, channel=channel) self._add_entry(entry, head=head, defer_serialize=defer_serialize) @@ -234,7 +233,7 @@ async def add_local_file_entry( Adds a local media file entry to the playlist. """ log.noise( # type: ignore[attr-defined] - f"Adding LocalFilePlaylistEntry for: {info.input_subject}" + f"Adding LocalFilePlaylistEntry for: {info.get('__input_subject')}" ) entry = LocalFilePlaylistEntry(self, info, author=author, channel=channel) self._add_entry(entry, head=head, defer_serialize=defer_serialize) @@ -385,9 +384,6 @@ def reorder_for_round_robin(self) -> None: request_counter = 0 song: Optional[EntryTypes] = None while self.entries: - log.everything( # type: ignore[attr-defined] - "Reorder looping over entries." - ) # Do not continue if we have no more authors. if len(all_authors) == 0: break @@ -431,7 +427,41 @@ def _add_entry( "entry-added", playlist=self, entry=entry, defer_serialize=defer_serialize ) - async def get_next_entry(self) -> Any: + if self.peek() is entry: + entry.get_ready_future() + + async def _try_get_entry_future( + self, entry: EntryTypes, predownload: bool = False + ) -> Any: + """gracefully try to get the entry ready future, or start pre-downloading one.""" + moving_on = " Moving to the next entry..." + if predownload: + moving_on = "" + + try: + if predownload: + entry.get_ready_future() + else: + return await entry.get_ready_future() + + except ExtractionError as e: + log.warning("Extraction failed for a playlist entry.%s", moving_on) + self.emit("entry-failed", entry=entry, error=e) + if not predownload: + return await self.get_next_entry() + + except AttributeError as e: + log.warning( + "Deserialize probably failed for a playlist entry.%s", + moving_on, + ) + self.emit("entry-failed", entry=entry, error=e) + if not predownload: + return await self.get_next_entry() + + return None + + async def get_next_entry(self, predownload_next: bool = True) -> Any: """ A coroutine which will return the next song or None if no songs left to play. @@ -442,34 +472,13 @@ async def get_next_entry(self) -> Any: return None entry = self.entries.popleft() - self.bot.create_task( - self._pre_download_entry_after_next(entry), - name="MB_PreDownloadNextUp", - ) - - return await entry.get_ready_future() - - async def _pre_download_entry_after_next(self, last_entry: EntryTypes) -> None: - """ - Enforces a delay before doing pre-download of the "next" song. - Should only be called from get_next_entry() after pop. - """ - if not self.bot.config.pre_download_next_song: - return - - if not self.entries: - return - # get the next entry to pre-download before we wait. - next_entry = self.peek() + if predownload_next: + next_entry = self.peek() + if next_entry: + await self._try_get_entry_future(next_entry, predownload_next) - await asyncio.sleep(DEFAULT_PRE_DOWNLOAD_DELAY) - - if next_entry and next_entry != last_entry: - log.everything( # type: ignore[attr-defined] - "Pre-downloading next track: %r", next_entry - ) - next_entry.get_ready_future() + return await self._try_get_entry_future(entry) def peek(self) -> Optional[EntryTypes]: """ diff --git a/musicbot/spotify.py b/musicbot/spotify.py index 22ea66c42..207faf627 100644 --- a/musicbot/spotify.py +++ b/musicbot/spotify.py @@ -112,11 +112,9 @@ class SpotifyTrack(SpotifyObject): def __init__( self, track_data: Dict[str, Any], origin_url: Optional[str] = None ) -> None: - super().__init__(track_data, origin_url) if not SpotifyObject.is_track_data(track_data): - raise SpotifyError( - f"Invalid track_data, must be of type `track` got `{self.spotify_type}`" - ) + raise SpotifyError("Invalid track_data, must be of type 'track'") + super().__init__(track_data, origin_url) @property def artist_name(self) -> str: @@ -291,14 +289,8 @@ def _create_track_objects(self) -> None: raise ValueError("Invalid playlist_data, missing track key in items") track_data = item.get("track", None) - track_type = track_data.get("type", None) - if track_data and track_type == "track": + if track_data: self._track_objects.append(SpotifyTrack(track_data)) - else: - log.everything( # type: ignore[attr-defined] - "Ignored non-track entry in playlist with type: %s", - track_type, - ) @property def track_objects(self) -> List[SpotifyTrack]: @@ -316,11 +308,6 @@ def track_count(self) -> int: tracks = self.data.get("tracks", {}) return int(tracks.get("total", 0)) - @property - def tracks_loaded(self) -> int: - """Get number of valid tracks in the playlist.""" - return len(self._track_objects) - @property def thumbnail_url(self) -> str: """ @@ -545,9 +532,7 @@ async def get_playlist_object_complete(self, list_id: str) -> SpotifyPlaylist: pldata["tracks"]["items"] = tracks - plobj = SpotifyPlaylist(pldata) - log.debug("Spotify Playlist contained %s usable tracks.", plobj.tracks_loaded) - return plobj + return SpotifyPlaylist(pldata) async def get_playlist_object(self, list_id: str) -> SpotifyPlaylist: """Lookup a spotify playlist by its ID and return a SpotifyPlaylist object""" @@ -574,7 +559,6 @@ async def _make_get( raise SpotifyError( f"Response status is not OK: [{r.status}] {r.reason}" ) - # log.everything("Spotify API GET: %s\nData: %s", url, await r.text() ) data = await r.json() # type: Dict[str, Any] if not isinstance(data, dict): raise SpotifyError("Response JSON did not decode to a dict!") diff --git a/musicbot/utils.py b/musicbot/utils.py index cbc40b2a9..92b34ddb7 100644 --- a/musicbot/utils.py +++ b/musicbot/utils.py @@ -297,8 +297,6 @@ def rotate_log_files(max_kept: int = -1, date_fmt: str = "") -> None: :param: date_fmt: format compatible with datetime.strftime() for rotated filename. """ if hasattr(logging, "_mb_logs_rotated"): - if log.getEffectiveLevel() <= logging.DEBUG: - print("Logs already rotated.") return # Use the input arguments or fall back to settings or defaults. @@ -314,8 +312,6 @@ def rotate_log_files(max_kept: int = -1, date_fmt: str = "") -> None: # Rotation can be disabled by setting 0. if not max_kept: - if log.getEffectiveLevel() <= logging.DEBUG: - print("No logs rotated.") return # Format a date that will be used for files rotated now. diff --git a/requirements.txt b/requirements.txt index 69092369c..e1c11500d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,8 @@ pynacl -#discord.py [voice, speed] >= 2.4.0 discord.py [voice, speed] @ git+https://github.com/Rapptz/discord.py pip yt-dlp colorlog -colorama >= 0.4.6; sys_platform == 'win32' cffi --only-binary all; sys_platform == 'win32' certifi configupdater diff --git a/run.py b/run.py index 0d5433056..d99352bc1 100644 --- a/run.py +++ b/run.py @@ -40,13 +40,9 @@ # protect dependency import from stopping the launcher try: - # This has been available for 7+ years. So it should be OK to do this... from aiohttp.client_exceptions import ClientConnectorCertificateError except ImportError: - # prevent NameError while handling exceptions later, if import fails. - class ClientConnectorCertificateError(Exception): # type: ignore[no-redef] - pass - + pass log = logging.getLogger("musicbot.launcher") @@ -506,40 +502,11 @@ def req_ensure_env() -> None: finally: shutil.rmtree("musicbot-test-folder", True) - # this actually does an access check as well. - ffmpeg_bin = shutil.which("ffmpeg") if sys.platform.startswith("win"): - if ffmpeg_bin: - log.info("Detected FFmpeg is installed at: %s", ffmpeg_bin) - else: - log.info("Adding local bins/ folder environment PATH for bundled ffmpeg...") - os.environ["PATH"] += ";" + os.path.abspath("bin/") - sys.path.append(os.path.abspath("bin/")) # might as well - # try to get the local bin path again. - ffmpeg_bin = shutil.which("ffmpeg") - - # make sure ffmpeg is available. - if not ffmpeg_bin: - log.critical( - "MusicBot could not locate FFmpeg binary in your environment.\n" - "Please install FFmpeg so it is available in your environment PATH variable." - ) - if sys.platform.startswith("win"): - log.info( - "On Windows, you can add a pre-compiled EXE to the MusicBot `bin` folder,\n" - "or you can install FFmpeg system-wide using WinGet or by running the install.bat file." - ) - elif sys.platform.startswith("darwin"): - log.info( - "On MacOS, you may be able to install FFmpeg via homebrew.\n" - "Otherwise, check the official FFmpeg site for build or install steps." - ) - else: - log.info( - "On Linux, many distros make FFmpeg available via system package managers.\n" - "Check for ffmpeg with your system package manager or build from sources." - ) - bugger_off() + # TODO: this should probably be conditional, in favor of system installed exe. + log.info("Adding local bins/ folder to path") + os.environ["PATH"] += ";" + os.path.abspath("bin/") + sys.path.append(os.path.abspath("bin/")) # might as well def opt_check_disk_space(warnlimit_mb: int = 200) -> None: @@ -842,10 +809,9 @@ def set_console_title() -> None: # if colorama fails to import we can assume setup_logs didn't load it. import colorama # type: ignore[import-untyped] - # this is only available in colorama version 0.4.6+ - # which as it happens isn't required by colorlog. + # if it works, then great, one less thing to worry about right??? colorama.just_fix_windows_console() - except (ImportError, AttributeError): + except ImportError: # This might only work for Win 10+ from ctypes import windll # type: ignore[attr-defined] @@ -989,13 +955,13 @@ def main() -> None: continue except SyntaxError: - if "-modded" in BOTVERSION: + if "-dirty" in BOTVERSION: log.exception("Syntax error (version is dirty, did you edit the code?)") else: log.exception("Syntax error (this is a bug, not your fault)") break - except (AttributeError, ImportError, ModuleNotFoundError) as e: + except (AttributeError, ImportError) as e: # In case a discord extension is installed but discord.py isn't. if isinstance(e, AttributeError): if "module 'discord'" not in str(e): diff --git a/run.sh b/run.sh index 0bb064be6..a103a9c15 100755 --- a/run.sh +++ b/run.sh @@ -4,22 +4,6 @@ # make sure we're in MusicBot directory... cd "$(dirname "${BASH_SOURCE[0]}")" || { echo "Could not change directory to MusicBot."; exit 1; } -# provides an exit that also deactivates venv. -function do_exit() { - if [ "${VIRTUAL_ENV}" != "" ] ; then - echo "Leaving MusicBot Venv..." - deactivate - fi - exit "$1" -} - -# attempt to find the "standard" venv and activate it. -if [ -f "../bin/activate" ] ; then - echo "Detected MusicBot Venv & Loading it..." - # shellcheck disable=SC1091 - source "../bin/activate" -fi - # Suported versions of python using only major.minor format PySupported=("3.8" "3.9" "3.10" "3.11" "3.12") @@ -76,7 +60,7 @@ done # if we don't have a good version for python, bail. if [[ "$VerGood" == "0" ]]; then echo "Python 3.8.7 or higher is required to run MusicBot." - do_exit 1 + exit 1 fi echo "Using '${Python_Bin}' to launch MusicBot..." @@ -85,4 +69,4 @@ echo "Using '${Python_Bin}' to launch MusicBot..." $Python_Bin run.py "$@" # exit using the code that python exited with. -do_exit $? +exit $? diff --git a/update.py b/update.py index 06fa56853..fdfecf87d 100644 --- a/update.py +++ b/update.py @@ -29,20 +29,15 @@ def run_or_raise_error(cmd: List[str], message: str, **kws: Any) -> None: """ Wrapper for subprocess.check_call that avoids shell=True - :kwparam: ok_codes: A list of non-zero exit codes to consider OK. :raises: RuntimeError with given `message` as exception text. """ - ok_codes = kws.pop("ok_codes", []) try: subprocess.check_call(cmd, **kws) - except subprocess.CalledProcessError as e: - if e.returncode in ok_codes: - return - raise RuntimeError(message) from e except ( # pylint: disable=duplicate-code OSError, PermissionError, FileNotFoundError, + subprocess.CalledProcessError, ) as e: raise RuntimeError(message) from e @@ -52,24 +47,13 @@ def get_bot_version(git_bin: str) -> str: Gets the bot current version as reported by git, without loading constants. """ try: - # Get the last release tag, number of commits since, and g{commit_id} as string. - ver_p1 = ( - subprocess.check_output([git_bin, "describe", "--tags", "--always"]) - .decode("ascii") - .strip() - ) - # Check status of file modifications. - ver_p2 = ( - subprocess.check_output([git_bin, "status", "-suno", "--porcelain"]) + ver = ( + subprocess.check_output( + [git_bin, "describe", "--tags", "--always", "--dirty"] + ) .decode("ascii") .strip() ) - if ver_p2: - ver_p2 = "-modded" - else: - ver_p2 = "" - - ver = f"{ver_p1}{ver_p2}" except (subprocess.SubprocessError, OSError, ValueError) as e: print(f"Failed getting version due to: {str(e)}") @@ -135,35 +119,18 @@ def update_deps() -> None: """ print("Attempting to update dependencies...") - # outside a venv these args are used for pip update - run_args = [ - sys.executable, - "-m", - "pip", - "install", - "--no-warn-script-location", - "--user", - "-U", - "-r", - "requirements.txt", - ] - - # detect if venv is in use and update args. - if sys.prefix != sys.base_prefix: - run_args = [ + run_or_raise_error( + [ sys.executable, "-m", "pip", "install", "--no-warn-script-location", - # No --user site-packages in venv + "--user", "-U", "-r", "requirements.txt", - ] - - run_or_raise_error( - run_args, + ], "Could not update dependencies. You need to update manually. " f"Run: {sys.executable} -m pip install -U -r requirements.txt", ) @@ -292,15 +259,10 @@ def update_ffmpeg() -> None: [ winget_bin, "upgrade", - "ffmpeg", + "Gyan.FFmpeg", ], "Could not update ffmpeg. You need to update it manually." - "Try running: winget upgrade ffmpeg", - # See here for documented codes: - # https://github.com/microsoft/winget-cli/blob/master/doc/windows/package-manager/winget/returnCodes.md - ok_codes=[ - 0x8A15002B, # No applicable update found - ], + "Try running: winget upgrade Gyan.FFmpeg", ) return @@ -381,29 +343,20 @@ def main() -> None: print("Checking for current bot version and local changes...") get_bot_version(git_bin) - # Check that the current working directory is clean. - # -suno is --short with --untracked-files=no + # Check that the current working directory is clean status_unclean = subprocess.check_output( - [git_bin, "status", "-suno", "--porcelain"], universal_newlines=True + [git_bin, "status", "--porcelain"], universal_newlines=True ) - if status_unclean.strip(): - # TODO: Maybe offering a stash option here would not be so bad... - print( - "Detected the following files have been modified:\n" - f"{status_unclean}\n" - "To update MusicBot source code, you must first remove modifications made to the above source files.\n" - "If you want to keep your changes, consider using `git stash` or otherwise back them up before you continue.\n" - "This script can automatically revert your modifications, but cannot automatically save them.\n" - ) + if status_unclean: hard_reset = yes_or_no_input( - "WARNING: All changed files listed above will be reset!\n" - "Would you like to reset the Source code, to allow MusicBot to update?" + "You have modified files that are tracked by Git (e.g the bot's source files).\n" + "Should we try to hard reset the repo? You will lose local modifications." ) if hard_reset: run_or_raise_error( [git_bin, "reset", "--hard"], "Could not hard reset the directory to a clean state.\n" - "You will need to manually reset the local git repository, or make a new clone of MusicBot.", + "You will need to run `git pull` manually.", ) else: do_deps = yes_or_no_input( diff --git a/update.sh b/update.sh index ab532be29..1302d18ba 100644 --- a/update.sh +++ b/update.sh @@ -3,22 +3,6 @@ # make sure we're in MusicBot directory... cd "$(dirname "${BASH_SOURCE[0]}")" || { echo "Could not change directory to MusicBot."; exit 1; } -# provides an exit that also deactivates venv. -function do_exit() { - if [ "${VIRTUAL_ENV}" != "" ] ; then - echo "Leaving MusicBot Venv..." - deactivate - fi - exit "$1" -} - -# attempt to find the "standard" venv and activate it. -if [ -f "../bin/activate" ] ; then - echo "Detected MusicBot Venv & Loading it..." - # shellcheck disable=SC1091 - source "../bin/activate" -fi - # Suported versions of python using only major.minor format PySupported=("3.8" "3.9" "3.10" "3.11" "3.12") @@ -75,11 +59,11 @@ done # if we don't have a good version for python, bail. if [[ "$VerGood" == "0" ]]; then echo "Python 3.8.7 or higher is required to update MusicBot." - do_exit 1 + exit 1 fi echo "Using '${Python_Bin}' to update MusicBot..." $Python_Bin update.py # exit using the code that python exited with. -do_exit $? +exit $?