One probe: ffprobe run against a source, its JSON collected, and the §4
contract filtered out of it.
Three public functions, split where the tests want to cut. probe/2 runs the
subprocess and hands back ffprobe's own decoded JSON; contract/2 is a pure
mapping from that JSON to the object docs/audio-proxy-api-v1.md §4 defines.
Everything version- and format-specific lives in the second one, which is
what makes it testable against canned output per container rather than
against a binary.
has_video?/1 is the third, and it is the audio-only policy rather than part
of §4: both callers run it on the probe they already paid for and refuse a
source carrying video — the render action before it takes a render slot, the
info action before it describes anything. It reads the same JSON contract/2
does, pure and canned-output-testable for the same reason.
Collect, not stream
AudioProxy.Ffmpeg.Render is reused verbatim — same argv-only spawn, same
kill discipline, same stderr classification — with this process as consumer
and the chunks accumulated instead of forwarded. A probe reads container
headers and stops, so its output is a few kilobytes; @max_output bounds the
case where it is not (a source carrying a pathological tag block), because a
collector with no ceiling is a memory hazard on a request path.
Reusing Render also means reusing its failure classes: ffprobe exits 1 for
almost everything, and the same stderr patterns that tell a missing input
from an undecodable one for the encoder tell them apart here.
No render slot
A probe deliberately does not take an AP_MAX_CONCURRENCY slot. The
semaphore exists to bound decoding — an encoder pinning a core for the
length of a file — and a header read is neither long nor CPU-bound. Queueing
probes behind renders would make /info, the endpoint a client calls before
it knows what to request, the slowest thing in the proxy. AP_PROBE_TIMEOUT
is what bounds this path instead.
What the contract does with what ffprobe says
ffprobe's output is verbose, version-dependent and inconsistent across containers; §4's object is none of those. The mapping is therefore explicit rather than a passthrough, and its rules are:
- Omission over null. A field ffprobe cannot answer is absent from the
object, never
nulland never a zero standing in for "unknown". A client testing"bit_depth" in infogets a true answer for every source. formatis the API's own vocabulary, not the container's. ffprobe names themov,mp4,m4a,3gp,3g2,mj2family with one string and both Ogg payloads withogg; §3.1 spells thosem4a,opusandogg. Reporting thef:token a client would pass back is more useful than reporting ffprobe's demuxer name, so the codec refines the container where the container alone is ambiguous.bit_depthcomes from the stream, and only when it means something.bits_per_raw_samplefirst,bits_per_samplesecond; a lossy stream answers0to both and so has no depth, which is exactly the §4 behaviour. Deliberately notsample_fmt, which the design sketch suggested: mp3 decodes tofltpand would report a 32-bit depth for a source that has none.sizeis the store's, not ffprobe's.AudioProxy.Source.stat/1already knows it and is authoritative for the object;format.sizeis the fallback for a backend that does not.- Tags are passthrough, bounded. String-valued format tags only,
lowercased keys, capped in both count and length (
@max_tags,@max_tag_length) — they are arbitrary bytes from a file the operator may not control, and they end up in a response body.
Summary
Functions
The argument vector probe/2 runs, for a caller that spawns the subprocess
itself.
Maps ffprobe's decoded JSON to the §4 contract.
Resolves the ffprobe binary, or {:error, :probe_failed}.
Whether a probe found a genuine video stream.
Runs ffprobe against input and returns its decoded JSON.
The probe deadline in milliseconds — AP_PROBE_TIMEOUT as the subprocess
sees it.
Types
@type error_reason() ::
:not_found | :undecodable_source | :probe_timeout | :probe_failed
Why a probe produced no contract.
The §4 object, with every unknown field omitted.
String values (format) and numbers land as themselves; tags is a map of
lowercased tag name to string.
Functions
The argument vector probe/2 runs, for a caller that spawns the subprocess
itself.
/info wants probe/2: it is answering a request and can block on the
collect. AudioProxy.Peaks.Render cannot — it is a GenServer that has to
stay answerable to cancel/1 while the probe runs, so it spawns the same
argv through AudioProxy.Ffmpeg.Render and folds the chunks in
handle_info/2. Both routes must ask ffprobe the same question, or the two
callers would drift on flags; this function is that shared answer.
protocols is the -protocol_whitelist set, from
AudioProxy.Ffmpeg.Command.protocols/1, and it is an argument rather than an
option for exactly the reason above: a caller that builds its own argv would
otherwise be the one route that reads a source unrestricted, which is the hole
the whitelist exists to close. It binds the input because it precedes it.
input is passed through as a single argv element, and is last, so nothing
after it can be read as a flag.
iex> AudioProxy.Ffprobe.args("s3://b/k.wav", "https,tls,tcp") |> List.last()
"s3://b/k.wav"
iex> AudioProxy.Ffprobe.args("/srv/a.wav", "file") |> Enum.take(-3)
["-protocol_whitelist", "file", "/srv/a.wav"]
Maps ffprobe's decoded JSON to the §4 contract.
A pure function — this is what the per-container fixtures pin. Options:
:size— the object sizeAudioProxy.Source.stat/1reported, which wins over ffprobe's own.
Returns {:error, :undecodable_source} when the probe found no audio stream:
ffprobe parsed something, but nothing this proxy can serve.
iex> probe = %{
...> "format" => %{"format_name" => "wav", "duration" => "3.5", "size" => "672044"},
...> "streams" => [%{
...> "codec_type" => "audio", "codec_name" => "pcm_s16le",
...> "sample_rate" => "48000", "channels" => 2, "bits_per_sample" => 16
...> }]
...> }
iex> AudioProxy.Ffprobe.contract(probe)
{:ok, %{format: "wav", duration: 3.5, sample_rate: 48000, channels: 2,
bit_depth: 16, size: 672044}}
@spec executable(String.t() | nil) :: {:ok, String.t()} | {:error, error_reason()}
Resolves the ffprobe binary, or {:error, :probe_failed}.
Public for the same reason args/1 is: a caller that spawns the subprocess
itself still has to find it, and finding it twice in two ways is how the two
paths end up disagreeing about which binary ran.
Whether a probe found a genuine video stream.
The question the audio-only policy turns on, and it is not simply "is there a
stream whose codec_type is video". Virtually every tagged mp3, flac and m4a
carries its cover art as exactly such a stream, and refusing those would
refuse most of a real catalogue. Two exemptions, in the order they are
checked:
attached_picdisposition — ffmpeg's own word for "this is cover art, not a video track". Authoritative where present.- A single-frame image codec —
mjpeg/png/… withnb_framesof 1, for containers whose disposition data is missing or ambiguous.
Anything else with a video codec_type is video. The fallback direction is
deliberate: an unrecognized codec, an absent frame count, a disposition map
that says nothing all reject. Failing closed here costs a client a 415 on an
odd file; failing open makes the proxy a video transcoder.
Two limits worth knowing. The image-codec list is not every still-image
decoder ffmpeg carries, only names that cannot also be a video stream — see
the list itself for why widening it is the risky direction. And the
attached_pic exemption trusts a flag that lives in the container, i.e. in
bytes the requester may control: a crafted file can wear it. What that buys is
bounded by the layer underneath rather than by this function — every argv
carries -vn -sn -dn, so the video stream is never mapped and the render is
an audio-only encode either way.
iex> AudioProxy.Ffprobe.has_video?(%{"streams" => [
...> %{"codec_type" => "audio", "codec_name" => "mp3"},
...> %{"codec_type" => "video", "codec_name" => "mjpeg",
...> "disposition" => %{"attached_pic" => 1}}
...> ]})
false
iex> AudioProxy.Ffprobe.has_video?(%{"streams" => [
...> %{"codec_type" => "video", "codec_name" => "h264"}
...> ]})
true
@spec probe( String.t(), keyword() ) :: {:ok, map()} | {:error, error_reason()}
Runs ffprobe against input and returns its decoded JSON.
input is whatever AudioProxy.Source.ffmpeg_input/1 produced — a path or a
presigned URL — and is passed through as a single argv element.
Options:
:protocols— required. The-protocol_whitelistset, fromAudioProxy.Ffmpeg.Command.protocols/1. Required rather than defaulted for the same reasonAudioProxy.Ffmpeg.Command.build/3requires itstype:: this is a subprocess that reads the source, so a caller with no protocol set to offer has no business starting one. There is deliberately no "unrestricted" spelling — an omitted key raises, which fails a refactor's tests rather than silently reopeningfile:andconcat:to whatever the source redirects to.:executable— the binary to run. Defaults toffprobefromPATH.:timeout— milliseconds. Defaults toAP_PROBE_TIMEOUT.
@spec timeout(keyword()) :: pos_integer()
The probe deadline in milliseconds — AP_PROBE_TIMEOUT as the subprocess
sees it.