diff --git a/.gitignore b/.gitignore index 02ce4d918..ec0145e5f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ *.pyc ~*/ .vscode/ +*.service audio_cache/ dectalk/ diff --git a/config/i18n/en.json b/config/i18n/en.json index 52fb7ab39..526bc3d8d 100644 --- a/config/i18n/en.json +++ b/config/i18n/en.json @@ -14,12 +14,15 @@ "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.", @@ -81,7 +84,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/install.bat b/install.bat index 3a3dfc647..d78d89529 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 91d26704c..ec8b76bf0 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. -# Current the script only supports one installation per user account. +# Currently 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,6 +8,17 @@ # # 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" "" @@ -34,32 +45,71 @@ if($iagree -ne "Y" -and $iagree -ne "y") Return } -if (-Not (Get-Command winget -ErrorAction SilentlyContinue) ) -{ +# First, unhide file extensions... +$FERegPath = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' +$HideExt = (Get-ItemProperty -Path $FERegPath -Name "HideFileExt").HideFileExt +if ($HideExt -eq 1) { "" - "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 " - "" - Return + "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 } -else + +# If no winget, try to download and install. +if (-Not (Get-Command winget -ErrorAction SilentlyContinue) ) { "" - "Checking WinGet can be used..." - "If prompted, you must agree to the MS terms to continue installing." + "Microsoft WinGet tool is required to continue installing." + "It will be downloaded from:" + " https://aka.ms/getwinget " "" - winget list -q Git.Git + "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 + } } +# +"" +"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..." @@ -69,6 +119,7 @@ if (!($LastExitCode -eq 0)) # install git "Installing git..." Invoke-Expression "winget install Git.Git" + $NeedsEnvReload = 1 "Done." } else @@ -85,6 +136,7 @@ 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 @@ -95,12 +147,13 @@ else # Check if ffmpeg is installed "Checking if FFmpeg is already installed..." -Invoke-Expression "winget list -q Gyan.FFmpeg" | Out-Null +Invoke-Expression "winget list -q ffmpeg" | Out-Null if (!($LastExitCode -eq 0)) { # install FFmpeg "Installing FFmpeg..." - Invoke-Expression "winget install Gyan.FFmpeg" + Invoke-Expression "winget install ffmpeg" + $NeedsEnvReload = 1 "Done." } else @@ -109,10 +162,11 @@ else } "" -# 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. +# try to reload environment variables... +if ($NeedsEnvReload -eq 1) +{ + $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") +} # --------------------------------------------------PULLING THE BOT---------------------------------------------------- @@ -125,32 +179,44 @@ 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." " 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" - if($experimental -eq "dev") - { - "Installing dev branch..." - $branch = "dev" - } - if($experimental -eq "review") - { - "Installing review branch..." - $branch = "review" - } - else - { - "Installing master branch..." - $branch = "master" + $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" + } + } } - Invoke-Expression "git clone https://github.com/Just-Some-Bots/MusicBot.git MusicBot -b $branch" - Invoke-Expression "cd MusicBot" + Invoke-Expression "git clone $MB_RepoURL '$Install_Dir' -b $branch" + Invoke-Expression "cd '$Install_Dir'" "" } @@ -169,7 +235,7 @@ $versionArray = "3.8", "3.9", "3.10", "3.11", "3.12" foreach ($version in $versionArray) { - Invoke-Expression "py -$version -c 'exit()'" | Out-Null + Invoke-Expression "py -$version -c 'exit()' 2>&1" | Out-Null if($LastExitCode -eq 0) { $PYTHON = "py -$version" @@ -177,6 +243,7 @@ foreach ($version in $versionArray) } "Using $PYTHON to install and run MusicBot..." +"" Invoke-Expression "$PYTHON -m pip install --upgrade -r requirements.txt" # -------------------------------------------------CONFIGURE THE BOT--------------------------------------------------- @@ -190,7 +257,9 @@ if($iagree -ne "Y" -and $iagree -ne "y") { "All done!" "Remember to configure your bot token and other options before you start." - "You can use run.bat to start the MusicBot." + "You must open a new command prompt before using run.bat to start the MusicBot." + "MusicBot was installed to:" + " $Install_Dir" Return } @@ -265,4 +334,7 @@ else "Saving your config..." Set-Content -Path ".\config\options.ini" -Value $config -"You can now use run.bat to run the bot" +"You can use run.bat to run the bot." +"Restart your command prompt first!" +"MusicBot was installed to:" +" $Install_Dir" diff --git a/install.sh b/install.sh index 4052ed9f3..512ad69d3 100644 --- a/install.sh +++ b/install.sh @@ -10,7 +10,9 @@ #-----------------------------------------------Configs-----------------------------------------------# MusicBotGitURL="https://github.com/Just-Some-Bots/MusicBot.git" CloneDir="MusicBot" -VenvDir="${CloneDir}Venv" +VenvDir="MusicBotVenv" +InstallDir="" +ServiceName="musicbot" EnableUnlistedBranches=0 DEBUG=0 @@ -54,6 +56,72 @@ 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 @@ -115,19 +183,45 @@ function find_python() { return 1 } -function pull_musicbot_git() { - echo "" - # Check if we're running inside a previously pulled repo. +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" - if [ -d "$GitDir" ] && [ -d "$BotDir" ] && [ -f "$ReqFile" ] ; then + 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 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 "" @@ -138,11 +232,18 @@ function pull_musicbot_git() { echo "Installer will attempt to create a new directory for MusicBot." 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." + # 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 fi echo "" @@ -150,6 +251,9 @@ 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 @@ -179,79 +283,250 @@ function pull_musicbot_git() { $PyBin -m pip install --upgrade -r requirements.txt echo "" - cp ./config/example_options.ini ./config/options.ini + 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 + } 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 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." + echo "The installer can also install MusicBot as a system service." + echo "This starts the MusicBot at boot and restarts after failures." read -rp "Install the musicbot system service? [N/y] " SERVICE case $SERVICE in [Yy]*) - # 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... + ask_change_service_name + ask_change_user_group - if [ "$BotSysUserName" == "" ] ; then + if [ "$Inst_User" == "" ] ; then echo "Cannot set up the service with a blank User name." - return + return 1 fi - if [ "$BotSysGroupName" == "" ] ; then + if [ "$Inst_Group" == "" ] ; then echo "Cannot set up the service with a blank Group name." - return + return 1 fi - 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 - - # 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 + SrvCpyFile="./${ServiceName}.service" + SrvInstFile="/etc/systemd/system/${ServiceName}.service" + + echo "" + echo "Setting up MusicBot as a service named: ${ServiceName}" + echo "Generated File: ${SrvCpyFile}" - echo "Bot setup as a service and started" - ask_setup_aliases - ;; - esac + 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" -} + echo "Installed File: ${SrvInstFile}" -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 "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 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 "[DEBUG] $msg" 1>&2 + echo -e "\e[1;36m[DEBUG]\e[0m $msg" 1>&2 fi } @@ -372,24 +647,85 @@ function configure_bot() { esac } -#------------------------------------------------Logic------------------------------------------------# -# 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//[|)]/}" +#------------------------------------------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 + ;; - echo "The MusicBot installer might have support for these flavors of Linux:" - echo "$Avail" - echo "" + --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 exit 0 fi +# display preamble cat << EOF MusicBot Installer @@ -415,106 +751,197 @@ For a list of potentially supported OS, run the command: EOF -echo "We detected your OS is: ${DISTRO_NAME}" +echo "We detected your OS is: $(distro_supported)" 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 "" -echo "Attempting to install required system packages..." +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 "" case $DISTRO_NAME in *"Arch Linux"*) # Tested working 2024.03.01 @ 2024/03/31 - # 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_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 - # Make sure newly install python is used. - find_python + if [ "$INSTALL_BOT_BITS" == "1" ] ; then + install_as_venv + fi + ;; - # 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" +*"Pop!_OS"* ) + case $DISTRO_NAME in - # Update python to use venv path. - find_python + # 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 - pull_musicbot_git + if [ "$INSTALL_BOT_BITS" == "1" ] ; then + pull_musicbot_git + fi + ;; - deactivate - ;; + *"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 -*"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 + if [ "$INSTALL_BOT_BITS" == "1" ] ; then + install_as_venv + fi + ;; - pull_musicbot_git + *) + echo "Unsupported version of Pop! OS." + exit 1 + ;; + esac ;; *"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 - 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 + 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 fi fi - pull_musicbot_git + if [ "$INSTALL_BOT_BITS" == "1" ] ; then + pull_musicbot_git + fi ;; # Tested working: # 20.04 @ 2024/03/28 # 22.04 @ 2024/03/30 *"Ubuntu 20"*|*"Ubuntu 22"*) - 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_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 - pull_musicbot_git + 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 + + if [ "$INSTALL_BOT_BITS" == "1" ] ; then + install_as_venv + fi ;; # Ubuntu version 17 and under is not supported. @@ -527,41 +954,40 @@ 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"*) - 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_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 - pull_musicbot_git + if [ "$INSTALL_BOT_BITS" == "1" ] ; then + pull_musicbot_git + fi ;; - *"Debian GNU/Linux 12"*) # Tested working 12.5 @ 2024/03/31 + # 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 12 uses system controlled python packages. - 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 - - # 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 + 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 - pull_musicbot_git - - # exit venv - deactiveate + if [ "$INSTALL_BOT_BITS" == "1" ] ; then + install_as_venv + fi ;; *) @@ -573,19 +999,23 @@ case $DISTRO_NAME in # Legacy install, needs testing. # Modern Raspberry Pi OS does not return "Raspbian" *"Raspbian"*) - 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 + 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 ;; *"CentOS"* ) - # Get the full release name and version + # Get the full release name and version for CentOS if [ -f "/etc/redhat-release" ]; then DISTRO_NAME=$(cat /etc/redhat-release) fi @@ -604,59 +1034,66 @@ case $DISTRO_NAME in # Supported versions. *"CentOS 7"*) # Tested 7.9 @ 2024/03/28 # TODO: CentOS 7 reaches EOL June 2024. - - # 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 + 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 fi fi - pull_musicbot_git + if [ "$INSTALL_BOT_BITS" == "1" ] ; then + pull_musicbot_git + fi ;; *"CentOS Stream 8"*) # Tested 2024/03/28 - # 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 + 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 - pull_musicbot_git + if [ "$INSTALL_BOT_BITS" == "1" ] ; then + pull_musicbot_git + fi ;; # Currently unsupported. @@ -669,18 +1106,23 @@ case $DISTRO_NAME in # Legacy installer, needs testing. *"Darwin"*) - /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 + 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 ;; *) @@ -690,8 +1132,10 @@ case $DISTRO_NAME in esac if ! [[ $DISTRO_NAME == *"Darwin"* ]]; then - configure_bot - setup_as_service + if [ "$INSTALL_BOT_BITS" == "1" ] ; then + configure_bot + setup_as_service + fi 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" @@ -701,12 +1145,13 @@ fi if [ "$InstalledViaVenv" == "1" ] ; then echo "" echo "Notice:" - 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 " 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 "" - 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.service b/musicbot.service.example similarity index 72% rename from musicbot.service rename to musicbot.service.example index 244ba5083..6fabb2248 100644 --- a/musicbot.service +++ b/musicbot.service.example @@ -1,3 +1,12 @@ +# This is an example of a systemd service file for MusicBot. +# +# You should copy and rename this file before you edit it! +# +# For details about systemd service files, see the following links: +# https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html +# https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html + + [Unit] Description=Just-Some-Bots/MusicBot a discord.py bot that plays music. diff --git a/musicbot/bot.py b/musicbot/bot.py index e9f01ff13..2f3b355c3 100644 --- a/musicbot/bot.py +++ b/musicbot/bot.py @@ -401,7 +401,10 @@ 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): + async with self.session.head( + ping_host, + timeout=FALLBACK_PING_TIMEOUT, # type: ignore[arg-type,unused-ignore] + ): return 0 except (aiohttp.ClientError, asyncio.exceptions.TimeoutError, OSError): return 1 @@ -2648,7 +2651,7 @@ async def handle_vc_inactivity(self, guild: discord.Guild) -> None: else: log.info( "Channel activity timer canceled for: %s in %s", - guild.voice_client.channel.name, + getattr(guild.voice_client.channel, "name", guild.voice_client.channel), guild.name, ) finally: @@ -3092,14 +3095,18 @@ 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. @@ -3125,6 +3132,39 @@ 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) @@ -4114,7 +4154,7 @@ async def _cmd_play( # 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 == "youtube:search": + if info.extractor.startswith("youtube:search"): # TOOD: UI, i18n stuff raise exceptions.CommandError( f"Youtube search returned no results for: {song_url}" @@ -4171,7 +4211,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", "") == "youtube:playlist": + if info.get("extractor", "").startswith("youtube:playlist"): log.noise( # type: ignore[attr-defined] "Extracted an entry with youtube:playlist as extractor key" ) @@ -4268,7 +4308,7 @@ async def cmd_stream( await self._do_cmd_unpause_check(_player, channel, author, message) - if permissions.summonplay: + if permissions.summonplay and not _player: response = await self.cmd_summon(guild, author, message) if response: if self.config.embeds: @@ -4811,49 +4851,55 @@ async def cmd_summon( Call the bot to the summoner's voice channel. """ - # @TheerapakG: Maybe summon should have async lock? + lock_key = f"summon:{guild.id}" - 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 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!", + ) ) - ) - 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, @@ -5002,7 +5048,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, @@ -5496,7 +5542,6 @@ async def cmd_config( ) option = option.lower() - valid_options = [ "missing", "diff", @@ -5508,7 +5553,6 @@ async def cmd_config( "reload", "reset", ] - if option not in valid_options: raise exceptions.CommandError( f"Invalid option for command: `{option}`", @@ -7120,7 +7164,7 @@ async def cmd_makemarkdown( valid_opts = ["opts", "perms"] if cfg not in valid_opts: opts = ", ".join([f"`{o}`" for o in valid_opts]) - raise exceptions.CommandError("Option must be one of: %s" % (opts)) + raise exceptions.CommandError(f"Option must be one of: {opts}") filename = "config_options.md" msg_str = "Config options described in Markdown:\n" diff --git a/musicbot/config.py b/musicbot/config.py index 5c42b6227..37c113a32 100644 --- a/musicbot/config.py +++ b/musicbot/config.py @@ -8,9 +8,10 @@ import time from typing import ( TYPE_CHECKING, - Dict, + Any, Iterable, List, + Mapping, Optional, Set, Tuple, @@ -57,7 +58,7 @@ from .permissions import Permissions # Type for ConfigParser.get(... vars) argument -ConfVars = Optional[Dict[str, str]] +ConfVars = Optional[Mapping[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] @@ -788,7 +789,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", @@ -809,7 +810,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", @@ -1812,10 +1813,10 @@ def export_markdown(self) -> str: # fmt: off md_option = ( - "#### %s\n" - "%s \n" - "**Default Value:** %s \n\n" - ) % (opt.option, opt.comment, dval) + 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] @@ -1825,7 +1826,7 @@ def export_markdown(self) -> str: markdown = "" for sect in self._parser.sections(): opts = md_sections[sect] - markdown += "### [%s]\n%s" % (sect, "".join(opts)) + markdown += f"### [{sect}]\n{''.join(opts)}" return markdown @@ -1867,7 +1868,7 @@ def getstr( section: str, key: str, raw: bool = False, - vars: ConfVars = None, + vars: ConfVars = None, # pylint: disable=redefined-builtin fallback: str = "", ) -> str: """A version of get which strips spaces and uses fallback / default for empty values.""" @@ -1879,19 +1880,20 @@ def getstr( def getboolean( # type: ignore[override] self, section: str, - key: str, + option: str, *, raw: bool = False, vars: ConfVars = None, # pylint: disable=redefined-builtin - fallback: bool, + 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, key, fallback="", raw=raw, vars=vars).strip() + val = self.get(section, option, fallback="", raw=raw, vars=vars).strip() if not val: return fallback try: - return super().getboolean(section, key, fallback=fallback) + return super().getboolean(section, option, fallback=fallback) except ValueError: return fallback diff --git a/musicbot/downloader.py b/musicbot/downloader.py index 499ceea1d..6ef002536 100644 --- a/musicbot/downloader.py +++ b/musicbot/downloader.py @@ -525,7 +525,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", "") == "youtube:search" + data.get("extractor", "").startswith("youtube:search") and len(data.get("entries", [])) == 1 and isinstance(data.get("entries", None), list) and data.get("playlist_count", 0) == 1 @@ -728,7 +728,7 @@ def thumbnail_url(self) -> str: return turl # if all else fails, try to make a URL on our own. - if self.extractor == "youtube": + if self.extractor.startswith("youtube"): if self.video_id: return f"https://i.ytimg.com/vi/{self.video_id}/maxresdefault.jpg" @@ -875,7 +875,7 @@ def is_stream(self) -> bool: return True # Warning: questionable methods from here on. - if self.extractor == "generic": + if self.extractor.startswith("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 67af4dce1..456a6f71e 100644 --- a/musicbot/entry.py +++ b/musicbot/entry.py @@ -444,7 +444,7 @@ async def _ensure_entry_info(self) -> None: self.info = info else: raise InvalidDataError( - "Cannot download spotify links, these should be extracted before now." + f"Cannot download spotify links, processing error with type: {info.ytdl_type}." ) # if this isn't set this entry is probably from a playlist and needs more info. diff --git a/musicbot/playlist.py b/musicbot/playlist.py index 0ee97f952..a015d4ac3 100644 --- a/musicbot/playlist.py +++ b/musicbot/playlist.py @@ -187,7 +187,7 @@ async def add_entry_from_info( ) # TODO: Extract this to its own function - if info.extractor in ["generic", "Dropbox"]: + if any(info.extractor.startswith(x) for x in ["generic", "Dropbox"]): content_type = info.http_header("content-type", None) if content_type: diff --git a/musicbot/spotify.py b/musicbot/spotify.py index 207faf627..22ea66c42 100644 --- a/musicbot/spotify.py +++ b/musicbot/spotify.py @@ -112,9 +112,11 @@ class SpotifyTrack(SpotifyObject): def __init__( self, track_data: Dict[str, Any], origin_url: Optional[str] = None ) -> None: - if not SpotifyObject.is_track_data(track_data): - raise SpotifyError("Invalid track_data, must be of type 'track'") 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}`" + ) @property def artist_name(self) -> str: @@ -289,8 +291,14 @@ def _create_track_objects(self) -> None: raise ValueError("Invalid playlist_data, missing track key in items") track_data = item.get("track", None) - if track_data: + track_type = track_data.get("type", None) + if track_data and track_type == "track": 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]: @@ -308,6 +316,11 @@ 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: """ @@ -532,7 +545,9 @@ async def get_playlist_object_complete(self, list_id: str) -> SpotifyPlaylist: pldata["tracks"]["items"] = tracks - return SpotifyPlaylist(pldata) + plobj = SpotifyPlaylist(pldata) + log.debug("Spotify Playlist contained %s usable tracks.", plobj.tracks_loaded) + return plobj async def get_playlist_object(self, list_id: str) -> SpotifyPlaylist: """Lookup a spotify playlist by its ID and return a SpotifyPlaylist object""" @@ -559,6 +574,7 @@ 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/requirements.txt b/requirements.txt index 5efec313c..69092369c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ pynacl +#discord.py [voice, speed] >= 2.4.0 discord.py [voice, speed] @ git+https://github.com/Rapptz/discord.py -# Windows users may want to install orjson and pycares for speed, but avoid aiodns for now. pip yt-dlp colorlog diff --git a/run.sh b/run.sh index a103a9c15..0bb064be6 100755 --- a/run.sh +++ b/run.sh @@ -4,6 +4,22 @@ # 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") @@ -60,7 +76,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." - exit 1 + do_exit 1 fi echo "Using '${Python_Bin}' to launch MusicBot..." @@ -69,4 +85,4 @@ echo "Using '${Python_Bin}' to launch MusicBot..." $Python_Bin run.py "$@" # exit using the code that python exited with. -exit $? +do_exit $? diff --git a/update.py b/update.py index efd27ba47..06fa56853 100644 --- a/update.py +++ b/update.py @@ -29,15 +29,20 @@ 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 @@ -130,18 +135,35 @@ def update_deps() -> None: """ print("Attempting to update dependencies...") - run_or_raise_error( - [ + # 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 = [ sys.executable, "-m", "pip", "install", "--no-warn-script-location", - "--user", + # No --user site-packages in venv "-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", ) @@ -270,10 +292,15 @@ def update_ffmpeg() -> None: [ winget_bin, "upgrade", - "Gyan.FFmpeg", + "ffmpeg", ], "Could not update ffmpeg. You need to update it manually." - "Try running: winget upgrade Gyan.FFmpeg", + "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 + ], ) return diff --git a/update.sh b/update.sh index 1302d18ba..ab532be29 100644 --- a/update.sh +++ b/update.sh @@ -3,6 +3,22 @@ # 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") @@ -59,11 +75,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." - exit 1 + do_exit 1 fi echo "Using '${Python_Bin}' to update MusicBot..." $Python_Bin update.py # exit using the code that python exited with. -exit $? +do_exit $?