From 14163bdbc12b8a99eb4d9208352e520280388e57 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 14 Jun 2026 01:48:13 -0400 Subject: [PATCH] Restore configured volume after playback stops --- README.md | 1 + bragi.py | 15 +++ configuration.default.ini | 2 +- configuration.example.ini | 3 + constants.py | 13 --- lang/en_US.json | 86 +-------------- tests/test_volume_restore.py | 206 +++++++++++++++++++++++++++++++++++ 7 files changed, 229 insertions(+), 97 deletions(-) create mode 100644 tests/test_volume_restore.py diff --git a/README.md b/README.md index 59e6d9f..62c6592 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ username = Bragi music_folder = music_folder/ tmp_folder = /tmp/ volume = 0.5 +restore_volume_on_stop = False ``` **Generate Certificate (Recommended):** diff --git a/bragi.py b/bragi.py index f944296..3552134 100755 --- a/bragi.py +++ b/bragi.py @@ -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: diff --git a/configuration.default.ini b/configuration.default.ini index cdfd4e8..b873dd6 100644 --- a/configuration.default.ini +++ b/configuration.default.ini @@ -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 - diff --git a/configuration.example.ini b/configuration.example.ini index 318ce4a..7767cb8 100644 --- a/configuration.example.ini +++ b/configuration.example.ini @@ -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. diff --git a/constants.py b/constants.py index 05b04d9..06a426b 100644 --- a/constants.py +++ b/constants.py @@ -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: diff --git a/lang/en_US.json b/lang/en_US.json index 0b96ed9..041af8c 100644 --- a/lang/en_US.json +++ b/lang/en_US.json @@ -2,7 +2,7 @@ "cli": { "added_tags": "Added tags {tags} to {song}.", "added_tags_to_all": "Added tags {tags} to songs on the playlist.", - "admin_help": "

Admin command

\nBot\n\nWeb Interface\n", + "admin_help": "

Admin command

\nBot\n", "auto_paused": "Use !play to resume music!", "bad_command": "{command}: command not found.", "bad_parameter": "{command}: invalid parameter.", @@ -28,7 +28,7 @@ "file_deleted": "Deleted {item} from the library.", "file_item": "{artist} - {title} added by {user}", "file_missed": "Music file '{file}' missed! This item has been removed from the playlist.", - "help": "

Commands

\nControl\n\nPlaylist\n\nMusic Library\n\nOther\n", + "help": "

Commands

\nControl\n\nPlaylist\n\nMusic Library\n\nOther\n", "invalid_index": "Invalid index {index}. Use !queue 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:
{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:
{users}", - "webpage_address": "Your own address to access the web interface is {address}", "which_command": "Do you mean
{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 !sl {{indexes}} to play the item you want.
\n!ytquery -n 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": "Are you really sure?
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 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.
\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" } -} \ No newline at end of file +} diff --git a/tests/test_volume_restore.py b/tests/test_volume_restore.py new file mode 100644 index 0000000..282b24d --- /dev/null +++ b/tests/test_volume_restore.py @@ -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("!web", 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()