feat: model honest conversion progress
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user