refactor(probe): consolidate full probe() into MediaProber port
Add probe(video) -> MediaInfo | None to the MediaProber Protocol and implement it on FfprobeMediaProber. The standalone alfred/infrastructure/filesystem/ffprobe.py module is removed; all callers (analyze_release / probe_media tools, testing scripts) now go through the adapter. Tests for the probe path moved to tests/infrastructure/test_ffprobe_prober.py (patching subprocess.run at the adapter module level). Unblocks the upcoming inspect_release orchestrator, which needs the port — not a free function — to compose parse + main-video selection + probe in one shot.
This commit is contained in:
@@ -1,21 +1,19 @@
|
||||
"""Tests for the smaller ``alfred.infrastructure.filesystem`` helpers.
|
||||
|
||||
Covers four siblings of ``FileManager`` that had near-zero coverage:
|
||||
Covers three siblings of ``FileManager`` that had near-zero coverage:
|
||||
|
||||
- ``ffprobe.probe`` — wraps ``ffprobe`` JSON output into a ``MediaInfo``.
|
||||
- ``filesystem_operations.create_folder`` / ``move`` — thin
|
||||
``mkdir`` / ``mv`` wrappers returning dict-shaped responses.
|
||||
- ``organizer.MediaOrganizer`` — computes destination paths for movies
|
||||
and TV episodes; creates folders for them.
|
||||
- ``find_video.find_video_file`` — first-video lookup in a folder.
|
||||
|
||||
External commands (``ffprobe`` / ``mv``) are patched via ``subprocess.run``.
|
||||
(``ffprobe`` coverage now lives in ``test_ffprobe_prober.py`` alongside
|
||||
its adapter.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from alfred.domain.movies.entities import Movie
|
||||
@@ -27,7 +25,6 @@ from alfred.domain.tv_shows.value_objects import (
|
||||
SeasonNumber,
|
||||
ShowStatus,
|
||||
)
|
||||
from alfred.infrastructure.filesystem import ffprobe
|
||||
from alfred.infrastructure.filesystem.filesystem_operations import (
|
||||
create_folder,
|
||||
move,
|
||||
@@ -38,147 +35,6 @@ from alfred.infrastructure.knowledge.release_kb import YamlReleaseKnowledge
|
||||
|
||||
_KB = YamlReleaseKnowledge()
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ffprobe.probe #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _ffprobe_result(returncode=0, stdout="{}", stderr="") -> MagicMock:
|
||||
return MagicMock(returncode=returncode, stdout=stdout, stderr=stderr)
|
||||
|
||||
|
||||
class TestFfprobe:
|
||||
def test_timeout_returns_none(self, tmp_path):
|
||||
f = tmp_path / "x.mkv"
|
||||
f.write_bytes(b"")
|
||||
with patch(
|
||||
"alfred.infrastructure.filesystem.ffprobe.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired(cmd="ffprobe", timeout=30),
|
||||
):
|
||||
assert ffprobe.probe(f) is None
|
||||
|
||||
def test_nonzero_returncode_returns_none(self, tmp_path):
|
||||
f = tmp_path / "x.mkv"
|
||||
f.write_bytes(b"")
|
||||
with patch(
|
||||
"alfred.infrastructure.filesystem.ffprobe.subprocess.run",
|
||||
return_value=_ffprobe_result(returncode=1, stderr="not a media file"),
|
||||
):
|
||||
assert ffprobe.probe(f) is None
|
||||
|
||||
def test_invalid_json_returns_none(self, tmp_path):
|
||||
f = tmp_path / "x.mkv"
|
||||
f.write_bytes(b"")
|
||||
with patch(
|
||||
"alfred.infrastructure.filesystem.ffprobe.subprocess.run",
|
||||
return_value=_ffprobe_result(stdout="not json {"),
|
||||
):
|
||||
assert ffprobe.probe(f) is None
|
||||
|
||||
def test_parses_format_duration_and_bitrate(self, tmp_path):
|
||||
f = tmp_path / "x.mkv"
|
||||
f.write_bytes(b"")
|
||||
payload = {
|
||||
"format": {"duration": "1234.5", "bit_rate": "5000000"},
|
||||
"streams": [],
|
||||
}
|
||||
with patch(
|
||||
"alfred.infrastructure.filesystem.ffprobe.subprocess.run",
|
||||
return_value=_ffprobe_result(stdout=json.dumps(payload)),
|
||||
):
|
||||
info = ffprobe.probe(f)
|
||||
assert info is not None
|
||||
assert info.duration_seconds == 1234.5
|
||||
assert info.bitrate_kbps == 5000 # bit_rate // 1000
|
||||
|
||||
def test_invalid_numeric_format_fields_skipped(self, tmp_path):
|
||||
f = tmp_path / "x.mkv"
|
||||
f.write_bytes(b"")
|
||||
payload = {
|
||||
"format": {"duration": "garbage", "bit_rate": "also-bad"},
|
||||
"streams": [],
|
||||
}
|
||||
with patch(
|
||||
"alfred.infrastructure.filesystem.ffprobe.subprocess.run",
|
||||
return_value=_ffprobe_result(stdout=json.dumps(payload)),
|
||||
):
|
||||
info = ffprobe.probe(f)
|
||||
assert info is not None
|
||||
assert info.duration_seconds is None
|
||||
assert info.bitrate_kbps is None
|
||||
|
||||
def test_parses_streams(self, tmp_path):
|
||||
f = tmp_path / "x.mkv"
|
||||
f.write_bytes(b"")
|
||||
payload = {
|
||||
"format": {},
|
||||
"streams": [
|
||||
{
|
||||
"index": 0,
|
||||
"codec_type": "video",
|
||||
"codec_name": "h264",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"codec_type": "audio",
|
||||
"codec_name": "ac3",
|
||||
"channels": 6,
|
||||
"channel_layout": "5.1",
|
||||
"tags": {"language": "eng"},
|
||||
"disposition": {"default": 1},
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"codec_type": "audio",
|
||||
"codec_name": "aac",
|
||||
"channels": 2,
|
||||
"tags": {"language": "fra"},
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"codec_type": "subtitle",
|
||||
"codec_name": "subrip",
|
||||
"tags": {"language": "fra"},
|
||||
"disposition": {"forced": 1},
|
||||
},
|
||||
],
|
||||
}
|
||||
with patch(
|
||||
"alfred.infrastructure.filesystem.ffprobe.subprocess.run",
|
||||
return_value=_ffprobe_result(stdout=json.dumps(payload)),
|
||||
):
|
||||
info = ffprobe.probe(f)
|
||||
assert info.video_codec == "h264"
|
||||
assert info.width == 1920 and info.height == 1080
|
||||
assert len(info.audio_tracks) == 2
|
||||
eng = info.audio_tracks[0]
|
||||
assert eng.language == "eng"
|
||||
assert eng.is_default is True
|
||||
assert info.audio_tracks[1].is_default is False
|
||||
assert len(info.subtitle_tracks) == 1
|
||||
assert info.subtitle_tracks[0].is_forced is True
|
||||
|
||||
def test_first_video_stream_wins(self, tmp_path):
|
||||
# The implementation only fills video_codec on the FIRST video stream.
|
||||
f = tmp_path / "x.mkv"
|
||||
f.write_bytes(b"")
|
||||
payload = {
|
||||
"format": {},
|
||||
"streams": [
|
||||
{"codec_type": "video", "codec_name": "h264", "width": 1920},
|
||||
{"codec_type": "video", "codec_name": "hevc", "width": 3840},
|
||||
],
|
||||
}
|
||||
with patch(
|
||||
"alfred.infrastructure.filesystem.ffprobe.subprocess.run",
|
||||
return_value=_ffprobe_result(stdout=json.dumps(payload)),
|
||||
):
|
||||
info = ffprobe.probe(f)
|
||||
assert info.video_codec == "h264"
|
||||
assert info.width == 1920
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# filesystem_operations #
|
||||
|
||||
Reference in New Issue
Block a user