feat: model honest conversion progress

This commit is contained in:
drockhollaback
2026-08-29 16:26:06 -04:00
parent 00357bcaf8
commit 3967702f3a
6 changed files with 255 additions and 0 deletions
+18
View File
@@ -1,5 +1,6 @@
from collections.abc import Mapping
from pathlib import Path
import math
import tomllib
from .models import Roots, Settings
@@ -24,6 +25,7 @@ def load_settings(path: Path, environ: Mapping[str, str]) -> Settings:
audnexus = _table(document, "audnexus", errors)
chapters = _table(document, "chapters", errors)
silence = _table(chapters, "silence", errors)
progress = _table(document, "progress", errors)
validation = _table(document, "validation", errors)
path_error_count = len(errors)
@@ -97,6 +99,7 @@ def load_settings(path: Path, environ: Mapping[str, str]) -> Settings:
silence, "analysis_timeout_seconds", errors, 1800.0
),
silence_max_output_bytes=_integer_value(silence, "max_output_bytes", errors, 10_485_760),
progress_update_seconds=_positive_number_value(progress, "update_seconds", errors, 5.0),
duration_tolerance_ms=_integer_value(validation, "duration_tolerance_ms", errors, 3000),
)
if paths_are_valid:
@@ -198,6 +201,21 @@ def _number_value(
return default
def _positive_number_value(
table: Mapping[str, object], key: str, errors: list[str], default: float
) -> float:
value = table.get(key, default)
if (
isinstance(value, (int, float))
and not isinstance(value, bool)
and math.isfinite(value)
and value > 0
):
return float(value)
errors.append(f"progress.{key} must be positive")
return default
def _boolean_value(
table: Mapping[str, object], key: str, errors: list[str], default: bool
) -> bool:
+3
View File
@@ -132,6 +132,7 @@ class Settings:
silence_snap_window_seconds: float = 45.0
silence_analysis_timeout_seconds: float = 1800.0
silence_max_output_bytes: int = 10_485_760
progress_update_seconds: float = 5.0
duration_tolerance_ms: int = 3000
def validate(self) -> None:
@@ -164,6 +165,8 @@ class Settings:
errors.append(f"{name} must be positive")
if self.silence_max_output_bytes <= 0:
errors.append("chapters.silence.max_output_bytes must be positive")
if not math.isfinite(self.progress_update_seconds) or self.progress_update_seconds <= 0:
errors.append("progress.update_seconds must be positive")
if self.duration_tolerance_ms < 0:
errors.append("validation.duration_tolerance_ms must not be negative")
if not valid_audnexus_base_url(self.audnexus_base_url):
+90
View File
@@ -0,0 +1,90 @@
from dataclasses import dataclass
from enum import StrEnum
import math
class ProgressPhase(StrEnum):
CONVERTING = "converting"
APPLYING_CHAPTERS = "applying_chapters"
VALIDATING = "validating"
MOVING = "moving"
_PHASE_LABELS = {
ProgressPhase.CONVERTING: "Converting audio",
ProgressPhase.APPLYING_CHAPTERS: "Applying chapters",
ProgressPhase.VALIDATING: "Validating output",
ProgressPhase.MOVING: "Moving completed files",
}
@dataclass(frozen=True)
class ProgressEvent:
phase: ProgressPhase
elapsed_seconds: float
active: bool = True
output_bytes: int | None = None
exit_status: int | None = None
error: str = ""
def __post_init__(self) -> None:
if (
isinstance(self.elapsed_seconds, bool)
or not isinstance(self.elapsed_seconds, (int, float))
or not math.isfinite(self.elapsed_seconds)
or self.elapsed_seconds < 0
):
raise ValueError("elapsed_seconds must be finite and non-negative")
if self.output_bytes is not None and (
isinstance(self.output_bytes, bool)
or not isinstance(self.output_bytes, int)
or self.output_bytes < 0
):
raise ValueError("output_bytes must be a non-negative integer")
def format_progress(event: ProgressEvent) -> str:
parts = [
f"[{_PHASE_LABELS[event.phase]}] elapsed {_format_elapsed(event.elapsed_seconds)}"
]
if event.output_bytes is not None:
parts.append(f"output {_format_size(event.output_bytes)}")
parts.append("active" if event.active else "inactive")
if event.exit_status is not None:
parts.append(f"exit={event.exit_status}")
if event.error:
parts.append(f"error={_escape_controls(event.error)}")
return " | ".join(parts)
def _format_elapsed(seconds: float) -> str:
total_seconds = int(seconds)
hours, remainder = divmod(total_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
def _format_size(size: int) -> str:
if size < 1024:
return f"{size} B"
if size < 1024**2:
return f"{size / 1024:.1f} KiB"
if size < 1024**3:
return f"{size / 1024**2:.1f} MiB"
return f"{size / 1024**3:.1f} GiB"
def _escape_controls(value: str) -> str:
escaped: list[str] = []
named = {"\n": r"\n", "\r": r"\r", "\t": r"\t"}
for character in value:
codepoint = ord(character)
if character in named:
escaped.append(named[character])
elif codepoint in {0x2028, 0x2029}:
escaped.append(f"\\u{codepoint:04x}")
elif codepoint < 32 or 127 <= codepoint <= 159:
escaped.append(f"\\x{codepoint:02x}")
else:
escaped.append(character)
return "".join(escaped)
+3
View File
@@ -30,5 +30,8 @@ snap_window_seconds = 45
analysis_timeout_seconds = 1800
max_output_bytes = 10485760
[progress]
update_seconds = 5.0
[validation]
duration_tolerance_ms = 3000
+42
View File
@@ -169,6 +169,48 @@ def test_deployed_settings_include_audnexus_region_and_chapter_analysis_defaults
assert settings.silence_max_output_bytes == 10_485_760
def test_progress_update_seconds_defaults_to_five(tmp_path: Path) -> None:
config = tmp_path / "config.toml"
config.write_text(
'[paths]\npending="/pending"\nready="/ready"\ndone="/done"\nreview="/review"\n',
encoding="utf-8",
)
settings = load_settings(config, {})
assert settings.progress_update_seconds == 5.0
def test_progress_update_seconds_reads_positive_number(tmp_path: Path) -> None:
config = tmp_path / "config.toml"
config.write_text(
'[paths]\npending="/pending"\nready="/ready"\ndone="/done"\nreview="/review"\n'
"[progress]\nupdate_seconds=0.25\n",
encoding="utf-8",
)
settings = load_settings(config, {})
assert settings.progress_update_seconds == 0.25
@pytest.mark.parametrize("value", ("false", "0", "-1", "nan", "inf", "-inf"))
def test_progress_update_seconds_rejects_non_positive_or_non_finite_values(
tmp_path: Path, value: str
) -> None:
config = tmp_path / "config.toml"
config.write_text(
'[paths]\npending="/pending"\nready="/ready"\ndone="/done"\nreview="/review"\n'
f"[progress]\nupdate_seconds={value}\n",
encoding="utf-8",
)
with pytest.raises(ConfigError) as caught:
load_settings(config, {})
assert caught.value.errors == ("progress.update_seconds must be positive",)
def test_validation_aggregates_invalid_chapter_analysis_numbers(tmp_path: Path) -> None:
config = tmp_path / "config.toml"
config.write_text(
+99
View File
@@ -0,0 +1,99 @@
import math
import pytest
from app.progress import ProgressEvent, ProgressPhase, format_progress
def test_formats_zero_elapsed_and_byte_output_exactly() -> None:
event = ProgressEvent(ProgressPhase.CONVERTING, 0, output_bytes=0)
assert format_progress(event) == (
"[Converting audio] elapsed 00:00:00 | output 0 B | active"
)
@pytest.mark.parametrize(
("output_bytes", "formatted_size"),
(
(1023, "1023 B"),
(1024, "1.0 KiB"),
(5 * 1024**2 + 512 * 1024, "5.5 MiB"),
(3 * 1024**3, "3.0 GiB"),
),
)
def test_formats_hour_plus_elapsed_and_iec_output_sizes(
output_bytes: int, formatted_size: str
) -> None:
event = ProgressEvent(
ProgressPhase.CONVERTING,
3_723.99,
output_bytes=output_bytes,
)
assert format_progress(event) == (
f"[Converting audio] elapsed 01:02:03 | output {formatted_size} | active"
)
@pytest.mark.parametrize(
("phase", "label"),
(
(ProgressPhase.APPLYING_CHAPTERS, "Applying chapters"),
(ProgressPhase.VALIDATING, "Validating output"),
(ProgressPhase.MOVING, "Moving completed files"),
),
)
def test_formats_phases_without_output(phase: ProgressPhase, label: str) -> None:
assert format_progress(ProgressEvent(phase, 8)) == (
f"[{label}] elapsed 00:00:08 | active"
)
def test_formats_inactive_success_with_zero_exit_status() -> None:
event = ProgressEvent(
ProgressPhase.CONVERTING,
65,
active=False,
output_bytes=1024,
exit_status=0,
)
assert format_progress(event) == (
"[Converting audio] elapsed 00:01:05 | output 1.0 KiB | inactive | exit=0"
)
def test_escapes_error_controls_without_forging_another_log_line() -> None:
event = ProgressEvent(
ProgressPhase.VALIDATING,
2,
active=False,
exit_status=1,
error="bad\n[Moving completed files]\r\t\x1b\x7f\x85\u2028fake\u2029line",
)
assert format_progress(event) == (
"[Validating output] elapsed 00:00:02 | inactive | exit=1 | "
r"error=bad\n[Moving completed files]\r\t\x1b\x7f\x85\u2028fake\u2029line"
)
assert len(format_progress(event).splitlines()) == 1
def test_progress_event_is_immutable() -> None:
event = ProgressEvent(ProgressPhase.MOVING, 0)
with pytest.raises(AttributeError):
event.active = False # type: ignore[misc]
@pytest.mark.parametrize("elapsed", (-1, math.nan, math.inf, -math.inf))
def test_rejects_negative_or_non_finite_elapsed(elapsed: float) -> None:
with pytest.raises(ValueError, match="elapsed_seconds must be finite and non-negative"):
ProgressEvent(ProgressPhase.CONVERTING, elapsed)
@pytest.mark.parametrize("output", (-1, 1.5, math.nan, math.inf, True))
def test_rejects_invalid_output_bytes(output: object) -> None:
with pytest.raises(ValueError, match="output_bytes must be a non-negative integer"):
ProgressEvent(ProgressPhase.CONVERTING, 0, output_bytes=output) # type: ignore[arg-type]