AudioProxy.Ffprobe (audio_proxy v0.7.0)

Copy Markdown View Source

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 serves the audio-only policy rather than §4: both callers run it on the probe they already paid for — the render action before it takes a render slot, the info action before it describes anything — through AudioProxy.VideoPolicy, which holds the verdict on a true. This function answers only the question, identically under either verdict. 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 null and never a zero standing in for "unknown". A client testing "bit_depth" in info gets a true answer for every source.
  • format is the API's own vocabulary, not the container's. ffprobe names the mov,mp4,m4a,3gp,3g2,mj2 family with one string and both Ogg payloads with ogg; §3.1 spells those m4a, opus and ogg. Reporting the f: 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_depth comes from the stream, and only when it means something. bits_per_raw_sample first, bits_per_sample second; a lossy stream answers 0 to both and so has no depth, which is exactly the §4 behaviour. Deliberately not sample_fmt, which the design sketch suggested: mp3 decodes to fltp and would report a 32-bit depth for a source that has none.
  • size is the store's, not ffprobe's. AudioProxy.Source.stat/1 already knows it and is authoritative for the object; format.size is 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

Types

Why a probe produced no contract.

The §4 object, with every unknown field omitted.

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 source properties AudioProxy.Ffmpeg.Command.build/3 takes, read off a probe.

The probe deadline in milliseconds — AP_PROBE_TIMEOUT as the subprocess sees it.

Types

error_reason()

@type error_reason() ::
  :not_found | :undecodable_source | :probe_timeout | :probe_failed

Why a probe produced no contract.

info()

@type info() :: %{optional(atom()) => term()}

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

args(input, protocols)

@spec args(String.t(), String.t()) :: [String.t()]

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"]

contract(probe, opts \\ [])

@spec contract(
  map(),
  keyword()
) :: {:ok, info()} | {:error, :undecodable_source}

Maps ffprobe's decoded JSON to the §4 contract.

A pure function — this is what the per-container fixtures pin. Options:

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}}

executable(path)

@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.

has_video?(probe)

@spec has_video?(map()) :: boolean()

Whether a probe found a genuine video stream.

The question the audio-only policy turns on — AudioProxy.VideoPolicy holds what is done about a true — 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_pic disposition — ffmpeg's own word for "this is cover art, not a video track". Authoritative where present.
  • A single-frame image codecmjpeg/png/… with nb_frames of 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

probe(input, opts)

@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:

  • :protocolsrequired. The -protocol_whitelist set, from AudioProxy.Ffmpeg.Command.protocols/1. Required rather than defaulted for the same reason AudioProxy.Ffmpeg.Command.build/3 requires its type:: 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 reopening file: and concat: to whatever the source redirects to.
  • :executable — the binary to run. Defaults to ffprobe from PATH.
  • :timeout — milliseconds. Defaults to AP_PROBE_TIMEOUT.

source_properties(probe)

@spec source_properties(map()) :: keyword()

The source properties AudioProxy.Ffmpeg.Command.build/3 takes, read off a probe.

A third mapping alongside contract/2 and has_video?/1, and pure for the same reason: it is the render path's answer to "what is this source", where contract/2 is /info's. They read the same audio stream and must not be able to disagree about it, which is why this lives here rather than in the render action.

Only keys the probe actually answered are present — an absent key is what build/3 reads as "fall back", so a nil value would have to be handled twice. A rate ffprobe could not report and a lossy source's absent bit depth are both ordinary, not errors.

bits_per_raw_sample/bits_per_sample are integers and bd is a token, so the mapping is explicit and deliberately partial: 16 and 24 are the depths a source can wear that bd also spells. A 32-bit source is not mapped, because the depth alone cannot tell pcm_s32le from pcm_f32le and bd:32f means the float one; it falls back to 16-bit, exactly as an unprobed source does.

iex> AudioProxy.Ffprobe.source_properties(%{"streams" => [
...>   %{"codec_type" => "audio", "sample_rate" => "44100",
...>     "bits_per_raw_sample" => 24}
...> ]})
[sample_rate: 44100, bit_depth: :bd24]

iex> AudioProxy.Ffprobe.source_properties(%{"streams" => [
...>   %{"codec_type" => "audio", "codec_name" => "mp3",
...>     "sample_rate" => "44100", "bits_per_sample" => 0}
...> ]})
[sample_rate: 44100]

timeout(opts \\ [])

@spec timeout(keyword()) :: pos_integer()

The probe deadline in milliseconds — AP_PROBE_TIMEOUT as the subprocess sees it.