3 Commits

Author SHA1 Message Date
Storm Dragon 14163bdbc1 Restore configured volume after playback stops 2026-06-14 01:48:13 -04:00
Storm Dragon e2df8972e7 Stop playback on ffmpeg HTTP 404 2026-06-14 01:45:18 -04:00
Storm Dragon c310a1c318 Speed updatabase generation. Generate and use a certificate by default. 2025-12-12 22:34:11 -05:00
14 changed files with 604 additions and 246 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):**
+62 -9
View File
@@ -66,6 +66,7 @@ class MumbleBot:
self.read_pcm_size = 0
self.pcm_buffer_size = 0
self.last_ffmpeg_err = ""
self.ffmpeg_fatal_error = ""
# Play/pause status
self.is_pause = False
@@ -101,7 +102,8 @@ class MumbleBot:
if args.certificate:
certificate = args.certificate
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:
tokens = args.tokens
@@ -527,13 +529,51 @@ class MumbleBot:
# The ffmpeg process is a thread
# 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
if pipe_rd is not None:
self.thread_stderr = os.fdopen(pipe_rd)
else:
pipe_rd, pipe_wd = None, None
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):
# 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)
self.read_pcm_size += len(raw_music)
if self.redirect_ffmpeg_log:
try:
self.last_ffmpeg_err = self.thread_stderr.readline()
if self.last_ffmpeg_err:
self.log.debug("ffmpeg: " + self.last_ffmpeg_err.strip("\n"))
except:
pass
self._read_ffmpeg_stderr()
if self.ffmpeg_fatal_error:
self._fail_current_ffmpeg_item()
raw_music = None
continue
if raw_music:
# Adjust the volume and send it to mumble
@@ -678,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
@@ -817,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):
@@ -827,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:
+6 -1
View File
@@ -54,8 +54,14 @@ music_database_path = music.db
music_folder = music_folder/
pip3_path = venv/bin/pip
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
refresh_cache_on_startup = True
restore_volume_on_stop = False
save_music_library = True
save_playlist = 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
# URLs are downloaded and played like the !url command
+13 -13
View File
@@ -16,6 +16,10 @@ port = 64738
#password =
#channel =
#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 =
# 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!"
#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 = music_folder/
@@ -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.
@@ -74,8 +77,11 @@ port = 64738
#autoplay_length = 5
#clear_when_stop_in_oneshot = False
# Auto-update system has been removed from Bragi
#target_version = stable
# 'rebuild_workers': 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
# '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
@@ -89,10 +95,6 @@ port = 64738
# 'download_attempts': How many times to attempt a download.
#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.
# 'redirect_stderr': Whether to capture outputs from standard error and write
# it into the log file. Useful for capturing an exception message when the
@@ -105,7 +107,7 @@ port = 64738
#allow_private_message = True
# '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
# '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.
#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.
[debug]
# 'ffmpeg': Whether to display debug messages from ffmpeg.
-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:
+2 -82
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"
}
}
+102 -14
View File
@@ -5,6 +5,8 @@
import logging
import os
import multiprocessing
from concurrent.futures import ProcessPoolExecutor, as_completed
import json
import threading
@@ -23,6 +25,29 @@ class ItemNotCachedError(Exception):
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):
def __init__(self, db: MusicDatabase):
super().__init__()
@@ -115,26 +140,89 @@ class MusicCache(dict):
def build_dir_cache(self):
self.dir_lock.acquire()
try:
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
results = self.db.query_music(Condition().or_equal('type', 'file'))
for result in results:
if result['path'] not in files:
self.log.debug("library: music file missed: %s, delete from library." % result['path'])
self.db.delete_music(Condition().and_equal('id', result['id']))
self.log.info(f"library: found {len(files_on_disk)} audio files on disk")
# Get all existing file paths from database as a set
db_paths = set(self.db.query_all_paths())
self.log.info(f"library: found {len(db_paths)} files in database")
# 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:
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))
if not results:
item = item_builders['file'](path=file)
self.log.debug("library: music save into database: %s" % item.format_debug_string())
self.db.insert_music(item.to_dict())
# Process files in parallel
processed_items = []
with ProcessPoolExecutor(max_workers=num_workers) as executor:
# Submit all files for processing
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.log.info("library: directory cache rebuild complete")
finally:
self.dir_lock.release()
+1 -67
View File
@@ -5,11 +5,8 @@
import os
import re
from io import BytesIO
import base64
import hashlib
import mutagen
from PIL import Image
import util
import variables as var
@@ -23,7 +20,6 @@ type : file
title
artist
duration
thumbnail
user
'''
@@ -52,7 +48,6 @@ class FileItem(BaseItem):
self.path = path
self.title = ""
self.artist = ""
self.thumbnail = None
self.id = hashlib.md5(path.encode()).hexdigest()
if os.path.exists(self.uri()):
self._get_info_from_tag()
@@ -62,7 +57,6 @@ class FileItem(BaseItem):
else:
super().__init__(from_dict)
self.artist = from_dict['artist']
self.thumbnail = from_dict['thumbnail']
try:
self.validate()
except ValidationFailedError:
@@ -95,112 +89,58 @@ class FileItem(BaseItem):
assert path is not None and file_name is not None
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":
# title: TIT2
# artist: TPE1, TPE2
# album: TALB
# cover artwork: APIC:
tags = mutagen.File(self.uri())
if 'TIT2' in tags:
self.title = tags['TIT2'].text[0]
if 'TPE1' in tags: # artist
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":
# title: ©nam (\xa9nam)
# artist: ©ART
# album: ©alb
# cover artwork: covr
tags = mutagen.File(self.uri())
if '©nam' in tags:
self.title = tags['©nam'][0]
if '©ART' in tags: # artist
self.artist = tags['©ART'][0]
if im is None:
if "covr" in tags:
im = Image.open(BytesIO(tags["covr"][0]))
elif ext == ".opus":
# title: 'title'
# artist: 'artist'
# 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())
if 'title' in tags:
self.title = tags['title'][0]
if 'artist' in tags:
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":
# title: 'title'
# artist: 'artist'
# album: 'album'
# cover artwork: tags.pictures
tags = mutagen.File(self.uri())
if 'title' in tags:
self.title = tags['title'][0]
if 'artist' in tags:
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:
pass
if not self.title:
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):
dict = super().to_dict()
dict['type'] = 'file'
dict['path'] = self.path
dict['title'] = self.title
dict['artist'] = self.artist
dict['thumbnail'] = self.thumbnail
return dict
def format_debug_string(self):
@@ -217,13 +157,7 @@ class FileItem(BaseItem):
)
def format_current_playing(self, user):
display = 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
return tr("now_playing", item=self.format_song_string(user))
def format_title(self):
title = self.title if self.title else self.path
+1 -28
View File
@@ -8,11 +8,8 @@ import logging
import os
import hashlib
import traceback
from PIL import Image
import yt_dlp as youtube_dl
import glob
from io import BytesIO
import base64
import util
from constants import tr_cli as tr
@@ -52,7 +49,6 @@ class URLItem(BaseItem):
self.duration = 0
self.id = hashlib.md5(url.encode()).hexdigest()
self.path = var.tmp_folder + self.id
self.thumbnail = ""
self.keywords = ""
else:
super().__init__(from_dict)
@@ -60,7 +56,6 @@ class URLItem(BaseItem):
self.duration = from_dict['duration']
self.path = from_dict['path']
self.title = from_dict['title']
self.thumbnail = from_dict['thumbnail']
self.downloading = False
self.type = "url"
@@ -194,7 +189,6 @@ class URLItem(BaseItem):
'format': 'bestaudio/best',
'outtmpl': base_path,
'noplaylist': True,
'writethumbnail': True,
'updatetime': False,
'verbose': var.config.getboolean('debug', 'youtube_dl'),
'postprocessors': [{
@@ -232,7 +226,6 @@ class URLItem(BaseItem):
self.log.info(
"bot: finished downloading url (%s) %s, saved to %s." % (self.title, self.url, self.path))
self.downloading = False
self._read_thumbnail_from_file(base_path + ".jpg")
self.version += 1 # notify wrapper to save me
return True
else:
@@ -242,18 +235,6 @@ class URLItem(BaseItem):
self.downloading = False
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):
dict = super().to_dict()
dict['type'] = 'url'
@@ -261,7 +242,6 @@ class URLItem(BaseItem):
dict['duration'] = self.duration
dict['path'] = self.path
dict['title'] = self.title
dict['thumbnail'] = self.thumbnail
return dict
@@ -280,14 +260,7 @@ class URLItem(BaseItem):
return self.url
def format_current_playing(self, user):
display = 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
return tr("now_playing", item=self.format_song_string(user))
def format_title(self):
return self.title if self.title else self.url
+1 -8
View File
@@ -125,14 +125,7 @@ class PlaylistURLItem(URLItem):
user=user)
def format_current_playing(self, user):
display = 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
return tr("now_playing", item=self.format_song_string(user))
def display_type(self):
return tr("url_from_playlist")
+1 -1
View File
@@ -1,6 +1,5 @@
yt-dlp
python-magic
Pillow
mutagen
requests
packaging
@@ -9,3 +8,4 @@ opuslib==3.0.1
numpy
protobuf
pycryptodome
cryptography
+83
View File
@@ -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()
+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()
+116 -1
View File
@@ -27,6 +27,108 @@ YT_PKG_NAME = 'yt-dlp'
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):
if not path:
@@ -42,6 +144,12 @@ def solve_filepath(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 = []
for root, dirs, files in os.walk(path, topdown=True, onerror=None, followlinks=True):
relroot = root.replace(path, '', 1)
@@ -55,9 +163,16 @@ def get_recursive_file_list_sorted(path):
if not os.access(fullpath, os.R_OK):
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:
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))
except:
pass