From 28e4f8ab0cb71d281e1e3d5e6fefcb507042f2a2 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 12 Jul 2026 15:06:44 -0400 Subject: [PATCH] Progress bar beeps now work on ffmpeg output. --- .../onScreenUpdate/65000-progress_detector.py | 59 ++++++++++- src/fenrirscreenreader/fenrirVersion.py | 2 +- tests/unit/test_progress_detector.py | 98 +++++++++++++++++++ 3 files changed, 156 insertions(+), 3 deletions(-) diff --git a/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py b/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py index 6374ad15..10b7c350 100644 --- a/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py +++ b/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py @@ -117,7 +117,7 @@ class command: has_percentage = re.search(r"(^|\s)\d+(?:\.\d+)?\s*%", text) if not has_percentage: - return False + return self.has_progress_status_fields(text) return bool( re.search( @@ -240,8 +240,20 @@ class command: pacman_match = re.search( r"\d+(?:\.\d+)?\s+[kKmMgGtT]iB\s+\d+(?:\.\d+)?\s+[kKmMgGtT]iB/s\s+\d+:\d+", text ) + # Pattern 1g: Key/value status lines from ffmpeg-like terminal tools. + # These do not expose a percentage in the status line, so treat them as + # activity rather than guessing progress from elapsed time. + status_fields_match = self.has_progress_status_fields(text) - if time_match or token_match or dd_match or curl_match or transfer_match or pacman_match: + if ( + time_match + or token_match + or dd_match + or curl_match + or transfer_match + or pacman_match + or status_fields_match + ): # For non-percentage progress, use a single activity beep every 2 # seconds if ( @@ -577,5 +589,48 @@ class command: return True return False + def has_progress_status_fields(self, text): + """Recognize dense key=value status lines from terminal tools.""" + import re + + if "\n" in text or self.contains_url(text): + return False + + fields = re.findall(r"\b([a-zA-Z][a-zA-Z0-9_-]*)=\s*\S+", text) + if len(fields) < 3: + return False + + field_names = {field.lower() for field in fields} + progress_fields = { + "frame", + "fps", + "size", + "time", + "bitrate", + "speed", + "elapsed", + "dup", + "drop", + } + if len(field_names & progress_fields) < 3: + return False + + has_time_field = bool( + re.search( + r"\b(?:time|elapsed|eta)=\s*\d+(?::\d{2}){1,2}(?:\.\d+)?\b", + text, + re.IGNORECASE, + ) + ) + has_rate_field = bool( + re.search( + r"\b(?:speed|fps|bitrate)=\s*[\d.]+[a-zA-Z/]*\b", + text, + re.IGNORECASE, + ) + ) + + return has_time_field and has_rate_field + def set_callback(self, callback): pass diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index 7a7eccb1..6afdbb4f 100644 --- a/src/fenrirscreenreader/fenrirVersion.py +++ b/src/fenrirscreenreader/fenrirVersion.py @@ -4,5 +4,5 @@ # Fenrir TTY screen reader # By Chrys, Storm Dragon, and contributors. -version = "2026.06.27" +version = "2026.07.12" code_name = "testing" diff --git a/tests/unit/test_progress_detector.py b/tests/unit/test_progress_detector.py index d0ec90be..928fb59e 100644 --- a/tests/unit/test_progress_detector.py +++ b/tests/unit/test_progress_detector.py @@ -140,3 +140,101 @@ def test_progress_detector_beeps_for_interruptible_status_without_ellipsis(): command.run() command.play_activity_beep.assert_called_once_with() + + +@pytest.mark.unit +def test_progress_detector_allows_long_key_value_status_delta(): + progress_module = _load_progress_module() + command = progress_module.command() + sample = ( + "frame=33480 fps=429 q=28.0 size= 195328KiB " + "time=00:23:16.26 bitrate=1146.0kbits/s dup=0 drop=1 " + "speed=17.9x elapsed=0:01:18.01 " + "filter=scale,format output=archive-video-final-render-pass.mkv " + "metadata=preserve-chapters-and-stream-layout" + ) + command.env = { + "commandBuffer": {"progress_monitoring": True}, + "runtime": { + "DebugManager": Mock(write_debug_out=Mock()), + "ScreenManager": Mock(is_screen_change=Mock(return_value=False)), + "CursorManager": Mock(is_cursor_vertical_move=Mock(return_value=False)), + }, + "screen": { + "new_delta": sample, + "new_content_text": sample, + "old_cursor": {"x": 0, "y": 0}, + "new_cursor": {"x": 0, "y": 0}, + }, + } + + assert len(sample) > 200 + assert command.is_real_progress_update() + + +@pytest.mark.unit +def test_progress_detector_beeps_for_ffmpeg_status_line(): + progress_module = _load_progress_module() + command = progress_module.command() + sample = ( + "frame=33480 fps=429 q=28.0 size= 195328KiB " + "time=00:23:16.26 bitrate=1146.0kbits/s dup=0 drop=1 " + "speed=17.9x elapsed=0:01:18.01" + ) + command.env = { + "commandBuffer": { + "progress_monitoring": True, + "lastProgressValue": -1, + "lastProgressTime": 0, + }, + "runtime": { + "DebugManager": Mock(write_debug_out=Mock()), + "ScreenManager": Mock(is_screen_change=Mock(return_value=False)), + "CursorManager": Mock(is_cursor_vertical_move=Mock(return_value=False)), + }, + "screen": { + "new_delta": sample, + "new_delta_is_typing": False, + "new_content_text": sample, + "old_cursor": {"x": 0, "y": 0}, + "new_cursor": {"x": 0, "y": 0}, + }, + } + command.play_activity_beep = Mock() + + command.run() + + command.play_activity_beep.assert_called_once_with() + + +@pytest.mark.unit +def test_progress_detector_ignores_generic_key_value_text(): + progress_module = _load_progress_module() + command = progress_module.command() + sample = "host=server mode=debug user=Username retry=3 status=ready" + command.env = { + "commandBuffer": { + "progress_monitoring": True, + "lastProgressValue": -1, + "lastProgressTime": 0, + }, + "runtime": { + "DebugManager": Mock(write_debug_out=Mock()), + "ScreenManager": Mock(is_screen_change=Mock(return_value=False)), + "CursorManager": Mock(is_cursor_vertical_move=Mock(return_value=False)), + }, + "screen": { + "new_delta": sample, + "new_delta_is_typing": False, + "new_content_text": sample, + "old_cursor": {"x": 0, "y": 0}, + "new_cursor": {"x": 0, "y": 0}, + }, + } + command.play_activity_beep = Mock() + command.play_progress_tone = Mock() + + command.run() + + command.play_activity_beep.assert_not_called() + command.play_progress_tone.assert_not_called()