From e2df8972e7dc329a38e28feb5b9a5580784ed639 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 14 Jun 2026 01:45:18 -0400 Subject: [PATCH] Stop playback on ffmpeg HTTP 404 --- bragi.py | 55 +++++++++++++++++---- tests/test_ffmpeg_http_errors.py | 83 ++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 9 deletions(-) create mode 100644 tests/test_ffmpeg_http_errors.py diff --git a/bragi.py b/bragi.py index 0d3d99d..f944296 100755 --- a/bragi.py +++ b/bragi.py @@ -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 @@ -528,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 + 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 @@ -616,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 diff --git a/tests/test_ffmpeg_http_errors.py b/tests/test_ffmpeg_http_errors.py new file mode 100644 index 0000000..c01094d --- /dev/null +++ b/tests/test_ffmpeg_http_errors.py @@ -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()