Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 29 additions & 14 deletions media_shrinker.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,31 @@ def build_opus_plan(
)


def _run_media_tool(
command: list[str], *, tool: str
) -> "subprocess.CompletedProcess[str]":
"""Run an ffmpeg/ffprobe command, mapping a missing binary to a clear error.

When the external tool is not installed or not on PATH, ``subprocess.run``
raises a bare ``FileNotFoundError`` whose message ("No such file or
directory: 'ffprobe'") gives the user no idea what to do. Translate it into
an actionable ``MediaShrinkerError`` that names the tool and how to install
it, while leaving nonzero-exit handling to each caller.
"""

try:
return subprocess.run(
command, check=False, capture_output=True, text=True, shell=False
)
except FileNotFoundError as exc:
raise MediaShrinkerError(
f"{tool} not found: could not run '{command[0]}'. "
"Install ffmpeg (it provides both ffmpeg and ffprobe) and make sure "
"it is on your PATH — e.g. 'brew install ffmpeg' (macOS) or "
"'sudo apt install ffmpeg' (Debian/Ubuntu)."
) from exc


def probe_media(
source_path: Path,
*,
Expand All @@ -465,9 +490,7 @@ def probe_media(
"-i",
str(Path(source_path).resolve()),
]
completed = subprocess.run(
command, check=False, capture_output=True, text=True, shell=False
)
completed = _run_media_tool(command, tool="ffprobe")
if completed.returncode != 0:
raise MediaShrinkerError(
f"ffprobe failed for {source_path}: {completed.stderr.strip()}"
Expand Down Expand Up @@ -520,17 +543,14 @@ def detect_silence_intervals(
) -> list[SilenceInterval]:
"""Run ffmpeg silencedetect and return paired silence intervals."""

completed = subprocess.run(
completed = _run_media_tool(
build_silencedetect_command(
source_path,
ffmpeg_path=ffmpeg_path,
silence_noise=silence_noise,
silence_min_duration_seconds=silence_min_duration_seconds,
),
check=False,
capture_output=True,
text=True,
shell=False,
tool="ffmpeg",
)
if completed.returncode != 0:
raise MediaShrinkerError(
Expand Down Expand Up @@ -1588,12 +1608,7 @@ def _execute_plan(
output_path=temp_output,
overwrite=True,
)
try:
completed = subprocess.run(
command, check=False, capture_output=True, text=True, shell=False
)
except FileNotFoundError as exc:
raise MediaShrinkerError(f"ffmpeg not found: {ffmpeg_path}") from exc
completed = _run_media_tool(command, tool="ffmpeg")

if completed.returncode != 0:
raise MediaShrinkerError(
Expand Down
25 changes: 25 additions & 0 deletions tests/test_media_shrinker.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,31 @@ def test_probe_media_raises_error_on_invalid_json(

self.assertIn("ffprobe returned invalid JSON for test.wav", str(cm.exception))

@patch("media_shrinker.subprocess.run", side_effect=FileNotFoundError)
def test_probe_media_raises_clear_error_when_ffprobe_missing(
self, _mock_run: MagicMock
) -> None:
with self.assertRaises(MediaShrinkerError) as cm:
probe_media(Path("test.wav"), ffprobe_path="missing-ffprobe")

message = str(cm.exception)
self.assertIn("ffprobe not found", message)
self.assertIn("missing-ffprobe", message)
self.assertIn("Install ffmpeg", message)

@patch("media_shrinker.subprocess.run", side_effect=FileNotFoundError)
def test_detect_silence_intervals_raises_clear_error_when_ffmpeg_missing(
self, _mock_run: MagicMock
) -> None:
with self.assertRaises(MediaShrinkerError) as cm:
media_shrinker.detect_silence_intervals(
Path("test.wav"), ffmpeg_path="missing-ffmpeg"
)

message = str(cm.exception)
self.assertIn("ffmpeg not found", message)
self.assertIn("Install ffmpeg", message)

def test_parse_probe_payload_uses_known_source_size_without_stat(self) -> None:
payload = {
"streams": [
Expand Down
Loading