Compare commits
3 Commits
2025.12.08
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 14163bdbc1 | |||
| e2df8972e7 | |||
| c310a1c318 |
@@ -98,6 +98,7 @@ username = Bragi
|
|||||||
music_folder = music_folder/
|
music_folder = music_folder/
|
||||||
tmp_folder = /tmp/
|
tmp_folder = /tmp/
|
||||||
volume = 0.5
|
volume = 0.5
|
||||||
|
restore_volume_on_stop = False
|
||||||
```
|
```
|
||||||
|
|
||||||
**Generate Certificate (Recommended):**
|
**Generate Certificate (Recommended):**
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ class MumbleBot:
|
|||||||
self.read_pcm_size = 0
|
self.read_pcm_size = 0
|
||||||
self.pcm_buffer_size = 0
|
self.pcm_buffer_size = 0
|
||||||
self.last_ffmpeg_err = ""
|
self.last_ffmpeg_err = ""
|
||||||
|
self.ffmpeg_fatal_error = ""
|
||||||
|
|
||||||
# Play/pause status
|
# Play/pause status
|
||||||
self.is_pause = False
|
self.is_pause = False
|
||||||
@@ -101,7 +102,8 @@ class MumbleBot:
|
|||||||
if args.certificate:
|
if args.certificate:
|
||||||
certificate = args.certificate
|
certificate = args.certificate
|
||||||
else:
|
else:
|
||||||
certificate = util.solve_filepath(var.config.get("server", "certificate"))
|
# Get configured cert or auto-generate one if needed
|
||||||
|
certificate = util.get_or_create_certificate(var.config.get("server", "certificate"))
|
||||||
|
|
||||||
if args.tokens:
|
if args.tokens:
|
||||||
tokens = args.tokens
|
tokens = args.tokens
|
||||||
@@ -527,13 +529,51 @@ class MumbleBot:
|
|||||||
|
|
||||||
# The ffmpeg process is a thread
|
# The ffmpeg process is a thread
|
||||||
# prepare pipe for catching stderr of ffmpeg
|
# prepare pipe for catching stderr of ffmpeg
|
||||||
if self.redirect_ffmpeg_log:
|
|
||||||
pipe_rd, pipe_wd = util.pipe_no_wait() # Let the pipe work in non-blocking mode
|
pipe_rd, pipe_wd = util.pipe_no_wait() # Let the pipe work in non-blocking mode
|
||||||
|
if pipe_rd is not None:
|
||||||
self.thread_stderr = os.fdopen(pipe_rd)
|
self.thread_stderr = os.fdopen(pipe_rd)
|
||||||
else:
|
else:
|
||||||
pipe_rd, pipe_wd = None, None
|
pipe_rd, pipe_wd = None, None
|
||||||
|
|
||||||
self.thread = sp.Popen(command, stdout=sp.PIPE, stderr=pipe_wd, bufsize=self.pcm_buffer_size)
|
self.thread = sp.Popen(command, stdout=sp.PIPE, stderr=pipe_wd, bufsize=self.pcm_buffer_size)
|
||||||
|
if pipe_wd is not None:
|
||||||
|
os.close(pipe_wd)
|
||||||
|
|
||||||
|
def _read_ffmpeg_stderr(self):
|
||||||
|
if not self.thread_stderr:
|
||||||
|
return
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
line = self.thread_stderr.readline()
|
||||||
|
except (BlockingIOError, OSError):
|
||||||
|
return
|
||||||
|
|
||||||
|
if not line:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.last_ffmpeg_err = line
|
||||||
|
stripped_line = line.strip("\n")
|
||||||
|
if self.redirect_ffmpeg_log:
|
||||||
|
self.log.debug("ffmpeg: " + stripped_line)
|
||||||
|
if "HTTP error 404" in line or "404 Not Found" in line:
|
||||||
|
self.ffmpeg_fatal_error = stripped_line
|
||||||
|
|
||||||
|
def _fail_current_ffmpeg_item(self):
|
||||||
|
current = var.playlist.current_item()
|
||||||
|
self._cleanup_ffmpeg_process()
|
||||||
|
self.last_ffmpeg_err = ""
|
||||||
|
|
||||||
|
if not current:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.log.error("bot: cannot play music %s", current.format_debug_string())
|
||||||
|
self.log.error("bot: with ffmpeg error: %s", self.ffmpeg_fatal_error)
|
||||||
|
self.ffmpeg_fatal_error = ""
|
||||||
|
|
||||||
|
self.send_channel_msg(tr('unable_play', item=current.format_title()))
|
||||||
|
var.playlist.remove_by_id(current.id)
|
||||||
|
var.cache.free_and_delete(current.id)
|
||||||
|
|
||||||
def async_download_next(self):
|
def async_download_next(self):
|
||||||
# Function start if the next music isn't ready
|
# Function start if the next music isn't ready
|
||||||
@@ -615,13 +655,11 @@ class MumbleBot:
|
|||||||
raw_music = self.thread.stdout.read(self.pcm_buffer_size)
|
raw_music = self.thread.stdout.read(self.pcm_buffer_size)
|
||||||
self.read_pcm_size += len(raw_music)
|
self.read_pcm_size += len(raw_music)
|
||||||
|
|
||||||
if self.redirect_ffmpeg_log:
|
self._read_ffmpeg_stderr()
|
||||||
try:
|
if self.ffmpeg_fatal_error:
|
||||||
self.last_ffmpeg_err = self.thread_stderr.readline()
|
self._fail_current_ffmpeg_item()
|
||||||
if self.last_ffmpeg_err:
|
raw_music = None
|
||||||
self.log.debug("ffmpeg: " + self.last_ffmpeg_err.strip("\n"))
|
continue
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if raw_music:
|
if raw_music:
|
||||||
# Adjust the volume and send it to mumble
|
# Adjust the volume and send it to mumble
|
||||||
@@ -678,6 +716,7 @@ class MumbleBot:
|
|||||||
var.cache.free_and_delete(current.id)
|
var.cache.free_and_delete(current.id)
|
||||||
else:
|
else:
|
||||||
self._loop_status = 'Empty queue'
|
self._loop_status = 'Empty queue'
|
||||||
|
self.restore_default_volume()
|
||||||
else:
|
else:
|
||||||
# if wait_for_ready flag is true, means the pointer is already
|
# if wait_for_ready flag is true, means the pointer is already
|
||||||
# pointing to target song. start playing
|
# pointing to target song. start playing
|
||||||
@@ -817,6 +856,7 @@ class MumbleBot:
|
|||||||
self._cleanup_ffmpeg_process() # Ensure proper cleanup
|
self._cleanup_ffmpeg_process() # Ensure proper cleanup
|
||||||
var.playlist.clear()
|
var.playlist.clear()
|
||||||
self.wait_for_ready = False
|
self.wait_for_ready = False
|
||||||
|
self.restore_default_volume()
|
||||||
self.log.info("bot: music stopped. playlist trashed.")
|
self.log.info("bot: music stopped. playlist trashed.")
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
@@ -827,8 +867,21 @@ class MumbleBot:
|
|||||||
self.wait_for_ready = True
|
self.wait_for_ready = True
|
||||||
else:
|
else:
|
||||||
self.wait_for_ready = False
|
self.wait_for_ready = False
|
||||||
|
self.restore_default_volume()
|
||||||
self.log.info("bot: music stopped.")
|
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):
|
def _cleanup_ffmpeg_process(self):
|
||||||
"""Properly cleanup FFmpeg process and associated resources"""
|
"""Properly cleanup FFmpeg process and associated resources"""
|
||||||
if self.thread:
|
if self.thread:
|
||||||
|
|||||||
@@ -54,8 +54,14 @@ music_database_path = music.db
|
|||||||
music_folder = music_folder/
|
music_folder = music_folder/
|
||||||
pip3_path = venv/bin/pip
|
pip3_path = venv/bin/pip
|
||||||
playback_mode = one-shot
|
playback_mode = one-shot
|
||||||
|
# Number of parallel workers for database rebuild (scanning music files)
|
||||||
|
# 0 = auto (recommended: uses all CPU cores minus 1 to leave one free for audio/system)
|
||||||
|
# 1 = sequential (no parallelization, slowest but lowest resource usage)
|
||||||
|
# N = use exactly N worker processes (2 or higher for parallel processing)
|
||||||
|
rebuild_workers = 0
|
||||||
redirect_stderr = True
|
redirect_stderr = True
|
||||||
refresh_cache_on_startup = True
|
refresh_cache_on_startup = True
|
||||||
|
restore_volume_on_stop = False
|
||||||
save_music_library = True
|
save_music_library = True
|
||||||
save_playlist = True
|
save_playlist = True
|
||||||
stereo = True
|
stereo = True
|
||||||
@@ -150,4 +156,3 @@ yt_search = ysearch
|
|||||||
#
|
#
|
||||||
# File paths use fuzzy matching - the first matching file in the music database will be used
|
# 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
|
# URLs are downloaded and played like the !url command
|
||||||
|
|
||||||
|
|||||||
+13
-13
@@ -16,6 +16,10 @@ port = 64738
|
|||||||
#password =
|
#password =
|
||||||
#channel =
|
#channel =
|
||||||
#tokens = token1,token2
|
#tokens = token1,token2
|
||||||
|
# 'certificate': Path to client certificate for Mumble authentication.
|
||||||
|
# If not specified, a self-signed certificate (bragi.pem) will be
|
||||||
|
# automatically generated in the bot's directory. This provides the bot
|
||||||
|
# with a persistent identity on the Mumble server.
|
||||||
#certificate =
|
#certificate =
|
||||||
|
|
||||||
# The [bot] section stores some basic settings for the bot.
|
# The [bot] section stores some basic settings for the bot.
|
||||||
@@ -27,10 +31,6 @@ port = 64738
|
|||||||
#comment = "Hi, I'm here to play radio, local music or youtube/soundcloud music. Have fun!"
|
#comment = "Hi, I'm here to play radio, local music or youtube/soundcloud music. Have fun!"
|
||||||
#avatar =
|
#avatar =
|
||||||
|
|
||||||
# 'language': Language to use; available languages can be found inside
|
|
||||||
# the lang/ folder.
|
|
||||||
#language=en_US
|
|
||||||
|
|
||||||
# 'music_folder': Folder that stores your local songs.
|
# 'music_folder': Folder that stores your local songs.
|
||||||
#music_folder = music_folder/
|
#music_folder = music_folder/
|
||||||
|
|
||||||
@@ -53,7 +53,10 @@ port = 64738
|
|||||||
|
|
||||||
# 'volume': The default volume, a number from 0 to 1.
|
# 'volume': The default volume, a number from 0 to 1.
|
||||||
# This option will be overridden by the value set in the database.
|
# 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
|
#volume = 0.1
|
||||||
|
#restore_volume_on_stop = False
|
||||||
|
|
||||||
# 'bandwidth': The number of bits per second used by the bot when streaming audio.
|
# '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.
|
# Enabling this option will allow you to set it higher than the default value.
|
||||||
@@ -74,8 +77,11 @@ port = 64738
|
|||||||
#autoplay_length = 5
|
#autoplay_length = 5
|
||||||
#clear_when_stop_in_oneshot = False
|
#clear_when_stop_in_oneshot = False
|
||||||
|
|
||||||
# Auto-update system has been removed from Bragi
|
# 'rebuild_workers': Number of parallel workers for database rebuild (scanning music files).
|
||||||
#target_version = stable
|
# 0 = auto (recommended: uses all CPU cores minus 1 to leave one free for audio/system)
|
||||||
|
# 1 = sequential (no parallelization, slowest but lowest resource usage)
|
||||||
|
# N = use exactly N worker processes (2 or higher for parallel processing)
|
||||||
|
#rebuild_workers = 0
|
||||||
|
|
||||||
# 'tmp_folder': Folder that music will be downloaded into.
|
# 'tmp_folder': Folder that music will be downloaded into.
|
||||||
# 'tmp_folder_max_size': Maximum size of tmp_folder in MB, or 0 to not cache
|
# 'tmp_folder_max_size': Maximum size of tmp_folder in MB, or 0 to not cache
|
||||||
@@ -89,10 +95,6 @@ port = 64738
|
|||||||
# 'download_attempts': How many times to attempt a download.
|
# 'download_attempts': How many times to attempt a download.
|
||||||
#download_attempts = 2
|
#download_attempts = 2
|
||||||
|
|
||||||
# Auto-update system has been removed from Bragi
|
|
||||||
#auto_check_update = False
|
|
||||||
#pip3_path = venv/bin/pip
|
|
||||||
|
|
||||||
# 'logfile': File to write log messages to.
|
# 'logfile': File to write log messages to.
|
||||||
# 'redirect_stderr': Whether to capture outputs from standard error and write
|
# 'redirect_stderr': Whether to capture outputs from standard error and write
|
||||||
# it into the log file. Useful for capturing an exception message when the
|
# it into the log file. Useful for capturing an exception message when the
|
||||||
@@ -105,7 +107,7 @@ port = 64738
|
|||||||
#allow_private_message = True
|
#allow_private_message = True
|
||||||
|
|
||||||
# 'delete_allowed': Whether to allow admins to delete a file from the library
|
# 'delete_allowed': Whether to allow admins to delete a file from the library
|
||||||
# stored on disk. Works for both command and web interfaces.
|
# stored on disk.
|
||||||
#delete_allowed = True
|
#delete_allowed = True
|
||||||
|
|
||||||
# 'save_music_library': Whether to save music metadata to the database.
|
# 'save_music_library': Whether to save music metadata to the database.
|
||||||
@@ -153,8 +155,6 @@ port = 64738
|
|||||||
# query youtube", you should provide a value here.
|
# query youtube", you should provide a value here.
|
||||||
#youtube_query_cookie = {"CONSENT": "paste your CONSENT cookie value here"}
|
#youtube_query_cookie = {"CONSENT": "paste your CONSENT cookie value here"}
|
||||||
|
|
||||||
# Web interface has been removed from Bragi
|
|
||||||
|
|
||||||
# The [debug] section contains settings to enable debugging messages.
|
# The [debug] section contains settings to enable debugging messages.
|
||||||
[debug]
|
[debug]
|
||||||
# 'ffmpeg': Whether to display debug messages from ffmpeg.
|
# 'ffmpeg': Whether to display debug messages from ffmpeg.
|
||||||
|
|||||||
@@ -30,19 +30,6 @@ def tr_cli(option, *argv, **kwargs):
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
raise KeyError("Missed strings in language file: '{string}'. ".format(string=option))
|
raise KeyError("Missed strings in language file: '{string}'. ".format(string=option))
|
||||||
return _tr(string, *argv, **kwargs)
|
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):
|
def _tr(string, *argv, **kwargs):
|
||||||
if argv or kwargs:
|
if argv or kwargs:
|
||||||
try:
|
try:
|
||||||
|
|||||||
+2
-82
@@ -2,7 +2,7 @@
|
|||||||
"cli": {
|
"cli": {
|
||||||
"added_tags": "Added tags <i>{tags}</i> to <b>{song}</b>.",
|
"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.",
|
"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!",
|
"auto_paused": "Use <i>!play</i> to resume music!",
|
||||||
"bad_command": "<i>{command}</i>: command not found.",
|
"bad_command": "<i>{command}</i>: command not found.",
|
||||||
"bad_parameter": "<i>{command}</i>: invalid parameter.",
|
"bad_parameter": "<i>{command}</i>: invalid parameter.",
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
"file_deleted": "Deleted {item} from the library.",
|
"file_deleted": "Deleted {item} from the library.",
|
||||||
"file_item": "<b>{artist} - {title}</b> <i>added by</i> {user}",
|
"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.",
|
"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.",
|
"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.",
|
"last_song_on_the_queue": "Last one on the queue.",
|
||||||
"max_volume": "Volume exceeds max volume of {max}. Setting volume to max.",
|
"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": "You are banned, not allowed to do that!",
|
||||||
"user_ban_list": "List of banned user: <br>{list}",
|
"user_ban_list": "List of banned user: <br>{list}",
|
||||||
"user_ban_success": "User {user} is banned.",
|
"user_ban_success": "User {user} is banned.",
|
||||||
"user_password_set": "Your password has been updated.",
|
|
||||||
"user_unban_success": "User {user} is unbanned.",
|
"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}",
|
"which_command": "Do you mean <br /> {commands}",
|
||||||
"wrong_pattern": "Invalid regex: {error}.",
|
"wrong_pattern": "Invalid regex: {error}.",
|
||||||
"yt_no_more": "No more results!",
|
"yt_no_more": "No more results!",
|
||||||
"yt_query_error": "Unable to query youtube!",
|
"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."
|
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+102
-14
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import multiprocessing
|
||||||
|
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
@@ -23,6 +25,29 @@ class ItemNotCachedError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _process_file_for_cache(file_path):
|
||||||
|
"""Worker function to process a single file for the cache.
|
||||||
|
This must be a module-level function for multiprocessing to work.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Relative path to the audio file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Music item dictionary ready for database insertion, or None on error
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Import inside function to avoid pickling issues
|
||||||
|
import variables as var
|
||||||
|
from media.item import item_builders
|
||||||
|
|
||||||
|
item = item_builders['file'](path=file_path)
|
||||||
|
return item.to_dict()
|
||||||
|
except Exception as e:
|
||||||
|
# Log errors but don't fail the whole process
|
||||||
|
logging.getLogger("bot").warning(f"library: failed to process file {file_path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class MusicCache(dict):
|
class MusicCache(dict):
|
||||||
def __init__(self, db: MusicDatabase):
|
def __init__(self, db: MusicDatabase):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -115,26 +140,89 @@ class MusicCache(dict):
|
|||||||
|
|
||||||
def build_dir_cache(self):
|
def build_dir_cache(self):
|
||||||
self.dir_lock.acquire()
|
self.dir_lock.acquire()
|
||||||
|
try:
|
||||||
self.log.info("library: rebuild directory cache")
|
self.log.info("library: rebuild directory cache")
|
||||||
files = util.get_recursive_file_list_sorted(var.music_folder)
|
files_list = util.get_recursive_file_list_sorted(var.music_folder)
|
||||||
|
files_on_disk = set(files_list) # Convert to set for O(1) lookup
|
||||||
|
|
||||||
# remove deleted files
|
self.log.info(f"library: found {len(files_on_disk)} audio files on disk")
|
||||||
results = self.db.query_music(Condition().or_equal('type', 'file'))
|
|
||||||
for result in results:
|
# Get all existing file paths from database as a set
|
||||||
if result['path'] not in files:
|
db_paths = set(self.db.query_all_paths())
|
||||||
self.log.debug("library: music file missed: %s, delete from library." % result['path'])
|
self.log.info(f"library: found {len(db_paths)} files in database")
|
||||||
self.db.delete_music(Condition().and_equal('id', result['id']))
|
|
||||||
|
# Find files to delete (in DB but not on disk)
|
||||||
|
files_to_delete = db_paths - files_on_disk
|
||||||
|
if files_to_delete:
|
||||||
|
self.log.info(f"library: removing {len(files_to_delete)} deleted files from database")
|
||||||
|
for path in files_to_delete:
|
||||||
|
self.log.debug(f"library: music file missed: {path}, delete from library.")
|
||||||
|
self.db.delete_music(Condition().and_equal('path', path))
|
||||||
|
|
||||||
|
# Find new files to add (on disk but not in DB)
|
||||||
|
new_files = files_on_disk - db_paths
|
||||||
|
if not new_files:
|
||||||
|
self.log.info("library: no new files to add")
|
||||||
|
self.db.manage_special_tags()
|
||||||
|
return
|
||||||
|
|
||||||
|
self.log.info(f"library: processing {len(new_files)} new files with parallel workers")
|
||||||
|
|
||||||
|
# Determine number of worker processes from config
|
||||||
|
# 0 = auto (cpu_count - 1), N = use N workers
|
||||||
|
configured_workers = var.config.getint('bot', 'rebuild_workers', fallback=0)
|
||||||
|
if configured_workers == 0:
|
||||||
|
# Auto mode: use all cores minus one (leave one free for audio/system)
|
||||||
|
num_workers = max(1, multiprocessing.cpu_count() - 1)
|
||||||
|
self.log.info(f"library: auto-detected {multiprocessing.cpu_count()} cores, using {num_workers} workers")
|
||||||
else:
|
else:
|
||||||
files.remove(result['path'])
|
# User specified: validate minimum of 1
|
||||||
|
num_workers = max(1, configured_workers)
|
||||||
|
if num_workers == 1:
|
||||||
|
self.log.info("library: using 1 worker (sequential processing)")
|
||||||
|
else:
|
||||||
|
self.log.info(f"library: using {num_workers} workers (configured)")
|
||||||
|
|
||||||
for file in files:
|
|
||||||
results = self.db.query_music(Condition().and_equal('path', file))
|
# Process files in parallel
|
||||||
if not results:
|
processed_items = []
|
||||||
item = item_builders['file'](path=file)
|
with ProcessPoolExecutor(max_workers=num_workers) as executor:
|
||||||
self.log.debug("library: music save into database: %s" % item.format_debug_string())
|
# Submit all files for processing
|
||||||
self.db.insert_music(item.to_dict())
|
future_to_file = {executor.submit(_process_file_for_cache, file_path): file_path
|
||||||
|
for file_path in new_files}
|
||||||
|
|
||||||
|
# Collect results as they complete
|
||||||
|
completed = 0
|
||||||
|
for future in as_completed(future_to_file):
|
||||||
|
file_path = future_to_file[future]
|
||||||
|
try:
|
||||||
|
result = future.result()
|
||||||
|
if result:
|
||||||
|
processed_items.append(result)
|
||||||
|
completed += 1
|
||||||
|
if completed % 100 == 0:
|
||||||
|
self.log.info(f"library: processed {completed}/{len(new_files)} files")
|
||||||
|
except Exception as e:
|
||||||
|
self.log.warning(f"library: failed to process {file_path}: {e}")
|
||||||
|
|
||||||
|
self.log.info(f"library: successfully processed {len(processed_items)} files")
|
||||||
|
|
||||||
|
# Batch insert all new items into database
|
||||||
|
if processed_items:
|
||||||
|
self.log.info(f"library: inserting {len(processed_items)} items into database")
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect(self.db.db_path)
|
||||||
|
try:
|
||||||
|
for item in processed_items:
|
||||||
|
self.db.insert_music(item, _conn=conn)
|
||||||
|
conn.commit()
|
||||||
|
self.log.info("library: database batch insert completed")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
self.db.manage_special_tags()
|
self.db.manage_special_tags()
|
||||||
|
self.log.info("library: directory cache rebuild complete")
|
||||||
|
finally:
|
||||||
self.dir_lock.release()
|
self.dir_lock.release()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-67
@@ -5,11 +5,8 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from io import BytesIO
|
|
||||||
import base64
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import mutagen
|
import mutagen
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
import util
|
import util
|
||||||
import variables as var
|
import variables as var
|
||||||
@@ -23,7 +20,6 @@ type : file
|
|||||||
title
|
title
|
||||||
artist
|
artist
|
||||||
duration
|
duration
|
||||||
thumbnail
|
|
||||||
user
|
user
|
||||||
'''
|
'''
|
||||||
|
|
||||||
@@ -52,7 +48,6 @@ class FileItem(BaseItem):
|
|||||||
self.path = path
|
self.path = path
|
||||||
self.title = ""
|
self.title = ""
|
||||||
self.artist = ""
|
self.artist = ""
|
||||||
self.thumbnail = None
|
|
||||||
self.id = hashlib.md5(path.encode()).hexdigest()
|
self.id = hashlib.md5(path.encode()).hexdigest()
|
||||||
if os.path.exists(self.uri()):
|
if os.path.exists(self.uri()):
|
||||||
self._get_info_from_tag()
|
self._get_info_from_tag()
|
||||||
@@ -62,7 +57,6 @@ class FileItem(BaseItem):
|
|||||||
else:
|
else:
|
||||||
super().__init__(from_dict)
|
super().__init__(from_dict)
|
||||||
self.artist = from_dict['artist']
|
self.artist = from_dict['artist']
|
||||||
self.thumbnail = from_dict['thumbnail']
|
|
||||||
try:
|
try:
|
||||||
self.validate()
|
self.validate()
|
||||||
except ValidationFailedError:
|
except ValidationFailedError:
|
||||||
@@ -95,112 +89,58 @@ class FileItem(BaseItem):
|
|||||||
assert path is not None and file_name is not None
|
assert path is not None and file_name is not None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
im = None
|
|
||||||
path_thumbnail = os.path.join(path, file_name + ".jpg")
|
|
||||||
|
|
||||||
if os.path.isfile(path_thumbnail):
|
|
||||||
im = Image.open(path_thumbnail)
|
|
||||||
else:
|
|
||||||
path_thumbnail = os.path.join(path, "cover.jpg")
|
|
||||||
if os.path.isfile(path_thumbnail):
|
|
||||||
im = Image.open(path_thumbnail)
|
|
||||||
|
|
||||||
if ext == ".mp3":
|
if ext == ".mp3":
|
||||||
# title: TIT2
|
# title: TIT2
|
||||||
# artist: TPE1, TPE2
|
# artist: TPE1, TPE2
|
||||||
# album: TALB
|
# album: TALB
|
||||||
# cover artwork: APIC:
|
|
||||||
tags = mutagen.File(self.uri())
|
tags = mutagen.File(self.uri())
|
||||||
if 'TIT2' in tags:
|
if 'TIT2' in tags:
|
||||||
self.title = tags['TIT2'].text[0]
|
self.title = tags['TIT2'].text[0]
|
||||||
if 'TPE1' in tags: # artist
|
if 'TPE1' in tags: # artist
|
||||||
self.artist = tags['TPE1'].text[0]
|
self.artist = tags['TPE1'].text[0]
|
||||||
|
|
||||||
if im is None:
|
|
||||||
if "APIC:" in tags:
|
|
||||||
im = Image.open(BytesIO(tags["APIC:"].data))
|
|
||||||
|
|
||||||
elif ext == ".m4a" or ext == ".m4b" or ext == ".mp4" or ext == ".m4p":
|
elif ext == ".m4a" or ext == ".m4b" or ext == ".mp4" or ext == ".m4p":
|
||||||
# title: ©nam (\xa9nam)
|
# title: ©nam (\xa9nam)
|
||||||
# artist: ©ART
|
# artist: ©ART
|
||||||
# album: ©alb
|
# album: ©alb
|
||||||
# cover artwork: covr
|
|
||||||
tags = mutagen.File(self.uri())
|
tags = mutagen.File(self.uri())
|
||||||
if '©nam' in tags:
|
if '©nam' in tags:
|
||||||
self.title = tags['©nam'][0]
|
self.title = tags['©nam'][0]
|
||||||
if '©ART' in tags: # artist
|
if '©ART' in tags: # artist
|
||||||
self.artist = tags['©ART'][0]
|
self.artist = tags['©ART'][0]
|
||||||
|
|
||||||
if im is None:
|
|
||||||
if "covr" in tags:
|
|
||||||
im = Image.open(BytesIO(tags["covr"][0]))
|
|
||||||
|
|
||||||
elif ext == ".opus":
|
elif ext == ".opus":
|
||||||
# title: 'title'
|
# title: 'title'
|
||||||
# artist: 'artist'
|
# artist: 'artist'
|
||||||
# album: 'album'
|
# album: 'album'
|
||||||
# cover artwork: 'metadata_block_picture', and then:
|
|
||||||
## |
|
|
||||||
## |
|
|
||||||
## v
|
|
||||||
## Decode string as base64 binary
|
|
||||||
## |
|
|
||||||
## v
|
|
||||||
## Open that binary as a mutagen.flac.Picture
|
|
||||||
## |
|
|
||||||
## v
|
|
||||||
## Extract binary image data
|
|
||||||
tags = mutagen.File(self.uri())
|
tags = mutagen.File(self.uri())
|
||||||
if 'title' in tags:
|
if 'title' in tags:
|
||||||
self.title = tags['title'][0]
|
self.title = tags['title'][0]
|
||||||
if 'artist' in tags:
|
if 'artist' in tags:
|
||||||
self.artist = tags['artist'][0]
|
self.artist = tags['artist'][0]
|
||||||
|
|
||||||
if im is None:
|
|
||||||
if 'metadata_block_picture' in tags:
|
|
||||||
pic_as_base64 = tags['metadata_block_picture'][0]
|
|
||||||
as_flac_picture = mutagen.flac.Picture(base64.b64decode(pic_as_base64))
|
|
||||||
im = Image.open(BytesIO(as_flac_picture.data))
|
|
||||||
|
|
||||||
elif ext == ".flac":
|
elif ext == ".flac":
|
||||||
# title: 'title'
|
# title: 'title'
|
||||||
# artist: 'artist'
|
# artist: 'artist'
|
||||||
# album: 'album'
|
# album: 'album'
|
||||||
# cover artwork: tags.pictures
|
|
||||||
tags = mutagen.File(self.uri())
|
tags = mutagen.File(self.uri())
|
||||||
if 'title' in tags:
|
if 'title' in tags:
|
||||||
self.title = tags['title'][0]
|
self.title = tags['title'][0]
|
||||||
if 'artist' in tags:
|
if 'artist' in tags:
|
||||||
self.artist = tags['artist'][0]
|
self.artist = tags['artist'][0]
|
||||||
|
|
||||||
if im is None:
|
|
||||||
for flac_picture in tags.pictures:
|
|
||||||
if flac_picture.type == 3:
|
|
||||||
im = Image.open(BytesIO(flac_picture.data))
|
|
||||||
|
|
||||||
if im:
|
|
||||||
self.thumbnail = self._prepare_thumbnail(im)
|
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if not self.title:
|
if not self.title:
|
||||||
self.title = file_name
|
self.title = file_name
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _prepare_thumbnail(im):
|
|
||||||
im.thumbnail((100, 100), Image.LANCZOS)
|
|
||||||
buffer = BytesIO()
|
|
||||||
im = im.convert('RGB')
|
|
||||||
im.save(buffer, format="JPEG")
|
|
||||||
return base64.b64encode(buffer.getvalue()).decode('utf-8')
|
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
dict = super().to_dict()
|
dict = super().to_dict()
|
||||||
dict['type'] = 'file'
|
dict['type'] = 'file'
|
||||||
dict['path'] = self.path
|
dict['path'] = self.path
|
||||||
dict['title'] = self.title
|
dict['title'] = self.title
|
||||||
dict['artist'] = self.artist
|
dict['artist'] = self.artist
|
||||||
dict['thumbnail'] = self.thumbnail
|
|
||||||
return dict
|
return dict
|
||||||
|
|
||||||
def format_debug_string(self):
|
def format_debug_string(self):
|
||||||
@@ -217,13 +157,7 @@ class FileItem(BaseItem):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def format_current_playing(self, user):
|
def format_current_playing(self, user):
|
||||||
display = tr("now_playing", item=self.format_song_string(user))
|
return tr("now_playing", item=self.format_song_string(user))
|
||||||
if self.thumbnail:
|
|
||||||
thumbnail_html = '<img width="80" src="data:image/jpge;base64,' + \
|
|
||||||
self.thumbnail + '"/>'
|
|
||||||
display += "<br />" + thumbnail_html
|
|
||||||
|
|
||||||
return display
|
|
||||||
|
|
||||||
def format_title(self):
|
def format_title(self):
|
||||||
title = self.title if self.title else self.path
|
title = self.title if self.title else self.path
|
||||||
|
|||||||
+1
-28
@@ -8,11 +8,8 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import hashlib
|
import hashlib
|
||||||
import traceback
|
import traceback
|
||||||
from PIL import Image
|
|
||||||
import yt_dlp as youtube_dl
|
import yt_dlp as youtube_dl
|
||||||
import glob
|
import glob
|
||||||
from io import BytesIO
|
|
||||||
import base64
|
|
||||||
|
|
||||||
import util
|
import util
|
||||||
from constants import tr_cli as tr
|
from constants import tr_cli as tr
|
||||||
@@ -52,7 +49,6 @@ class URLItem(BaseItem):
|
|||||||
self.duration = 0
|
self.duration = 0
|
||||||
self.id = hashlib.md5(url.encode()).hexdigest()
|
self.id = hashlib.md5(url.encode()).hexdigest()
|
||||||
self.path = var.tmp_folder + self.id
|
self.path = var.tmp_folder + self.id
|
||||||
self.thumbnail = ""
|
|
||||||
self.keywords = ""
|
self.keywords = ""
|
||||||
else:
|
else:
|
||||||
super().__init__(from_dict)
|
super().__init__(from_dict)
|
||||||
@@ -60,7 +56,6 @@ class URLItem(BaseItem):
|
|||||||
self.duration = from_dict['duration']
|
self.duration = from_dict['duration']
|
||||||
self.path = from_dict['path']
|
self.path = from_dict['path']
|
||||||
self.title = from_dict['title']
|
self.title = from_dict['title']
|
||||||
self.thumbnail = from_dict['thumbnail']
|
|
||||||
|
|
||||||
self.downloading = False
|
self.downloading = False
|
||||||
self.type = "url"
|
self.type = "url"
|
||||||
@@ -194,7 +189,6 @@ class URLItem(BaseItem):
|
|||||||
'format': 'bestaudio/best',
|
'format': 'bestaudio/best',
|
||||||
'outtmpl': base_path,
|
'outtmpl': base_path,
|
||||||
'noplaylist': True,
|
'noplaylist': True,
|
||||||
'writethumbnail': True,
|
|
||||||
'updatetime': False,
|
'updatetime': False,
|
||||||
'verbose': var.config.getboolean('debug', 'youtube_dl'),
|
'verbose': var.config.getboolean('debug', 'youtube_dl'),
|
||||||
'postprocessors': [{
|
'postprocessors': [{
|
||||||
@@ -232,7 +226,6 @@ class URLItem(BaseItem):
|
|||||||
self.log.info(
|
self.log.info(
|
||||||
"bot: finished downloading url (%s) %s, saved to %s." % (self.title, self.url, self.path))
|
"bot: finished downloading url (%s) %s, saved to %s." % (self.title, self.url, self.path))
|
||||||
self.downloading = False
|
self.downloading = False
|
||||||
self._read_thumbnail_from_file(base_path + ".jpg")
|
|
||||||
self.version += 1 # notify wrapper to save me
|
self.version += 1 # notify wrapper to save me
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
@@ -242,18 +235,6 @@ class URLItem(BaseItem):
|
|||||||
self.downloading = False
|
self.downloading = False
|
||||||
raise PreparationFailedError(tr('unable_download', item=self.format_title()))
|
raise PreparationFailedError(tr('unable_download', item=self.format_title()))
|
||||||
|
|
||||||
def _read_thumbnail_from_file(self, path_thumbnail):
|
|
||||||
if os.path.isfile(path_thumbnail):
|
|
||||||
im = Image.open(path_thumbnail)
|
|
||||||
self.thumbnail = self._prepare_thumbnail(im)
|
|
||||||
|
|
||||||
def _prepare_thumbnail(self, im):
|
|
||||||
im.thumbnail((100, 100), Image.LANCZOS)
|
|
||||||
buffer = BytesIO()
|
|
||||||
im = im.convert('RGB')
|
|
||||||
im.save(buffer, format="JPEG")
|
|
||||||
return base64.b64encode(buffer.getvalue()).decode('utf-8')
|
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
dict = super().to_dict()
|
dict = super().to_dict()
|
||||||
dict['type'] = 'url'
|
dict['type'] = 'url'
|
||||||
@@ -261,7 +242,6 @@ class URLItem(BaseItem):
|
|||||||
dict['duration'] = self.duration
|
dict['duration'] = self.duration
|
||||||
dict['path'] = self.path
|
dict['path'] = self.path
|
||||||
dict['title'] = self.title
|
dict['title'] = self.title
|
||||||
dict['thumbnail'] = self.thumbnail
|
|
||||||
|
|
||||||
return dict
|
return dict
|
||||||
|
|
||||||
@@ -280,14 +260,7 @@ class URLItem(BaseItem):
|
|||||||
return self.url
|
return self.url
|
||||||
|
|
||||||
def format_current_playing(self, user):
|
def format_current_playing(self, user):
|
||||||
display = tr("now_playing", item=self.format_song_string(user))
|
return tr("now_playing", item=self.format_song_string(user))
|
||||||
|
|
||||||
if self.thumbnail:
|
|
||||||
thumbnail_html = '<img width="80" src="data:image/jpge;base64,' + \
|
|
||||||
self.thumbnail + '"/>'
|
|
||||||
display += "<br />" + thumbnail_html
|
|
||||||
|
|
||||||
return display
|
|
||||||
|
|
||||||
def format_title(self):
|
def format_title(self):
|
||||||
return self.title if self.title else self.url
|
return self.title if self.title else self.url
|
||||||
|
|||||||
@@ -125,14 +125,7 @@ class PlaylistURLItem(URLItem):
|
|||||||
user=user)
|
user=user)
|
||||||
|
|
||||||
def format_current_playing(self, user):
|
def format_current_playing(self, user):
|
||||||
display = tr("now_playing", item=self.format_song_string(user))
|
return tr("now_playing", item=self.format_song_string(user))
|
||||||
|
|
||||||
if self.thumbnail:
|
|
||||||
thumbnail_html = '<img width="80" src="data:image/jpge;base64,' + \
|
|
||||||
self.thumbnail + '"/>'
|
|
||||||
display += "<br />" + thumbnail_html
|
|
||||||
|
|
||||||
return display
|
|
||||||
|
|
||||||
def display_type(self):
|
def display_type(self):
|
||||||
return tr("url_from_playlist")
|
return tr("url_from_playlist")
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,5 @@
|
|||||||
yt-dlp
|
yt-dlp
|
||||||
python-magic
|
python-magic
|
||||||
Pillow
|
|
||||||
mutagen
|
mutagen
|
||||||
requests
|
requests
|
||||||
packaging
|
packaging
|
||||||
@@ -9,3 +8,4 @@ opuslib==3.0.1
|
|||||||
numpy
|
numpy
|
||||||
protobuf
|
protobuf
|
||||||
pycryptodome
|
pycryptodome
|
||||||
|
cryptography
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import bragi
|
||||||
|
import constants
|
||||||
|
import variables as var
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCache:
|
||||||
|
def __init__(self):
|
||||||
|
self.freed_id = None
|
||||||
|
|
||||||
|
def free_and_delete(self, item_id):
|
||||||
|
self.freed_id = item_id
|
||||||
|
|
||||||
|
|
||||||
|
class FakeItem:
|
||||||
|
id = "track-id"
|
||||||
|
|
||||||
|
def format_debug_string(self):
|
||||||
|
return "fake item"
|
||||||
|
|
||||||
|
def format_title(self):
|
||||||
|
return "Fake Item"
|
||||||
|
|
||||||
|
|
||||||
|
class FakePlaylist:
|
||||||
|
def __init__(self, item=None):
|
||||||
|
self.item = item
|
||||||
|
self.removed_id = None
|
||||||
|
|
||||||
|
def current_item(self):
|
||||||
|
return self.item
|
||||||
|
|
||||||
|
def remove_by_id(self, item_id):
|
||||||
|
self.removed_id = item_id
|
||||||
|
self.item = None
|
||||||
|
|
||||||
|
|
||||||
|
class FfmpegHttpErrorTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
constants.load_lang("en_US")
|
||||||
|
|
||||||
|
def make_bot(self):
|
||||||
|
bot = bragi.MumbleBot.__new__(bragi.MumbleBot)
|
||||||
|
bot.thread_stderr = None
|
||||||
|
bot.last_ffmpeg_err = ""
|
||||||
|
bot.ffmpeg_fatal_error = ""
|
||||||
|
bot.redirect_ffmpeg_log = False
|
||||||
|
bot.log = logging.getLogger("test")
|
||||||
|
return bot
|
||||||
|
|
||||||
|
def test_ffmpeg_http_404_stderr_is_fatal(self):
|
||||||
|
bot = self.make_bot()
|
||||||
|
bot.thread_stderr = io.StringIO("[http @ 0x123] HTTP error 404 Not Found\n")
|
||||||
|
|
||||||
|
bot._read_ffmpeg_stderr()
|
||||||
|
|
||||||
|
self.assertEqual(bot.ffmpeg_fatal_error, "[http @ 0x123] HTTP error 404 Not Found")
|
||||||
|
|
||||||
|
def test_ffmpeg_fatal_error_removes_current_item(self):
|
||||||
|
item = FakeItem()
|
||||||
|
var.playlist = FakePlaylist(item)
|
||||||
|
var.cache = FakeCache()
|
||||||
|
|
||||||
|
bot = self.make_bot()
|
||||||
|
bot.ffmpeg_fatal_error = "[http @ 0x123] HTTP error 404 Not Found"
|
||||||
|
bot._cleanup_ffmpeg_process = mock.Mock()
|
||||||
|
bot.send_channel_msg = mock.Mock()
|
||||||
|
|
||||||
|
bot._fail_current_ffmpeg_item()
|
||||||
|
|
||||||
|
bot._cleanup_ffmpeg_process.assert_called_once_with()
|
||||||
|
self.assertEqual(var.playlist.removed_id, item.id)
|
||||||
|
self.assertEqual(var.cache.freed_id, item.id)
|
||||||
|
bot.send_channel_msg.assert_called_once()
|
||||||
|
self.assertEqual(bot.ffmpeg_fatal_error, "")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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()
|
||||||
@@ -27,6 +27,108 @@ YT_PKG_NAME = 'yt-dlp'
|
|||||||
|
|
||||||
log = logging.getLogger("bot")
|
log = logging.getLogger("bot")
|
||||||
|
|
||||||
|
# Default certificate filename for auto-generation
|
||||||
|
DEFAULT_CERT_NAME = "bragi.pem"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_certificate(cert_path):
|
||||||
|
"""Generate a self-signed certificate for Mumble authentication.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cert_path: Path where the certificate file will be saved
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if certificate was generated successfully, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
log.info(f"certificate: generating new self-signed certificate at {cert_path}")
|
||||||
|
|
||||||
|
# Generate RSA private key (2048 bits is standard for this use)
|
||||||
|
privateKey = rsa.generate_private_key(
|
||||||
|
public_exponent=65537,
|
||||||
|
key_size=2048,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create certificate subject/issuer
|
||||||
|
subject = issuer = x509.Name([
|
||||||
|
x509.NameAttribute(NameOID.COMMON_NAME, "Bragi Music Bot"),
|
||||||
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Bragi"),
|
||||||
|
])
|
||||||
|
|
||||||
|
# Build and sign certificate (valid for 10 years)
|
||||||
|
cert = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(subject)
|
||||||
|
.issuer_name(issuer)
|
||||||
|
.public_key(privateKey.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(datetime.datetime.now(datetime.timezone.utc))
|
||||||
|
.not_valid_after(datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=3650))
|
||||||
|
.sign(privateKey, hashes.SHA256())
|
||||||
|
)
|
||||||
|
|
||||||
|
# Write both private key and certificate to same PEM file
|
||||||
|
# (this is the format Mumble expects)
|
||||||
|
with open(cert_path, "wb") as f:
|
||||||
|
f.write(privateKey.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
))
|
||||||
|
f.write(cert.public_bytes(serialization.Encoding.PEM))
|
||||||
|
|
||||||
|
log.info("certificate: successfully generated new certificate")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
log.warning("certificate: cryptography library not installed, cannot generate certificate")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"certificate: failed to generate certificate: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_certificate(config_cert_path):
|
||||||
|
"""Get existing certificate or create a new one if needed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_cert_path: Certificate path from config (may be empty)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to certificate file, or empty string if none available
|
||||||
|
"""
|
||||||
|
# If user specified a certificate in config, use that
|
||||||
|
if config_cert_path:
|
||||||
|
resolved = solve_filepath(config_cert_path)
|
||||||
|
if resolved and os.path.exists(resolved):
|
||||||
|
log.debug(f"certificate: using configured certificate: {resolved}")
|
||||||
|
return resolved
|
||||||
|
elif config_cert_path:
|
||||||
|
log.warning(f"certificate: configured certificate not found: {config_cert_path}")
|
||||||
|
# Fall through to auto-generation
|
||||||
|
|
||||||
|
# Check for existing auto-generated certificate
|
||||||
|
scriptDir = os.path.dirname(os.path.realpath(__file__))
|
||||||
|
defaultCertPath = os.path.join(scriptDir, DEFAULT_CERT_NAME)
|
||||||
|
|
||||||
|
if os.path.exists(defaultCertPath):
|
||||||
|
log.debug(f"certificate: using existing auto-generated certificate: {defaultCertPath}")
|
||||||
|
return defaultCertPath
|
||||||
|
|
||||||
|
# Generate new certificate
|
||||||
|
if generate_certificate(defaultCertPath):
|
||||||
|
return defaultCertPath
|
||||||
|
|
||||||
|
# No certificate available
|
||||||
|
log.warning("certificate: no certificate available, connecting without one")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def solve_filepath(path):
|
def solve_filepath(path):
|
||||||
if not path:
|
if not path:
|
||||||
@@ -42,6 +144,12 @@ def solve_filepath(path):
|
|||||||
|
|
||||||
|
|
||||||
def get_recursive_file_list_sorted(path):
|
def get_recursive_file_list_sorted(path):
|
||||||
|
# Audio file extensions to include (fast check before expensive magic call)
|
||||||
|
AUDIO_EXTENSIONS = {
|
||||||
|
'.mp3', '.flac', '.ogg', '.opus', '.m4a', '.m4b', '.mp4', '.m4p',
|
||||||
|
'.wav', '.aac', '.wma', '.aiff', '.aif', '.ape', '.mka', '.webm'
|
||||||
|
}
|
||||||
|
|
||||||
filelist = []
|
filelist = []
|
||||||
for root, dirs, files in os.walk(path, topdown=True, onerror=None, followlinks=True):
|
for root, dirs, files in os.walk(path, topdown=True, onerror=None, followlinks=True):
|
||||||
relroot = root.replace(path, '', 1)
|
relroot = root.replace(path, '', 1)
|
||||||
@@ -55,9 +163,16 @@ def get_recursive_file_list_sorted(path):
|
|||||||
if not os.access(fullpath, os.R_OK):
|
if not os.access(fullpath, os.R_OK):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Fast path: check extension first (covers 99% of cases)
|
||||||
|
ext = os.path.splitext(file)[1].lower()
|
||||||
|
if ext in AUDIO_EXTENSIONS:
|
||||||
|
filelist.append(os.path.join(relroot, file))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Slow path: use magic for files without recognized extensions
|
||||||
try:
|
try:
|
||||||
mime = magic.from_file(fullpath, mime=True)
|
mime = magic.from_file(fullpath, mime=True)
|
||||||
if 'audio' in mime or 'audio' in magic.from_file(fullpath).lower() or 'video' in mime:
|
if 'audio' in mime or 'video' in mime:
|
||||||
filelist.append(os.path.join(relroot, file))
|
filelist.append(os.path.join(relroot, file))
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|||||||
Reference in New Issue
Block a user