3 changed files with 209 additions and 0 deletions
@ -0,0 +1,2 @@
|
||||
"""Application services independent from the GUI.""" |
||||
|
||||
@ -0,0 +1,93 @@
|
||||
from __future__ import annotations |
||||
|
||||
import json |
||||
import shutil |
||||
import subprocess |
||||
from pathlib import Path |
||||
from typing import Any |
||||
|
||||
from voice_transcriptor.models import MediaInfo, ToolStatus |
||||
|
||||
|
||||
class MediaProbeError(RuntimeError): |
||||
"""A media file could not be validated or inspected.""" |
||||
|
||||
|
||||
class InvalidMediaPathError(MediaProbeError): |
||||
"""The selected path is not a readable local file.""" |
||||
|
||||
|
||||
class ProbeOutputError(MediaProbeError): |
||||
"""FFprobe returned output that cannot be interpreted.""" |
||||
|
||||
|
||||
class ProbeExecutionError(MediaProbeError): |
||||
"""FFprobe could not start or inspect the selected media.""" |
||||
|
||||
|
||||
class ProbeTimeoutError(MediaProbeError): |
||||
"""FFprobe did not finish before its configured timeout.""" |
||||
|
||||
|
||||
def detect_tools() -> ToolStatus: |
||||
ffmpeg = shutil.which("ffmpeg") |
||||
ffprobe = shutil.which("ffprobe") |
||||
return ToolStatus(Path(ffmpeg) if ffmpeg else None, Path(ffprobe) if ffprobe else None) |
||||
|
||||
|
||||
def validate_media_path(path: Path) -> Path: |
||||
path = path.expanduser() |
||||
if not path.exists(): |
||||
raise InvalidMediaPathError("The selected file does not exist.") |
||||
if not path.is_file(): |
||||
raise InvalidMediaPathError("The selected path is not a file.") |
||||
try: |
||||
path.stat() |
||||
except OSError as exc: |
||||
raise InvalidMediaPathError("The selected file cannot be read.") from exc |
||||
return path.resolve() |
||||
|
||||
|
||||
def parse_probe_output(path: Path, size_bytes: int, payload: str) -> MediaInfo: |
||||
try: |
||||
data: dict[str, Any] = json.loads(payload) |
||||
except (json.JSONDecodeError, TypeError) as exc: |
||||
raise ProbeOutputError("FFprobe returned invalid data.") from exc |
||||
duration: float | None = None |
||||
raw_duration = data.get("format", {}).get("duration") |
||||
try: |
||||
if raw_duration is not None: |
||||
duration = float(raw_duration) |
||||
except (TypeError, ValueError): |
||||
duration = None |
||||
codec = next( |
||||
(stream.get("codec_name") for stream in data.get("streams", []) if stream.get("codec_type") == "audio"), |
||||
None, |
||||
) |
||||
return MediaInfo(path=path, size_bytes=size_bytes, duration_seconds=duration, audio_codec=codec) |
||||
|
||||
|
||||
class MediaProbeService: |
||||
def __init__(self, ffprobe_path: Path, timeout_seconds: int = 30) -> None: |
||||
self.ffprobe_path = ffprobe_path |
||||
self.timeout_seconds = timeout_seconds |
||||
|
||||
def probe(self, path: Path) -> MediaInfo: |
||||
media_path = validate_media_path(path) |
||||
command = [ |
||||
str(self.ffprobe_path), "-v", "error", "-show_entries", |
||||
"format=duration:stream=codec_type,codec_name", "-of", "json", str(media_path), |
||||
] |
||||
startup_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) |
||||
try: |
||||
result = subprocess.run( |
||||
command, capture_output=True, text=True, timeout=self.timeout_seconds, |
||||
check=False, creationflags=startup_flags, |
||||
) |
||||
except subprocess.TimeoutExpired as exc: |
||||
raise ProbeTimeoutError("FFprobe timed out while inspecting the file.") from exc |
||||
except OSError as exc: |
||||
raise ProbeExecutionError("FFprobe could not be started.") from exc |
||||
if result.returncode != 0: |
||||
raise ProbeExecutionError("FFprobe could not inspect this media file.") |
||||
return parse_probe_output(media_path, media_path.stat().st_size, result.stdout) |
||||
@ -0,0 +1,114 @@
|
||||
import json |
||||
import subprocess |
||||
from pathlib import Path |
||||
|
||||
import pytest |
||||
|
||||
from voice_transcriptor.services.media_probe import ( |
||||
InvalidMediaPathError, |
||||
MediaProbeError, |
||||
MediaProbeService, |
||||
ProbeExecutionError, |
||||
ProbeOutputError, |
||||
ProbeTimeoutError, |
||||
detect_tools, |
||||
parse_probe_output, |
||||
validate_media_path, |
||||
) |
||||
|
||||
|
||||
def test_detects_ffmpeg_and_ffprobe_executables(monkeypatch: pytest.MonkeyPatch) -> None: |
||||
discovered = {"ffmpeg": r"C:\\tools\\ffmpeg.exe", "ffprobe": r"C:\\tools\\ffprobe.exe"} |
||||
monkeypatch.setattr("voice_transcriptor.services.media_probe.shutil.which", discovered.get) |
||||
|
||||
status = detect_tools() |
||||
|
||||
assert status.ffmpeg_path == Path(r"C:\tools\ffmpeg.exe") |
||||
assert status.ffprobe_path == Path(r"C:\tools\ffprobe.exe") |
||||
|
||||
|
||||
def test_rejects_directory_path(tmp_path: Path) -> None: |
||||
with pytest.raises(InvalidMediaPathError, match="not a file"): |
||||
validate_media_path(tmp_path) |
||||
|
||||
|
||||
def test_parses_audio_only_media(tmp_path: Path) -> None: |
||||
media = tmp_path / "memo.m4a" |
||||
payload = json.dumps({ |
||||
"format": {"duration": "4.5"}, |
||||
"streams": [{"codec_type": "audio", "codec_name": "aac"}], |
||||
}) |
||||
|
||||
info = parse_probe_output(media, 3, payload) |
||||
|
||||
assert info.duration_seconds == 4.5 |
||||
assert info.audio_codec == "aac" |
||||
|
||||
|
||||
def test_parses_video_with_audio(tmp_path: Path) -> None: |
||||
media = tmp_path / "meeting.mov" |
||||
media.write_bytes(b"x" * 12) |
||||
payload = json.dumps({ |
||||
"format": {"duration": "3661.25"}, |
||||
"streams": [{"codec_type": "video", "codec_name": "h264"}, {"codec_type": "audio", "codec_name": "aac"}], |
||||
}) |
||||
info = parse_probe_output(media, 12, payload) |
||||
assert info.duration_seconds == 3661.25 |
||||
assert info.audio_codec == "aac" |
||||
|
||||
|
||||
def test_missing_metadata_is_unknown(tmp_path: Path) -> None: |
||||
media = tmp_path / "memo.m4a" |
||||
info = parse_probe_output(media, 0, '{"format": {}, "streams": []}') |
||||
assert info.duration_seconds is None |
||||
assert info.audio_codec is None |
||||
|
||||
|
||||
def test_rejects_malformed_probe_json(tmp_path: Path) -> None: |
||||
with pytest.raises(MediaProbeError, match="invalid data"): |
||||
parse_probe_output(tmp_path / "bad.mov", 0, "not json") |
||||
|
||||
|
||||
def test_rejects_missing_file(tmp_path: Path) -> None: |
||||
with pytest.raises(MediaProbeError, match="does not exist"): |
||||
validate_media_path(tmp_path / "missing.mov") |
||||
|
||||
|
||||
def test_maps_nonzero_ffprobe_result(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: |
||||
media = tmp_path / "bad.mov" |
||||
media.write_bytes(b"bad") |
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **k: subprocess.CompletedProcess(a, 1, "", "invalid input")) |
||||
with pytest.raises(MediaProbeError, match="could not inspect"): |
||||
MediaProbeService(Path("ffprobe")).probe(media) |
||||
|
||||
|
||||
def test_maps_ffprobe_timeout(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: |
||||
media = tmp_path / "slow.mov" |
||||
media.write_bytes(b"slow") |
||||
def timeout(*args: object, **kwargs: object) -> None: |
||||
raise subprocess.TimeoutExpired("ffprobe", 30) |
||||
monkeypatch.setattr(subprocess, "run", timeout) |
||||
with pytest.raises(MediaProbeError, match="timed out"): |
||||
MediaProbeService(Path("ffprobe")).probe(media) |
||||
|
||||
|
||||
def test_maps_probe_failures_to_typed_errors(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: |
||||
missing = tmp_path / "missing.mov" |
||||
with pytest.raises(InvalidMediaPathError): |
||||
validate_media_path(missing) |
||||
|
||||
with pytest.raises(ProbeOutputError): |
||||
parse_probe_output(tmp_path / "bad.mov", 0, "not json") |
||||
|
||||
media = tmp_path / "bad.mov" |
||||
media.write_bytes(b"bad") |
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **k: subprocess.CompletedProcess(a, 1, "", "invalid input")) |
||||
with pytest.raises(ProbeExecutionError): |
||||
MediaProbeService(Path("ffprobe")).probe(media) |
||||
|
||||
def timeout(*args: object, **kwargs: object) -> None: |
||||
raise subprocess.TimeoutExpired("ffprobe", 30) |
||||
|
||||
monkeypatch.setattr(subprocess, "run", timeout) |
||||
with pytest.raises(ProbeTimeoutError): |
||||
MediaProbeService(Path("ffprobe")).probe(media) |
||||
Loading…
Reference in new issue