Restore configured volume after playback stops

This commit is contained in:
Storm Dragon
2026-06-14 01:48:13 -04:00
parent e2df8972e7
commit 14163bdbc1
7 changed files with 229 additions and 97 deletions
+1
View File
@@ -98,6 +98,7 @@ username = Bragi
music_folder = music_folder/
tmp_folder = /tmp/
volume = 0.5
restore_volume_on_stop = False
```
**Generate Certificate (Recommended):**
+15
View File
@@ -716,6 +716,7 @@ class MumbleBot:
var.cache.free_and_delete(current.id)
else:
self._loop_status = 'Empty queue'
self.restore_default_volume()
else:
# if wait_for_ready flag is true, means the pointer is already
# pointing to target song. start playing
@@ -855,6 +856,7 @@ class MumbleBot:
self._cleanup_ffmpeg_process() # Ensure proper cleanup
var.playlist.clear()
self.wait_for_ready = False
self.restore_default_volume()
self.log.info("bot: music stopped. playlist trashed.")
def stop(self):
@@ -865,8 +867,21 @@ class MumbleBot:
self.wait_for_ready = True
else:
self.wait_for_ready = False
self.restore_default_volume()
self.log.info("bot: music stopped.")
def restore_default_volume(self):
if not var.config.getboolean("bot", "restore_volume_on_stop"):
return
max_volume = var.config.getfloat("bot", "max_volume")
if var.db.has_option("bot", "max_volume"):
max_volume = var.db.getfloat("bot", "max_volume")
target_volume = min(var.config.getfloat("bot", "volume"), max_volume)
self.volume_helper.set_volume(target_volume)
var.db.set("bot", "volume", str(target_volume))
def _cleanup_ffmpeg_process(self):
"""Properly cleanup FFmpeg process and associated resources"""
if self.thread:
+1 -1
View File
@@ -61,6 +61,7 @@ playback_mode = one-shot
rebuild_workers = 0
redirect_stderr = True
refresh_cache_on_startup = True
restore_volume_on_stop = False
save_music_library = True
save_playlist = True
stereo = True
@@ -155,4 +156,3 @@ yt_search = ysearch
#
# File paths use fuzzy matching - the first matching file in the music database will be used
# URLs are downloaded and played like the !url command
+3
View File
@@ -53,7 +53,10 @@ port = 64738
# 'volume': The default volume, a number from 0 to 1.
# This option will be overridden by the value set in the database.
# 'restore_volume_on_stop': When True, stopping playback restores the live and
# persisted volume back to this config-file default volume.
#volume = 0.1
#restore_volume_on_stop = False
# 'bandwidth': The number of bits per second used by the bot when streaming audio.
# Enabling this option will allow you to set it higher than the default value.
-13
View File
@@ -30,19 +30,6 @@ def tr_cli(option, *argv, **kwargs):
except KeyError:
raise KeyError("Missed strings in language file: '{string}'. ".format(string=option))
return _tr(string, *argv, **kwargs)
def tr_web(option, *argv, **kwargs):
try:
if option in lang_dict['web'] and lang_dict['web'][option]:
string = lang_dict['web'][option]
else:
string = default_lang_dict['web'][option]
except KeyError:
raise KeyError("Missed strings in language file: '{string}'. ".format(string=option))
return _tr(string, *argv, **kwargs)
def _tr(string, *argv, **kwargs):
if argv or kwargs:
try:
+3 -83
View File
@@ -2,7 +2,7 @@
"cli": {
"added_tags": "Added tags <i>{tags}</i> to <b>{song}</b>.",
"added_tags_to_all": "Added tags <i>{tags}</i> to songs on the playlist.",
"admin_help": "<h3>Admin command</h3>\n<b>Bot</b>\n<ul>\n<li><b>!<u>k</u>ill </b> - kill the bot</li>\n<li><b>!update </b> - update the bot</li>\n<li><b>!userban </b> {user} - ban a user</li>\n<li><b>!userunban </b> {user} - unban a user</li>\n<li><b>!urlbanlist </b> - list banned url</li>\n<li><b>!urlban </b> [{url}] - ban {url} (or current item's url by default) and remove this url from the library.</li>\n<li><b>!urlunban </b> {url} - unban {url}</li>\n<li><b>!rescan </b> {url} - rebuild local music file cache</li>\n<li><b>!dropdatabase</b> - clear the entire database, you will lose all settings and music library.</li>\n</ul>\n<b>Web Interface</b>\n<ul>\n<li><b>!<u>webuserlist</u></b> - list all users that have the permission of accessing the web interface, if auth mode is 'password'.</li>\n<li><b>!<u>webuseradd</u> {nick name}</b> - grant the user with {nick name} the access to the web interface, if auth mode is 'password'.</li>\n<li><b>!<u>webuserdel</u> {nick name}</b> - revoke the access to the web interface of {nick name}, if auth mode is 'password'.</li>\n</ul>",
"admin_help": "<h3>Admin command</h3>\n<b>Bot</b>\n<ul>\n<li><b>!<u>k</u>ill </b> - kill the bot</li>\n<li><b>!update </b> - update the bot</li>\n<li><b>!userban </b> {user} - ban a user</li>\n<li><b>!userunban </b> {user} - unban a user</li>\n<li><b>!urlbanlist </b> - list banned url</li>\n<li><b>!urlban </b> [{url}] - ban {url} (or current item's url by default) and remove this url from the library.</li>\n<li><b>!urlunban </b> {url} - unban {url}</li>\n<li><b>!rescan </b> {url} - rebuild local music file cache</li>\n<li><b>!dropdatabase</b> - clear the entire database, you will lose all settings and music library.</li>\n<li><b>!maxvolume </b> {volume} - set the maximum allowed volume.</li>\n</ul>",
"auto_paused": "Use <i>!play</i> to resume music!",
"bad_command": "<i>{command}</i>: command not found.",
"bad_parameter": "<i>{command}</i>: invalid parameter.",
@@ -28,7 +28,7 @@
"file_deleted": "Deleted {item} from the library.",
"file_item": "<b>{artist} - {title}</b> <i>added by</i> {user}",
"file_missed": "Music file '{file}' missed! This item has been removed from the playlist.",
"help": "<h3>Commands</h3>\n<b>Control</b>\n<ul>\n<li> <b>!<u>w</u>eb</b> - get the URL of the web interface, if enabled. </li>\n<li> <b>!play </b> (or <b>!p</b>) [{num}] [{start_from}] - resume from pausing / start to play (the num-th song is num if given) </li>\n<li> <b>!<u>pa</u>use </b> - pause </li>\n<li> <b>!<u>st</u>op </b> - stop playing </li>\n<li> <b>!<u>sk</u>ip </b> - jump to the next song </li>\n<li> <b>!<u>la</u>st </b> - jump to the last song </li>\n<li> <b>!<u>v</u>olume </b> {volume} - get or change the volume (from 0 to 100) </li>\n<li> <b>!<u>m</u>ode </b> [{mode}] - get or set the playback mode, {mode} should be one of <i>one-shot</i> (remove\nitem once played), <i>repeat</i> (looping through the playlist), <i>random</i> (randomize the playlist),\n<i>autoplay</i> (randomly grab something from the music library).</li>\n<li> <b>!duck </b> on/off - enable or disable ducking function </li>\n<li> <b>!duckv </b> {volume} - set the volume of the bot when ducking is activated </li>\n<li> <b>!<u>duckt</u>hres </b> - set the threshold of volume to activate ducking (3000 by default) </li>\n<li> <b>!<u>o</u>ust </b> - stop playing and go to default channel </li>\n</ul>\n<b>Playlist</b>\n<ul>\n<li> <b>!<u>n</u>ow </b> (or <b>!np</b>) - display the current song </li>\n<li> <b>!<u>q</u>ueue </b> - display items in the playlist </li>\n<li> <b>!<u>t</u>ag </b> {tags} - add all items with tags {tags}, tags separated by \",\". </li>\n<li> <b>!file </b>(or <b>!f</b>) {path/folder/keyword} - add a single file to the playlist by its path or keyword in its path. </li>\n<li> <b>!<u>filem</u>atch </b>(or <b>!fm</b>) {pattern} - add all files that match regex {pattern} </li>\n<li> <b>!<u>ur</u>l </b> {url} - add Youtube or SoundCloud music </li>\n<li> <b>!<u>playl</u>ist </b> {url} [{offset}] - add all items in a Youtube or SoundCloud playlist, and start with the {offset}-th item </li>\n<li> <b>!<u>rad</u>io </b> {url} - append a radio {url} to the playlist </li>\n<li> <b>!<u>rbq</u>uery </b> {keyword} - query http://www.radio-browser.info for a radio station </li>\n<li> <b>!<u>rbp</u>lay </b> {id} - play a radio station with {id} (eg. !rbplay 96746) </li>\n<li> <b>!<u>ys</u>earch </b> {keywords} - query youtube. Use <i>!ysearch -n</i> to turn the page. </li>\n<li> <b>!<u>yp</u>lay </b> {keywords} - add the first search result of {keywords} into the playlist.</li>\n<li> <b>!<u>sh</u>ortlist </b> (or <b>!sl</b>) {indexes/*} - add {indexes}-th item (or all items if * is given) on the shortlist. </li>\n<li> <b>!rm </b> {num} - remove the num-th song on the playlist </li>\n<li> <b>!<u>rep</u>eat </b> [{num}] - repeat current song {num} (1 by default) times.</li>\n<li> <b>!<u>ran</u>dom </b> - randomize the playlist.</li>\n</ul>\n<b>Music Library</b>\n<ul>\n<li> <b>!<u>se</u>arch </b> {keywords} - find item with {keywords} in the music library, keywords separated by space.</li>\n<li> <b>!<u>li</u>stfile </b> [{pattern}] - display list of available files (whose paths match the regex pattern if {pattern} is given) </li>\n<li> <b>!<u>addt</u>ag </b> [{index}] {tags} - add {tags} to {index}-th(current song if {index} is omitted) item on the playlist, tags separated by \",\". </li>\n<li> <b>!<u>addt</u>ag </b> * {tags} - add {tags} to all items on the playlist. </li>\n<li> <b>!<u>un</u>tag </b> [{index/*}] {tags}/* - remove {tags}/all tags from {index}-th(current song if {index} is omitted) item on the playlist. </li>\n<li> <b>!<u>fin</u>dtagged </b> (or <b>!ft</b>) {tags} - find item with {tags} in the music library. </li>\n<li> <b>!<u>del</u>ete </b> {index} - delete {index}-th item on the shortlist from the music library. </li>\n</ul>\n<b>Other</b>\n<ul>\n<li> <b>!<u>j</u>oinme {token} </b> - join your own channel with {token}.</li>\n<li> <b>!<u>password</u> {password} </b> - change your password, used to access the web interface.</li>\n</ul>",
"help": "<h3>Commands</h3>\n<b>Control</b>\n<ul>\n<li> <b>!play </b> (or <b>!p</b>) [{num}] [{start_from}] - resume from pausing / start to play (the num-th song is num if given) </li>\n<li> <b>!<u>pa</u>use </b> - pause </li>\n<li> <b>!<u>st</u>op </b> - stop playing </li>\n<li> <b>!<u>sk</u>ip </b> - jump to the next song </li>\n<li> <b>!<u>la</u>st </b> - jump to the last song </li>\n<li> <b>!<u>v</u>olume </b> {volume} - get or change the volume (from 0 to 100) </li>\n<li> <b>!<u>m</u>ode </b> [{mode}] - get or set the playback mode, {mode} should be one of <i>one-shot</i> (remove\nitem once played), <i>repeat</i> (looping through the playlist), <i>random</i> (randomize the playlist),\n<i>autoplay</i> (randomly grab something from the music library).</li>\n<li> <b>!duck </b> on/off - enable or disable ducking function </li>\n<li> <b>!duckv </b> {volume} - set the volume of the bot when ducking is activated </li>\n<li> <b>!<u>duckt</u>hres </b> - set the threshold of volume to activate ducking (3000 by default) </li>\n<li> <b>!<u>o</u>ust </b> - stop playing and go to default channel </li>\n</ul>\n<b>Playlist</b>\n<ul>\n<li> <b>!<u>n</u>ow </b> (or <b>!np</b>) - display the current song </li>\n<li> <b>!<u>q</u>ueue </b> - display items in the playlist </li>\n<li> <b>!<u>t</u>ag </b> {tags} - add all items with tags {tags}, tags separated by \",\". </li>\n<li> <b>!file </b>(or <b>!f</b>) {path/folder/keyword} - add a single file to the playlist by its path or keyword in its path. </li>\n<li> <b>!<u>filem</u>atch </b>(or <b>!fm</b>) {pattern} - add all files that match regex {pattern} </li>\n<li> <b>!<u>ur</u>l </b> {url} - add Youtube or SoundCloud music </li>\n<li> <b>!<u>playl</u>ist </b> {url} [{offset}] - add all items in a Youtube or SoundCloud playlist, and start with the {offset}-th item </li>\n<li> <b>!<u>rad</u>io </b> {url} - append a radio {url} to the playlist </li>\n<li> <b>!<u>rbq</u>uery </b> {keyword} - query http://www.radio-browser.info for a radio station </li>\n<li> <b>!<u>rbp</u>lay </b> {id} - play a radio station with {id} (eg. !rbplay 96746) </li>\n<li> <b>!<u>ys</u>earch </b> {keywords} - query youtube. Use <i>!ysearch -n</i> to turn the page. </li>\n<li> <b>!<u>yp</u>lay </b> {keywords} - add the first search result of {keywords} into the playlist.</li>\n<li> <b>!<u>sh</u>ortlist </b> (or <b>!sl</b>) {indexes/*} - add {indexes}-th item (or all items if * is given) on the shortlist. </li>\n<li> <b>!rm </b> {num} - remove the num-th song on the playlist </li>\n<li> <b>!<u>rep</u>eat </b> [{num}] - repeat current song {num} (1 by default) times.</li>\n<li> <b>!<u>ran</u>dom </b> - randomize the playlist.</li>\n</ul>\n<b>Music Library</b>\n<ul>\n<li> <b>!<u>se</u>arch </b> {keywords} - find item with {keywords} in the music library, keywords separated by space.</li>\n<li> <b>!<u>li</u>stfile </b> [{pattern}] - display list of available files (whose paths match the regex pattern if {pattern} is given) </li>\n<li> <b>!<u>addt</u>ag </b> [{index}] {tags} - add {tags} to {index}-th(current song if {index} is omitted) item on the playlist, tags separated by \",\". </li>\n<li> <b>!<u>addt</u>ag </b> * {tags} - add {tags} to all items on the playlist. </li>\n<li> <b>!<u>un</u>tag </b> [{index/*}] {tags}/* - remove {tags}/all tags from {index}-th(current song if {index} is omitted) item on the playlist. </li>\n<li> <b>!<u>fin</u>dtagged </b> (or <b>!ft</b>) {tags} - find item with {tags} in the music library. </li>\n<li> <b>!<u>del</u>ete </b> {index} - delete {index}-th item on the shortlist from the music library. </li>\n</ul>\n<b>Other</b>\n<ul>\n<li> <b>!<u>j</u>oinme {token} </b> - join your own channel with {token}.</li>\n</ul>",
"invalid_index": "Invalid index <i>{index}</i>. Use <i>!queue</i> to see the playlist.",
"last_song_on_the_queue": "Last one on the queue.",
"max_volume": "Volume exceeds max volume of {max}. Setting volume to max.",
@@ -83,91 +83,11 @@
"user_ban": "You are banned, not allowed to do that!",
"user_ban_list": "List of banned user: <br>{list}",
"user_ban_success": "User {user} is banned.",
"user_password_set": "Your password has been updated.",
"user_unban_success": "User {user} is unbanned.",
"web_user_list": "Following users have the privilege to access the web interface: <br /> {users}",
"webpage_address": "Your own address to access the web interface is <a href=\"{address}\">{address}</a>",
"which_command": "Do you mean <br /> {commands}",
"wrong_pattern": "Invalid regex: {error}.",
"yt_no_more": "No more results!",
"yt_query_error": "Unable to query youtube!",
"yt_result": "Youtube query result: {result_table} Use <i>!sl {{indexes}}</i> to play the item you want. <br />\n<i>!ytquery -n</i> for the next page."
},
"web": {
"action": "Action",
"add": "Add",
"add_all": "Add All",
"add_radio": "Add Radio",
"add_radio_url": "Add Radio URL",
"add_to_bottom": "Add to bottom",
"add_to_bottom_of_current_playlist": "Add to bottom of current playlist",
"add_to_playlist_next": "Add to playlist right after current song",
"add_url": "Add URL",
"add_youtube_or_soundcloud_url": "Add Youtube or Soundcloud URL",
"are_you_really_sure": "Are you really sure?",
"aria_bragi_logo": "Botamusique Logo: a fox with two headphones, enjoying the music",
"aria_default_cover": "A black square with two eighth notes beamed together.",
"aria_empty_box": "A drawing of an empty box.",
"aria_remove_this_song": "Remove this song from the current playlist",
"aria_skip_current_song": "Skip current song and play this song right now",
"aria_skip_to_next_track": "Skip to next track",
"aria_spinner": "A loading spinner",
"aria_warning_of_deletion": "Warning about deletion of files.",
"autoplay": "Autoplay",
"browse_music_file": "Browse Music file",
"cancel": "Cancel",
"cancel_upload_warning": "<strong>Are you really sure?</strong> <br /> Click again to abort uploading.",
"change_playback_mode": "Change Playback Mode",
"choose_file": "Choose file",
"clear_playlist": "Clear Playlist",
"close": "Close",
"delete_all": "Delete All",
"delete_all_files": "Delete All Listed Files",
"delete_file_warning": "All files listed here, include files on other pages, will be deleted from your hard-drive.\n Is that what you want?",
"directory": "Directory",
"download_all": "Download All",
"download_song_from_library": "Download song from library",
"edit_submit": "Edit!",
"edit_tags_for": "Edit tags for",
"expand_playlist": "See item <span\n class=\"playlist-expand-item-range\"></span> on the playlist.",
"file": "File",
"filters": "Filters",
"index": "#",
"keywords": "Keywords",
"keywords_placeholder": "Keywords...",
"mini_player_title": "Now Playing...",
"music_library": "Music Library",
"next_to_play": "Next to play",
"no_tag": "No tag",
"oneshot": "One-shot",
"open_volume_controls": "Open Volume Controls",
"page_title": "bragi Web Interface",
"pause": "Pause",
"play": "Play",
"playlist_controls": "Playlist controls",
"radio": "Radio",
"radio_url_placeholder": "Radio URL...",
"random": "Random",
"remove_song_from_library": "Remove song from library",
"repeat": "Repeat",
"rescan_files": "Rescan Files",
"skip_track": "Skip Track",
"submit": "Submit",
"tags": "Tags",
"tags_to_add": "Tags to add",
"title": "Title",
"token": "Token",
"token_required": "Token Required",
"token_required_message": "You are accessing the web interface of {{ name }}.\nA token is needed to grant you access.<br />\nPlease send \"{{ command }}\" to the bot in mumble to acquire one.",
"type": "Type",
"upload_file": "Upload File",
"upload_submit": "Upload!",
"upload_to": "Upload To",
"uploaded_finished": "Uploaded finished!",
"uploading_files": "Uploading files...",
"url": "URL",
"url_path": "Url/Path",
"url_placeholder": "URL...",
"volume_slider": "Volume Slider"
}
}
}
+206
View File
@@ -0,0 +1,206 @@
import configparser
import logging
import os
import tempfile
import types
import unittest
from unittest import mock
import bragi
import constants
from database import DatabaseMigration, MusicDatabase, SettingsDatabase
import command
import util
import variables as var
class FakeBot:
def __init__(self):
self.registered = []
def register_command(self, cmd, handle, no_partial_match=False, access_outside_channel=False, admin=False):
self.registered.append(cmd)
class FakePlaylist:
def __init__(self, item=None):
self.item = item
def __len__(self):
return 1 if self.item is not None else 0
def current_item(self):
return self.item
class ExhaustedPlaylist:
current_index = -1
def next(self):
return False
class FakeSoundOutput:
def get_buffer_size(self):
return 0
class FakeMumble:
def __init__(self):
self.sound_output = FakeSoundOutput()
self.alive_checks = 0
def is_alive(self):
self.alive_checks += 1
return self.alive_checks < 2
class VolumeRestoreTests(unittest.TestCase):
def setUp(self):
settings_file = tempfile.NamedTemporaryFile(delete=False)
settings_file.close()
music_file = tempfile.NamedTemporaryFile(delete=False)
music_file.close()
self.settings_path = settings_file.name
self.music_path = music_file.name
self.settings_db = SettingsDatabase(self.settings_path)
self.music_db = MusicDatabase(self.music_path)
DatabaseMigration(self.settings_db, self.music_db).migrate()
self.config = configparser.ConfigParser(interpolation=None, allow_no_value=True)
self.config.read("configuration.default.ini", encoding="utf-8")
var.config = self.config
var.db = self.settings_db
var.music_db = self.music_db
var.cache = None
var.playlist = []
def tearDown(self):
os.unlink(self.settings_path)
os.unlink(self.music_path)
def make_bot(self):
bot = bragi.MumbleBot.__new__(bragi.MumbleBot)
bot.volume_helper = util.VolumeHelper()
bot.volume_helper.set_volume(1.0)
bot.thread = None
bot.on_interrupting = False
bot.song_start_at = -1
bot.read_pcm_size = 0
bot.is_pause = False
bot.wait_for_ready = False
bot.log = logging.getLogger("test")
return bot
def test_restore_volume_on_stop_updates_runtime_and_db_from_config(self):
self.config.set("bot", "restore_volume_on_stop", "True")
self.config.set("bot", "volume", "0.7")
self.settings_db.set("bot", "volume", "1.0")
bot = self.make_bot()
bot.restore_default_volume()
self.assertAlmostEqual(bot.volume_helper.plain_volume_set, 0.7)
self.assertAlmostEqual(self.settings_db.getfloat("bot", "volume"), 0.7)
def test_restore_volume_on_stop_respects_max_volume_limit(self):
self.config.set("bot", "restore_volume_on_stop", "True")
self.config.set("bot", "volume", "0.9")
self.settings_db.set("bot", "max_volume", "0.5")
bot = self.make_bot()
bot.restore_default_volume()
self.assertAlmostEqual(bot.volume_helper.plain_volume_set, 0.5)
self.assertAlmostEqual(self.settings_db.getfloat("bot", "volume"), 0.5)
def test_restore_volume_on_stop_is_disabled_by_default(self):
self.config.set("bot", "restore_volume_on_stop", "False")
self.config.set("bot", "volume", "0.7")
self.settings_db.set("bot", "volume", "1.0")
bot = self.make_bot()
bot.restore_default_volume()
self.assertAlmostEqual(bot.volume_helper.plain_volume_set, 1.0)
self.assertAlmostEqual(self.settings_db.getfloat("bot", "volume"), 1.0)
def test_stop_restores_default_volume(self):
self.config.set("bot", "restore_volume_on_stop", "True")
var.playlist = [object()]
bot = self.make_bot()
bot.restore_default_volume = mock.Mock()
bot._cleanup_ffmpeg_process = mock.Mock()
bot.stop()
bot.restore_default_volume.assert_called_once_with()
def test_pause_does_not_restore_default_volume(self):
var.playlist = FakePlaylist(types.SimpleNamespace(id="track-id"))
bot = self.make_bot()
bot.playhead = 12.5
bot.restore_default_volume = mock.Mock()
bot.pause()
bot.restore_default_volume.assert_not_called()
def test_loop_restores_volume_without_entering_pause_when_queue_runs_empty(self):
var.playlist = ExhaustedPlaylist()
bot = self.make_bot()
bot.mumble = FakeMumble()
bot.restore_default_volume = mock.Mock()
bot._cleanup_ffmpeg_process = mock.Mock()
bot.exit = False
bot.last_ffmpeg_err = ""
bot.pcm_buffer_size = 1
bot.read_pcm_size = 1
bot._loop_status = ""
bragi.MumbleBot.loop(bot)
bot.restore_default_volume.assert_called_once_with()
self.assertFalse(bot.is_pause)
class HelpTextTests(unittest.TestCase):
def test_help_text_does_not_reference_web_interface(self):
constants.load_lang("en_US")
help_text = constants.tr_cli("help")
admin_help = constants.tr_cli("admin_help")
self.assertNotIn("!<u>w</u>eb", help_text)
self.assertNotIn("web interface", help_text.lower())
self.assertNotIn("web interface", admin_help.lower())
class CommandRegistrationTests(unittest.TestCase):
def setUp(self):
self.config = configparser.ConfigParser(interpolation=None, allow_no_value=True)
self.config.read("configuration.default.ini", encoding="utf-8")
var.config = self.config
def test_register_all_commands_excludes_web_commands(self):
bot = FakeBot()
command.register_all_commands(bot)
registered = ",".join(bot.registered)
self.assertIn(self.config.get("commands", "volume"), registered)
self.assertNotIn("web", registered)
self.assertNotIn("webuseradd", registered)
self.assertNotIn("webuserlist", registered)
self.assertNotIn("webuserdel", registered)
if __name__ == "__main__":
unittest.main()