Status: progressive v0, the first service surface. This file is the
committed API contract: golden response fixtures in
server/testdata/golden/ are asserted in tests, and the client package
is the reference consumer. Endpoints marked later are designed but not
yet served; requesting them returns the 404 envelope.
Conventions:
-
Default port 4418. No
/api/v1prefix; JSON bodies carry"schemaVersion": 1instead. -
Errors are always the family envelope, kebab-case codes shared with the CLI exit-code contract (
waxflow exit-codes):{"error": "human text", "code": "not-found", "schemaVersion": 1} -
Codes:
invalid-request unauthorized signature-invalid signature-expired source-changed not-found unsupported-format unsupported-source payload-too-large source-unreadable output-unwritable overloaded canceled catalog-unavailable internal. -
Status mapping: 400 invalid-request, 401 unauthorized, 403 signature-invalid/expired, 404 not-found, 410 source-changed, 413 payload-too-large, 415 unsupported-format, 416 (range refusal, code invalid-request), 501 unsupported-source, 503 overloaded (with
Retry-After: 2) and catalog-unavailable, 500 the rest. -
A path that exists under other methods answers 405 with an
Allowheader listing them, and codeinvalid-request(no code maps to 405). A path no endpoint claims stays 404not-found.
Control endpoints require an API key: X-API-Key: <key> or
Authorization: Bearer <key> (SHA-256 + constant-time compare, multiple
keys supported). Playback endpoints (/stream) accept an API key or
a signed query. A valid API key wins outright: signature parameters on a
keyed request (even stale or tampered ones) are ignored, so a trusted
caller re-fetching an expired signed URL with its key just works. Source
identity is separate from auth: any request carrying id gets 410 source-changed when the file changed, keys included; drop id to opt
out of pinning.
Fail closed: with a non-loopback addr and no apiKeys, the daemon
refuses to start unless allowUnauthenticated: true is explicit.
exp (unix seconds) + kid (key id) + sig = base64url (no padding) of
HMAC-SHA256 over:
"waxflow-v1" "\n" method "\n" path "\n" canonicalQuery
canonicalQuery: every query parameter exceptsig, percent-encoded per RFC 3986 (uppercase hex,%20for space,~bare), sorted by key then value, joined with&. Every playback-affecting parameter is inside the signature.- HEAD signs and verifies as GET, so players' preflight HEADs pass.
- Expiry leeway: 60 seconds of clock skew is tolerated.
- Signed URLs must carry
id=<size>-<mtimeNS>, the source identity they were minted for. If the file changed, playback returns410 source-changedand the client re-mints. Key-authed requests may sendidvoluntarily to get the same pinning. - Default TTL:
max(6h, 2 x source duration).
Mint with POST /sign, the client package (client.MintURL offline),
or waxflow sign.
| Method and path | Auth | Purpose |
|---|---|---|
GET /ping |
none | liveness (Docker HEALTHCHECK): {"status":"ok","schemaVersion":1} |
GET /version |
key | {"schemaVersion":1,"version":"v1.0.0"}; version is the build's git describe stamp (a tag, a tag-commit-SHA, or dev for a plain go build), not a fixed constant |
GET /caps |
key | capability discovery (see below) |
GET/POST /probe |
key | container/track/warning report for a source |
POST /sign |
key | mint a signed playback URL |
GET/HEAD /stream |
key or sig | progressive stream (decision ladder) |
POST /transcode |
key | synchronous one-shot; the response body is the transcode |
GET /hls/master.m3u8 |
key or sig | HLS master playlist (ladder; see the HLS section) |
GET /hls/media.m3u8, /hls/init.mp4, /hls/seg/{n}.m4s |
key or sig | HLS variant playlist, init header, media segments |
POST /hls/timeline |
key | mint a multi-source timeline (a play queue) into a tl digest |
GET /cache/stats, POST /cache/gc |
key | cache operations |
POST /roots/reload |
key | reconcile library roots from the re-read config (present only when wired; see delivery.rootsReload) |
GET /metrics |
key or metricsKey | Prometheus text exposition |
GET /demo |
none (dev mode only, demo: true) |
browser test page |
POST /uploads, DELETE /uploads/{id} |
key | spool one-shot sources; reference as src=upload:<id> |
POST /jobs, GET /jobs[/{id}], DELETE /jobs/{id} |
key | async full-file transcode/analyze/merge/split jobs |
GET /jobs/{id}/events |
key or sig | server-sent job progress events (EventSource cannot set headers) |
GET /jobs/{id}/result[/{n}] |
key or sig | finished output file (full ranges), or the job's product as JSON when it wrote no file |
GET /art, GET /lyrics |
key or sig | embedded cover art / lyrics passthrough (raw bytes, ETag'd, no resizing) |
GET /probe?src=<ref>[&strict=1] or POST /probe with
{"src": "<ref>", "strict": false}. Both forms reject names they do not
know: src and strict are the whole surface, and a misspelled strict
would otherwise return the opposite of what was asked.
{
"schemaVersion": 1,
"container": "wav",
"tracks": [{
"id": 0, "codec": "pcm", "rate": 44100, "channels": 2,
"layout": "FL|FR", "sampleType": "int", "bitDepth": 16,
"samples": 2205, "durationSeconds": 0.05, "default": true
}],
"warnings": ["..."]
}
The body also carries the source's tag summary when present: tags
(canonical uppercase keys to value lists, ReplayGain included; the lyric
sheet is excluded, hasLyrics plus GET /lyrics cover it), hasArt,
and hasLyrics.
tags and chapters ([{"startSeconds", "endSeconds", "title"}]) both
need no mapper: a container that parses them surfaces them either
way, so a daemon embedded without one still reports what the file
carries. A mapper's values win when one is wired, since a tag library may
know forms and containers the container package does not. endSeconds is
omitted for the start-only chapter forms (Nero chpl, Musepack SV8
chapter packets, ASF markers) that mean "until the next chapter, or end
of stream"; a caller deriving a span from chapter n reads it when
present and chapter n+1's startSeconds when not.
hasArt and GET /art do still need a mapper, and that is deliberate
rather than an oversight: hasArt reports what /art can serve, and
/art serves only what the mapper hands it, so reporting true from a
cover-art atom the container merely saw would promise a payload the
endpoint then 404s. Text tags are what the container fallback covers.
bitDepth is the depth the source stores samples at. It is not
always the depth the pipeline decodes to: the signal path is float32, so
a 64-bit float source decodes at 32 bits and reports bitDepth: 64.
sampleType ("int" or "float") is the float discriminator; a client
must not infer one from a depth.
warnings lists input damage the tolerant parser worked around; strict
turns damage into errors. samples: -1 means unknown length. This is
byte-identical to waxflow probe --json.
/stream?src=<ref>&format=auto|wav|flac|alac|wavpack|mp3|aac|he-aac|opus|vorbis&rate=&ch=&bits=16|24&bitrate=|q=&hev2=&container=&gain=&dynamics=&t=&from=&to=&track=&maxBitRate=
Source references (src): <root>/<relative/path> under a configured
library root; upload:<id> for a spooled one-shot upload (POST
/uploads); pid:<ULID> for a WaxBin catalog item, served by a build with
a catalog resolver and catalogDB configured (delivery.pid in
/caps) and 501 unsupported-source everywhere else. A pid reference re-resolves to the
item's current path on every request, so catalog renames and moves are
transparent: the source identity pins bytes, not locations.
Parameters (unknown parameter names are rejected):
-
format:auto(default) engages the decision ladder;wavforces a WAV transcode;flaca FLAC one (lossless, level 5; a FLAC source underformat=flacwith no transforming parameters direct-plays instead);alaca lossless Apple Lossless stream in progressive fragmented MP4 (audio/mp4);mp3a baseline CBR MP3 (128 kbit/s default,bitrate/qselect it);aacan AAC-LC stream in progressive fragmented MP4 (audio/mp4, 128 kbit/s default,bitrate/qselect it; the init header's edit list carries the gapless trims);he-aacan HE-AAC v1 stream in the same wrappers (64 kbit/s default, output rates 32/44.1/48 kHz; a copyable HE-AAC source underformat=aacremuxes rather than re-encoding, sohe-aacis for encoding to SBR), or HE-AAC v2 underhev2=1(parametric stereo over a mono SBR core, 32 kbit/s default, stereo sources only);opusan Ogg-Opus stream (audio/ogg, 96 kbit/s default,bitrate/qselect it) from the full Opus encoder: SILK, hybrid, and CELT modes with analyser-driven speech/music selection;wavpacka lossless native .wv stream (audio/x-wavpack), whose compression level is a job or CLI parameter rather than a /stream one, like FLAC's. Its metadata is an APEv2 block after the audio, written by the muxer itself rather than by the tagging post-pass: text tags only, under APEv2's own key spellings (Track,Year,Album Artist), with multi-valued fields NUL-joined into one item per key as the format requires. Cover art and chapters do not survive a .wv output. Other formats join as encoders land (/capsis the truth).aiffandapeexist for jobs but have no streaming form: 415. Monkey's Audio has no streaming form at all, not merely an inconvenient one -- a .ape opens with a seek table that is its only index, and with totals nothing knows until the audio has gone out. It carries its metadata the same waywavpackdoes, as an APEv2 block the muxer writes after the audio. Live FLAC and ALAC streams omit the size hints and byte-rate pacing: a lossless encoder's output size is signal-dependent and unknown up front. CBR MP3 and Opus carry a size estimate. Completed cache entries serve with exact sizes like any other. -
rate,ch,bits: output sample rate, channel count (1 or 2), bit depth (16 or 24, dithered when reducing). Absent keeps the source's; omitting the parameter is the only spelling of that.rate=0,ch=0,bits=0,maxBitRate=0, and a barerate=are 400, because zero is not a rate, a channel count, a depth, or a cap. The same rule holds on the HLS master form."Keeps the source's" has one exception, and it is the lossy/lossless split rather than a per-encoder quirk. A source wider than the output format can hold (5.1 to
aac,mp3oropus, none of which encode more than two channels) is downmixed to stereo with a BS.775 matrix, on/streamand on HLS alike, and the daemon logs it atwarn. Lossless outputs refuse instead:alac,wavpackandapeanswer 415, because a lossless file that silently dropped four channels would be lying about what it holds.apeapplies the same rule to depth: it holds 8, 16 and 24-bit, and a 32-bit source is refused rather than narrowed, since every other lossless output here carries 32-bit through. An explicitbits=24still converts it, because then the caller asked.flac,vorbis,wavandaiffcarry multichannel natively and are untouched. An explicitchis never overridden in either direction.The fold is the re-encoding rung's, which is the only rung that can fold: direct play ships the source's own bytes and a container rewrite moves the source's own packets, so both deliver the source's layout whatever it is. A 5.1 AAC file requested as
format=aacis therefore served as 5.1, by packet move, while the same content as FLAC is re-encoded and folded. That is not a special case to remember, it is what those rungs are;ch=2asks for stereo specifically and gets it from every rung, because a channel count the source does not already have declines the passthrough rungs by construction. -
gain:off,track(default),album, or an explicit+/-dBnumber.trackandalbumresolve against the source's ReplayGain 2 tags (OpusR128_*tags convert from the -23 LUFS reference), fall back from album to track, and resolve to 0 dB when the source carries none. Exact measured loudness belongs to jobs (loudness: "analyze"). Positive gain engages the true-peak limiter and is clamped at +12 dB, or +24 dB withdynamics=voice(see below). Read the ceiling off/caps(dsp.gainMaxDb,dsp.gainMaxVoiceDb) rather than assuming one. -
dynamics:off(default) orvoice.voiceis a spoken-word leveller: a gentle 2.5:1 compressor with makeup gain, meant to make an audiobook or podcast intelligible at low volume, where the quiet half of a wide-range reading otherwise falls under the room. It is deliberately audible: that is the feature. It always engages the true-peak limiter.Dynamics acts on the post-gain signal, and composes with
gainrather than replacing it. The preset's curve has a fixed threshold, so level the signal to a known point first and let the preset shape it:/stream?src=book.m4b&format=opus&gain=-6.2&dynamics=voiceThe daemon cannot do the levelling for you on a live stream, because it cannot measure one before serving it; that is what analyze jobs are for. A client with a measured loudness sends the exact dB.
The gain ceiling is a function of this parameter, which is worth stating plainly rather than leaving to be discovered:
gain=16alone resolves to +12 dB, andgain=16&dynamics=voiceresolves to 16. The +12 bound is calibrated for ReplayGain-style music normalization, where more is amplifying noise. Spoken word is a different taste: amateur podcast and audiobook recordings sit near -30 LUFS routinely and cannot reach a -14 LUFS target in one pass under +12. Declaring the source is speech raises the ceiling to +24. Both bounds are taste rather than safety: the true-peak limiter is what makes either one safe, and a dynamics preset always engages it.What the limiter guarantees, precisely: for any legal
gain, the PCM it emits holdstruePeakCeilingDbas measured by its own 4x detector. Two caveats worth budgeting for, because measuring the output will show them:- Any other detector may read slightly higher.
/analyzemeasures with a different 4x design and reads about 0.04 dB over on broadband transients; that is detector disagreement, not headroom lost. It also follows BS.1770-4's rate schedule (4x below 96 kHz, 2x to 192 kHz, none above), so at high rates it is not measuring the same quantity the limiter bounds. - Lossy output is bounded at the encoder's input. For
wavandflacthe guarantee reaches the file (dither adds below it). Foropus,vorbis,aacandmp3a decoder can reconstruct a true peak a few tenths of a dB above what the encoder was handed.
A
dynamicsrequest never direct-plays: unlikegain=trackon an untagged file, which resolves to 0 dB and is a genuine no-op, a preset has no no-op state. - Any other detector may read slightly higher.
-
t: start position in seconds (decimal allowed). Seeks are sample-exact: the decoder pre-rolls from the nearest sync point. At>0request is always a transcode (a new request per seek; live streams are not byte-addressable). Seeking at or past the end of the track is not an error: the response is a valid empty stream (X-Content-Duration: 0.000). -
from,to: bound the stream to a sample range of the source,toexclusive. This is the virtual-track surface: one offset range of one file, served as a stream in its own right.fromdefaults to the start andtoto the end;to=0is400(the span would be empty; omit the parameter to mean the end). A range outside the source is400rather than clamped, since a cut point past the end means the caller's cut points describe a different file.A span is streamed, not seeked:
plan.Samples,X-Content-Duration, and the HLS segment count all describe the span, so/hls/master.m3u8?v={...,"from":10804500,"to":18744300}is a playlist for the virtual track alone. A spanned request never direct-plays, including one that only setsto.Samples, not seconds, and the difference is load-bearing.
tis a seek, where a sample either way is meaningless, so it takes seconds. A span is not a seek: it declares which samples are this track, so it is content identity. A CUE boundary at 245.32 s is not exactly representable in binary (245.32 * 44100floors to 10818611, not 10818612), which puts a one-sample error at every track boundary of a gapless album. That is exactly the failure a CUE split exists to avoid.The two compose, with different jobs:
from/tosay which samples the track is,tseeks within it./stream?src=rip.flac&from=10804500&to=18744300 the virtual track /stream?src=rip.flac&from=10804500&to=18744300&t=30 30 s into itA span can also join a play queue: a timeline member takes the same
from/towindow, so consecutive carves of one rip play gaplessly inside a multi-source timeline (see Multi-source timelines).A span with no rate change is bit-exact: the same samples a split job writes for the same cut points. An Opus or AAC-LC span requested in its own
formatgoes further and skips the decoder altogether, moving the source's own packets (the cut rung below). A resampled span is primed rather than started cold, which matters because HLS Opus is always 48 kHz and a CUE rip is 44.1 kHz, so a virtual track is resampled by construction. Unlike a file, a span has real audio ahead of its own first sample, and the segmented path feeds the resampler from it and discards the pre-roll. Its first samples are therefore the ones a continuous run of the whole source delivers at that offset, not a transient out of a zero-filled filter window. (The progressive form does not prime, matching whatt=has always done at a seek; HLS is where a virtual track is served.) -
track: must name the default track until multi-track containers land. -
bitrate,q: lossy quality selection, mutually exclusive.bitrateis an explicit CBR rate in kbit/s;qis a preset (low96,med128,high192). Both require an explicit lossyformat(mp3,aac,opus); on a lossless output they are415. Abitrate/qrequest forces a re-encode (never direct-play), and the resolved bit rate is part of the cache key, so two rates never share an entry. -
hev2: boolean, selects HE-AAC v2 (parametric stereo,mp4a.40.29) forformat=he-aac; any other format is415. Selection is explicit rather than bitrate-automatic, and the flag is part of the cache key: v1 and v2 at one bitrate are two different codecs and never share an entry. Mono sources are refused (encode v1 instead); the v2 default bitrate is 32 kbit/s. -
container: overrides the format's packaging where an alternative exists. It requires an explicitformat, and a name the format cannot produce is400rather than a silent fall back to the default form. A container override never direct-plays: direct play serves the file in the wrapper it already has, so an override is always a request for bytes rung 1 does not hold. Where the codec survives, rung 2 answers it without re-encoding. The available forms, by format:format container=aacadts,progressive,fragmented,mka(empty is fragmented MP4)alacprogressive,fragmented(empty is fragmented MP4)flacmka,ogg(empty is native FLAC)opusmka,webm(empty is Ogg)vorbismka,webm(empty is Ogg)wavmka(empty is RIFF)container=adtsselects the raw ADTS elementary stream (audio/aac), a legacy opt-out for players without fMP4 support; it carries no gapless signaling, which is why fMP4 is the default.container=progressiveflattens the MP4 boxes (moov+mdat, the.m4amost players expect) and back-patches its header, so it needs a seekable destination and is not a streaming form:/streamrefuses it and a job output takes it.container=fragmentednames the CMAF form explicitly. It is what the empty override already means on/streamand HLS, and it exists because the empty override no longer means it everywhere: every file output rewrites empty toprogressive, since a file can satisfy the back-patch and streaming buys it nothing. That is all three job types (transcode, split, merge) pluswaxflow transcodeandwaxflow split, through one engine-side rule so the set cannot drift. Namingfragmentedis how a file output asks for the delivery form anyway.webmis Opus and Vorbis only, which is webm's audio subset. -
maxBitRate: a kbit/s cap for the decision ladder. For direct play the cap is checked against whole-file bytes over duration (tags and embedded art included): direct play ships the entire file, so the wire cost is what the cap protects. A VBR-lossless plan has no fixed rate to hold under the cap, so a cap on it is415rather than silently unenforced.
Decision ladder (v1), cheapest first:
- Direct play. The source already satisfies the request (
format=autoor a matching container, nocontaineroverride, no transforming parameters, undermaxBitRateif given), so the original bytes are served:200,Content-Typeper container, strong identityETag,Accept-Ranges: byteswith full RFC 7233 range andIf-None-Matchsupport. - Transmux. The codec survives but the wrapper does not: the request
names a container the source is not in, and nothing asks for a sample
transform. The container is rewritten around the source's own packets,
so there is no decode, no re-encode, and no generation loss for a lossy
source. Gapless trims are carried across and re-signalled in whatever
the target container uses (iTunSMPB, an edit list, an Ogg pre-skip).
format=flac&container=mkaon a FLAC file is this rung, as isformat=opuson an Opus file over HLS. - Transcode. Anything else: decode, DSP, encode.
The cut sits between transmux and transcode for a from/to span. When
the source codec survives being repositioned (Opus or AAC-LC; HE-AAC cuts
exist but only for spans keeping the stream head, so they go unadvertised)
and the requested format matches it (format=opus, or format=aac for
AAC-LC), the span is
served by moving the source's own packets into a new stream: no decode, no
re-encode, no generation loss. The head and tail land exactly where asked, since
their snap-to-packet slop becomes the stream's gapless trims. It needs a
source-matching format, because format=auto resolves to wav and would
transcode, so a span that must not re-encode names its codec explicitly. A
client can feature-detect the formats that engage this rung from /caps
delivery.cutFormats instead of inferring cut availability from the source
codec plus a source-matching format=. It
serves both progressive /stream and segmented HLS: over HLS each media segment
holds the kept access units, and a worker restarted at a mid-stream segment
reproduces a continuous run's segment bytes exactly, since the packets are the
source's own and need no priming. waxflow_cut_total in /metrics counts the
pipelines it served, on either surface.
Transmux declines, and the request then tries the cut (for a span) before
falling to a transcode, whenever the codec would not survive (track.Codec is
not the output format's), any parameter transforms samples (rate, ch,
bits, gain, dynamics, t=, bitrate/q), the request names a span
(from/to cut at an arbitrary sample, which means mid-packet), the URL names
a timeline (one fMP4 timeline carries one edit list, and the packets at a seam
overlap), or maxBitRate is set (this rung reads headers, and a source's real
bit rate is in its packets). Over HLS it also declines a source whose packet
durations vary, since there is then no grid to lay segment boundaries on.
waxflow_remux_total counts the pipelines it served. The cut declines in turn
(a codec off the Opus/AAC-LC allowlist, maxBitRate set, a snapped window the
destination cannot express, or over HLS a source whose packet durations vary and
so give no grid to lay segment boundaries on) and falls to a transcode of the
same span, so a span is always served: zero-generation when it can be,
sample-exact through the decoder when it cannot.
Live transcode responses: 200 chunked, Accept-Ranges: none,
Cache-Control: no-store, X-Accel-Buffering: no, plus hints
X-Content-Duration (seconds) and X-Estimated-Content-Length. Range
policy per RFC 9110's permission to ignore: Range: bytes=0- gets the
plain 200 full stream (Safari/AVPlayer attach it to everything); any
nonzero offset gets 416 plus an envelope hinting at t=. Delivery
bursts paceBurstSeconds of audio then caps at paceFactor x realtime.
A write stalled for 60 s kills the session.
Cached responses: the pipeline writes through the transcode cache;
once the entry completes the same URL serves it with real
Content-Length, full ranges, and the cache key as strong ETag.
HEAD never spawns a pipeline: headers and hints come from probe/cache metadata only.
Same query parameters as /stream; the response body is the transcode
(uncacheable, ring-fed, dies with the request). Sets
Content-Disposition: attachment. Counts against live admission slots
for its whole duration, so scripts do not starve playback.
Stateless CMAF/fMP4 delivery: every HLS URL carries a v= descriptor
(base64url JSON) holding the complete output selection plus the source
identity, so segments regenerate after eviction or restart with zero
session state. Descriptor schema (version 1):
{"ver":1, "src":"lib/a.flac", "id":"<size-mtimeNS>", "format":"opus",
"bitrate":96, "bitrates":[64,96,160], "bits":16, "rate":48000,
"ch":2, "gain":"track", "dynamics":"off", "segDur":4,
"from":10804500, "to":18744300}
A he-aac descriptor may also carry "hev2":true (HE-AAC v2 selection;
the field is omitted when false, and refused on other formats).
from/to are the virtual-track span: samples on the source timeline,
to exclusive and omitted for the end. They mint from from=/to=
parameters exactly as /stream spells them, and they are int64 samples
rather than segDur's float seconds for the reason /stream gives above
(a span is content identity, a segment duration is a target the plan snaps
anyway). The playlist then describes the span alone: segment 0 is the
span's first sample and the segment count covers to-from. A span is
exclusive with tl, since it bounds one source and a timeline is several.
bitrates (the ladder) appears only in master URLs; every other URL is
per-variant. Descriptors are minted by the daemon (POST /sign with
path:/hls/master.m3u8, or the keyed raw-parameter master form below),
never hand-built by clients. Auth is an API key or the signed query,
like /stream; playlist child URLs come out signed (when signing is
configured) with the parent's expiry, so one minting governs a playback
session.
| Path | Purpose |
|---|---|
GET /hls/master.m3u8?v=... |
master playlist: one rung per ladder bitrate with BANDWIDTH and CODECS (Opus, fLaC, alac, mp4a.40.2; format=he-aac encodes and advertises mp4a.40.5, or mp4a.40.29 under hev2, and an HE-AAC remux advertises its own signalling, mp4a.40.5 or mp4a.40.29). With an API key, raw parameters (src, format, bitrate or bitrates, hev2, bits, rate, ch, gain, dynamics, segDur, and crossfadeSeconds for a tl timeline) also work; the daemon builds the descriptor. |
GET /hls/media.m3u8?v=... |
variant VOD playlist: EXT-X-VERSION:7, EXT-X-MAP, EXT-X-INDEPENDENT-SEGMENTS, every segment listed with its exact duration, EXT-X-ENDLIST. Unknown source lengths are measured (frame-index walk), never estimated. |
GET /hls/init.mp4?v=... |
the CMAF init header (codec config; the edit list carries encoder delay and the exact length) |
GET /hls/seg/{n}.m4s?v=... |
media segment n (0-based). Cached segments serve with ranges and strong ETags; misses wait on the variant worker (within a 3-segment lookahead) or restart it at n. |
Segments are styp + moof+mdat fragments, boundaries snapped to
whole encoder frames (segDur is a target; the playlist carries exact
durations), all sync samples, decode timeline in sample units. Formats
with a segmented form: opus, flac, aac, alac (see /caps
delivery.hlsFormats); of these, opus and aac also cut over HLS without
re-encoding (see /caps delivery.cutFormats). A 410 source-changed means
the file changed since minting: re-mint and reload.
A play queue can be streamed as one gapless timeline: several files
delivered as a single continuous stream, so a gapless album crosses its
track boundaries with no seam, no re-buffer, and no gap. It is one media
playlist, one init header, and one edit list, with no
EXT-X-DISCONTINUITY anywhere, because a concatenated timeline really is
continuous.
Mint the queue first, then sign a master against the digest. A member may be
a whole file or a from/to sample window of one (here, the virtual track
from the /stream example, joined to a queue):
POST /hls/timeline
{"srcs": [{"src": "lib/Album/01.flac"},
{"src": "lib/rip.flac", "from": 10804500, "to": 18744300}]}
201 {"schemaVersion": 1, "tl": "kJ3n...pQ", "members": 2, "durationSeconds": 1680.0408163265306,
"envelopeRate": 44100,
"boundaries": [{"offsetSamples": 0, "durationSamples": 66150000},
{"offsetSamples": 66150000, "durationSamples": 7939800}]}
POST /sign
{"path": "/hls/master.m3u8", "params": {"tl": "kJ3n...pQ", "format": "opus", "gain": "off"}}
tl and src are exclusive: a URL names one stream or one timeline.
Everything else (format, bitrate/bitrates, hev2, bits, rate,
ch, dynamics, segDur) means what it means for a single source.
Things worth knowing before you build on it:
- The digest is the identity. It covers every member's reference and
its source identity, so a member replaced on disk yields a different
digest and the old URL is a
410 source-changed, exactly as for a single source. Minting the same queue twice returns the same digest, so a client that did not keep it pays nothing to ask again. gain=trackandgain=albumare refused. A timeline is one processing chain, so it has one gain, and there is no honest single answer to read out of several members' tags; per-track gain would step the level at every track boundary, which is the artifact album gain exists to prevent. Passgain=offor the dB you want. Note that this bites a request with nogain=at all when the daemon's default is a tag mode, which is why the refusal names the default rather than a parameter you did not send.- Members are normalized, not refused. Mixed rates, channel counts, and bit depths are converted to the envelope no member loses information reaching (the maximum of each). A uniform queue, which is what a gapless album is, is passed through untouched and costs nothing.
- Members take
from/tosample windows. A member may bound itself to a sample range of its own source: the virtual-track span (/stream'sfrom/to, with its exact semantics), joined to the queue as a member in its own right, so a client-computed CUE carve plays gaplessly inside a timeline. Source samples,toexclusive,0(or absent) running to the end; samples rather than seconds for the span's reason (a CUE boundary at 245.32 s is not exactly representable in binary, and a timeline puts that error at every seam); a window that does not fit the measured source is a400, never clamped. The identity rule is the opposite ofcrossfadeSeconds' and worth internalizing: a window says which samples are this member, so it joins the digest, and two windowings of one file are two timelines with twotls. Consecutive windows tiling one file join gaplessly, byte-identically to streaming the file whole. One nuance: a windowed member that must resample to the envelope primes cold at its first samples (the same status quo as crossfade-seek exactness), while the motivating same-file carve is uniform and exact. Send windows only when/capsdelivery.timelineMemberWindowsis true: an older daemon400s them as unknown fields, so absent means fall back to per-item URLs. boundariessay where each member lands.envelopeRateis the timeline's normalized rate (the maximum member rate), and eachboundaries[i]gives that member'soffsetSamples(its start on the timeline) anddurationSamples(its own length), both on that clock, so a client need not probe the members itself. They are derived from the measured members, not part of the identity, so the digest does not cover them. With no crossfade the members tile exactly:offsetSamples[i+1] == offsetSamples[i] + durationSamples[i], and the last member's end isdurationSeconds * envelopeRate. Under a crossfade the members overlap, sooffsetSamples + durationSamplescan exceed the next member'soffsetSamples. Build on the offsets, not on the tiling.crossfadeSecondsblends the seams, and is a render option. Pass it in the mint body to shape this response'sdurationSecondsandboundaries: an equal-power blend ofcrossfadeSecondsat each of themembers - 1seams shortens the timeline by(members - 1) * crossfadeSeconds. Then pass the same value on the signedmaster.m3u8you build, exactly as you passformat, because the mint's boundaries reflect the crossfade minted with and a stream rendered with a different one disagrees with them. It is a render option, not identity: the digest covers the members alone, so a queue minted with two different crossfades keeps onetland the two renders are kept apart by the cache. Omit it (or send0) for a gapless butt-join, which is the default and what a gapless album needs. It is refused on a singlesrcURL (which has no seam, being one file rather than a queue), when it is longer than the shortest member can carry, or when it exceeds the largest blend buffer (tens of seconds, the one bound that applies to a timeline of any length). A one-member timeline is not refused for want of a seam, though: it has zero seams, so an ordinary crossfade meets none of them (a butt-join), which lets a queue-driven client send one render config whatever the queue length. Only the blend-buffer bound can still refuse it.202means the mint had to measure. A timeline's positions are a prefix sum, so every member's length is measured rather than read off its headers. That is a sub-millisecond walk for formats whose demuxer can find its end from a table (FLAC, WAV, Ogg, mp4), and a whole-file scan for MP3. When a cold queue needs enough of the latter to be worth it, the response is202with a job instead of201with a digest; pollGET /jobs/{id}or follow its events, and the finished job'stimelinefield carries the same values the201body would. The cost is once per file, so the same queue mints in one round trip afterwards.404 not-foundon a timeline means re-mint it. A stored timeline outlives every URL minted against it, so a correct client does not hit this during normal playback; it means the daemon's store was wiped or the timeline aged out unused. Re-mint from the queue you still have and carry on. Do not reset the queue position: the digest is a function of the members, so the re-minted timeline is the same timeline.maxTimelineMembers(in/caps) bounds one timeline at 1000. A timeline is a play queue, not a library.
{"path": "/stream", "params": {"src": "lib/a.flac", "format": "wav"}, "ttlSeconds": 3600}
path defaults to /stream. Also signable: /hls/master.m3u8 (its
params are the raw HLS master parameters above, src or tl, and
every rung is planned at mint time so a URL that mints is a URL that
plays), /art
and /lyrics (params take only src; identity is embedded), and
/jobs/<id>/events / /jobs/<id>/result / /jobs/<id>/result/<n> (no
params; the signature pins the job id, and the index, through the path;
the index is checked for shape only, since a URL worth signing is minted
while the job is still queued and has no outputs yet). params are validated like a live
request, the source identity is resolved and embedded, and the response
is:
{"schemaVersion": 1, "url": "/stream?exp=...&format=wav&id=...&kid=1&sig=...&src=...", "exp": 1767225600}
{
"schemaVersion": 1,
"inputs": ["flac", "wav", "aiff", "ogg", "mp4", "mka", "adts", "ape", "wavpack", "wma", "musepack", "mp3"],
"decoders": ["pcm", "flac", "mp3", "alac", "aac-lc", "he-aac", "wavpack", "ape", "wma", "musepack", "vorbis", "opus"],
"outputs": [{"name": "wav", "live": true, "exts": ["wav", "wave", "rf64", "bw64"]},
{"name": "opus", "live": true, "exts": ["opus"]},
{"name": "vorbis", "live": true, "exts": ["ogg", "oga"]},
{"name": "aiff", "live": false, "exts": ["aif", "aiff", "aifc", "afc"]},
{"name": "flac", "live": true, "exts": ["flac"]},
{"name": "mp3", "live": true, "exts": ["mp3", "mpga"]},
{"name": "aac", "live": true, "exts": ["m4a", "aac", "m4b"]},
{"name": "he-aac", "live": true, "exts": []},
{"name": "alac", "live": true, "exts": []},
{"name": "wavpack", "live": true, "exts": ["wv"]},
{"name": "ape", "live": false, "exts": ["ape"]}],
"delivery": {"progressive": true, "hls": true, "hlsFormats": ["opus", "flac", "aac", "he-aac", "alac"],
"cutFormats": ["opus", "aac"],
"jobs": false, "uploads": false, "pid": false,
"timelines": true, "maxTimelineMembers": 1000,
"timelineMemberWindows": true, "rootsReload": true},
"profiles": {
"apple-native": {
"delivery": "hls",
"progressive": ["aac", "mp3", "flac", "alac", "opus"],
"hls": ["aac", "alac", "flac", "opus"],
"basis": "vendor-documented + manual checklist (docs/client-matrix.md)",
"notes": ["hls opus needs iOS/tvOS 17 or macOS 14", "..."]
},
"hls-js": {"delivery": "hls", "progressive": ["opus", "flac", "mp3", "aac", "wav"],
"hls": ["opus", "flac", "aac"],
"basis": "automated: hls.js + <audio> in Chromium (make client-e2e, nightly)"},
"android-exoplayer": {"...": "..."},
"desktop-mpv": {"...": "..."}
}
}
Capability-gated: only what works is listed. delivery.jobs and
delivery.uploads report whether this daemon runs with a job store and
an upload spool (the CLI daemon always does; a bare library embedding
may not). delivery.pid reports whether pid:<ULID> source references
resolve against a WaxBin catalog (a build with a catalog resolver and
catalogDB).
delivery.timelines reports whether POST /hls/timeline and the tl
parameter are served, and maxTimelineMembers bounds one timeline.
delivery.timelineMemberWindows reports that timeline members take
from/to sample windows; route by its presence, because a daemon too
old to have it 400s a windowed member as an unknown field (fall back to
per-item URLs rather than trying).
delivery.rootsReload reports whether POST /roots/reload is served (see
that endpoint): true only for a file-configured daemon whose roots are
not pinned by WAXFLOW_ROOTS. It is always sent (true or false), so a
client detects support before calling; absent means a server too old to
advertise it.
dsp is the signal-processing surface, so a format policy routes by
capability instead of sniffing a version:
"dsp": {
"gainModes": ["off", "track", "album"],
"gainMaxDb": 12, "gainMaxVoiceDb": 24,
"dynamics": ["off", "voice"],
"loudness": ["analyze"],
"silenceDetector": "silence-1",
"truePeakCeilingDb": -1
}
Every advertised value parses, which is why gainModes does not list a
"<db>" placeholder for the scalar escape hatch: a dB number is always
accepted, and the two ceilings are what a client actually needs to know
about it. Read both ceilings, not one. The clamp on positive gain
depends on dynamics (see /stream), so gainMaxDb alone does not tell
you whether gain=16 is legal. loudness is jobs-only by construction:
a live stream cannot be measured before it is served.
silenceDetector is the silence detector's algorithm revision, the same
value a silence map's own version field carries (see Silence
detection): a caller that persists maps invalidates any whose version
differs, without running a job to find out. Like loudness, it is
advertised even when delivery.jobs is false, because the dsp slot
describes the build's signal path, not this daemon's enabled routes. A
daemon new enough to have the field always sends it non-empty, so absent
(or empty) means a server too old to advertise the detector: fall back
to reading versions off the maps themselves, do not read it as every
cached map being stale (the same rule cutFormats states).
dsp is deliberately orthogonal to profiles. Profiles are about what a
client can decode; dynamics is server-side and client-agnostic, so no
profile has anything to say about it.
profiles are the named delivery profiles: per client family, the
formats verified to play on each surface, in preference order (first is
the default choice), plus the recommended surface (delivery). Pick
the profile matching your player stack instead of guessing codecs:
apple-native (AVPlayer/Safari), hls-js (browser web players),
android-exoplayer (Media3), desktop-mpv (libmpv/ffmpeg players).
Each profile states the basis for its facts; the per-cell evidence
and the manual checklists live in docs/client-matrix.md. Profile lists
are filtered against this build's real outputs, so they never advertise
more than the daemon serves.
POST /uploads spools the raw request body as a one-shot source and
returns
{"schemaVersion": 1, "id": "01J...", "ref": "upload:01J...", "name": "in.flac", "bytes": 123, "expiresAt": 1767225600}
The optional name query parameter supplies the original filename (its
extension seeds the probe hint). Reference the upload anywhere a src
is accepted, upload:<id>. Uploads expire uploadTTL after creation
(default 1 h; expiresAt is absent when expiry is off) and are capped
by uploadMaxBytes each and scratchMaxBytes together.
DELETE /uploads/{id} removes one immediately (204).
Async full-file work that outlives any request, persisted under
dataDir/jobs: completed results survive daemon restarts, and a job
interrupted mid-run restarts cleanly from zero on the next start.
The client package wraps the whole surface (client.CreateJob,
Job, Jobs, DeleteJob, JobResult, JobEvents), with envelope
errors decoded to waxerr codes like every other method.
POST /jobs with a JSON body:
{"type": "transcode", "src": "lib/a.flac", "format": "opus", "bitrate": 96}
{"type": "transcode", "src": "lib/a.flac", "format": "flac", "loudness": "analyze"}
{"type": "analyze", "src": "lib/a.flac"}
{"type": "analyze", "src": "lib/a.flac", "silence": true}
{"type": "merge", "srcs": ["lib/ch1.flac", "lib/ch2.flac"], "format": "alac"}
{"type": "split", "src": "lib/album.flac", "format": "flac", "cuts": [10584000, 21344400]}
{"type": "split", "src": "lib/album.flac", "format": "flac", "cue": "lib/album.cue"}
Transcode jobs take the /stream shaping parameters (format required,
plus container, rate, ch, bits, bitrate, hev2, gain,
flacLevel, wavpackLevel, apeLevel)
and, unlike /stream, may target non-streaming formats (aiff, ape): job
outputs are seekable files, so every muxer back-patch applies (exact WAV
sizes, FLAC seek tables, the MP4 iTunSMPB gapless atom, the Monkey's Audio
seek table and file MD5). loudness: "analyze" selects the two-pass form: measure the source, apply the
exact gain to the ReplayGain reference (replacing gain), and write
measured REPLAYGAIN_TRACK_* tags describing the finished output. Three
outputs get a projection there instead of a measurement, because their
muxer takes its tags before the encode and cannot be patched after it:
Ogg-FLAC, WavPack, and Monkey's Audio. The job's analysis still carries
the measurement in every case; only the file carries the estimate.
Analyze jobs measure EBU R128 loudness (integrated LUFS, loudness
range, true peak, sample peak) without producing audio.
Each field belongs to a specific set of job types, and a field on a type
that does not take it is a 400 rather than a field silently ignored at
run time: srcs and titles are merge-only, cuts and cue split-only
(and exclusive with each other), the silence fields analyze-only, gain
and loudness transcode-only, and the shaping parameters belong to
transcode, merge, and split. src is required by every type but merge,
which refuses it.
merge concatenates srcs into one output, gaplessly: it is the same
timeline primitive tl= streams over HLS, pointed at a file. Members are
normalized to a common envelope (the maximum rate and channel count, so
no member loses information), and their lengths are measured rather
than trusted, since a timeline's positions are a prefix sum and an
advisory length that is two samples out desyncs every seam after it. That
measuring is what a POST /jobs for a cold MP3 queue waits on; a queue
already minted as a timeline (or any queue of FLACs) measures nothing.
split cuts one src at cuts into N+1 outputs. Cut points are source
sample offsets, strictly ascending, and interior: 0 is implied before
the first and the source's end after the last, so N cuts make N+1 pieces
and piece i runs [cuts[i-1], cuts[i]). A leading 0, a cut at or past
the end, a repeat, or a descending pair each ask for an empty piece or for
samples the source does not have, and are all a 400 at creation rather
than a job that dies on piece 7. Samples rather than seconds for the same
reason a span is: a cut declares which samples are this piece, so it is
content identity, and 245.32 s at 44100 floors to one sample short of the
boundary a CUE sheet's CD-frame arithmetic names exactly.
cue names a CUE sheet instead, exclusive with cuts. It is a source
reference like src, so the sheet can be uploaded (upload:<id>) or sit
in a library root beside its rip, and it resolves through the same
resolver. The sheet's track boundaries become this split's cut points at
creation: the first track's start is dropped (it is the implied 0, and a
cut there would ask for an empty piece), so a sheet of N tracks yields
N-1 cuts and N pieces.
The daemon parses the sheet rather than making each client do it, and that is the point of the field. A CD frame is 1/75 s, which no nanosecond clock can represent, so the obvious conversion lands a sample short at every boundary; every CD-family rate divides by 75 exactly (44100/75 = 588), so frames convert to samples directly and exactly. Pushing that to each client is pushing each client to rediscover the same off-by-one. Sheets are decoded best-effort (BOM stripped, UTF-8 when valid, CP1252 otherwise), and a sheet indexing several files is refused: its tracks are already separate, so there is nothing to cut.
The sheet does not reach the job. It is resolved into cuts, which is
what the job carries, so GET /jobs/{id} shows the boundaries the 201
accepted and an edit to the sheet afterward cannot change what runs.
Sending cue and sending the samples it means produce the identical job.
The pair is exact, and that is the property worth having: a split to a
lossless format at the source rate rejoins bit for bit through a merge.
No resampler and no limiter means nothing to prime, so each piece's sample
0 is the source's sample from exactly. Ask for a rate change and each
piece carries the same ~1.5 ms head transient a seek already does.
Neither takes gain or loudness. Both answer "how loud should this one
track be", against that track's own measurement or its own ReplayGain
tags; a merge has N tracks in and one file out and a split one in and N
out, so either would have to apply one source's number to things it does
not describe. Normalize with a transcode job after the cut, where the
numbers name what they measure. Neither carries source tags, and a split
carries no chapters; an mp4-family merge stamps chapter markers, one per
member (below).
An mp4-family merge (alac, aac) writes the flat MP4 form by
default, container: "progressive", which is what the job document will
say. That is the shape most players expect from an .m4a/.m4b, and the
only one that can carry a chapter text track; the fragmented CMAF form is
the row default only because /stream needs a container that streams, and
a job writes to a seekable file where the flat form's back-patch applies.
An explicit container wins, which also means a merge cannot ask for the
fragmented form: omitting the field is how you get the flat one.
That chapter text track is one chapter per member, at the member's start
on the concatenated timeline (the same boundaries POST /hls/timeline
reports). Title them with the optional titles, a string per member
index-aligned to srcs (a title per member, or none, else a 400). A
member's chapter title is its titles entry when non-empty, else the
member's own TITLE tag when a metadata mapper is wired, else a generated
Chapter N. An empty titles[i] does not force a blank title: it falls
through to the tag or the generated name, since the precedence cannot spell
"deliberately blank". A title past the text track's per-entry byte cap is
truncated on a rune boundary. Chapters are stamped only on the
flat/progressive form (the one that carries the track), so a merge to any
other format writes none and reads no per-member titles.
A fifth type, timeline, appears in GET /jobs but cannot be created
here: POST /hls/timeline creates one on its own when a cold queue needs
enough measuring to be worth it (see the HLS section). Its product is the
timeline field, {"tl", "members", "durationSeconds"}, rather than an
output file, since a timeline lives in the timeline store under the digest
that is its identity. The restart contract still holds, by
content-addressing rather than by the usual rule: re-running the mint from
zero writes the same digest.
Every job's sources are pinned by identity at creation, so a source
replaced before the job ran fails with source-changed rather than
quietly producing different audio. A merge pins each member, and the error
names the index that moved (member 2 (lib/ch3.flac): the source changed since the job was created): naming one of forty candidates is the
difference between re-uploading a file and re-uploading a library.
silence: true on an analyze job maps the source's silent spans from
the same decode the loudness measurement runs on, so it costs no extra
I/O. Two optional parameters shape it:
| field | default | range |
|---|---|---|
silenceThresholdDb |
-50 | -90 up to (not including) 0 |
silenceMinSeconds |
0.5 | above 0, at most 60 |
Either without silence: true is a 400 rather than a silently ignored
field. These are raw numbers rather than named levels, unlike gain and
dynamics, and deliberately: a closed vocabulary belongs where a value
enters a cache key or a validated signal path, where it must mean the
same thing forever. These do neither. They shape a report, nothing is
keyed by them, and the right threshold is a property of the content,
which the caller knows and the daemon does not, because what counts as
silence is wherever the source's own noise floor sits: a studio podcast or
any clean digital capture near -80 dBFS, an audiobook's room tone near
-55, an analog or vinyl transfer near -40.
Getting it wrong does not fail cleanly. A threshold below the source's
noise floor makes the signal cross it repeatedly, which fragments one
long silence into many short ones that each fall under
silenceMinSeconds and are dropped, reporting a plainly quiet source as
having no silence at all. droppedSeconds is how that shows up: read
it against the source's duration. A healthy source leaves it a fraction
of a percent; a sizeable share of the stream means the threshold is
wrong for this source rather than that the source is loud.
Read dropped (the count) only alongside it, never alone. Ordinary audio
dips under any threshold at every zero crossing, so even a clean source
with well-formed silences drops hundreds of one-sample runs per second
and the count is large either way.
The job carries only the summary:
"silence": {"version": "silence-1", "thresholdDb": -50, "minSeconds": 0.5,
"spans": 12, "dropped": 431, "droppedSeconds": 0.009, "totalSeconds": 84.2}
The spans themselves are the job's output file, served by GET /jobs/{id}/result, because a 40-hour audiobook pausing every 30 s is
~4800 spans and the job document is broadcast whole on every progress
event. It is silence.json:
{"schemaVersion": 1, "version": "silence-1", "thresholdDb": -50, "minSeconds": 0.5,
"rate": 44100, "samples": 220500, "durationSeconds": 5,
"spans": [{"fromSample": 44100, "toSample": 88200, "fromSeconds": 1, "toSeconds": 2}],
"dropped": 431, "droppedSeconds": 0.009, "totalSeconds": 3}
Spans are half-open (toSample exclusive) and carry both spellings.
The samples are the exact ones, and are what a cut-point list wants;
the seconds are the convenience. version is the detector revision: a
caller caching the map needs it to know when the map went stale, and
/caps advertises the current one as dsp.silenceDetector, so a cached
map whose version differs can be invalidated without re-running a job.
Detection matches ffmpeg's silencedetect exactly (a frame is silent
when every channel is strictly inside the threshold band), which is what
lets the differential test assert span counts rather than approximate
them.
The response (and GET /jobs/{id}) is the job document:
{"schemaVersion": 2, "id": "01J...", "type": "transcode", "state": "queued",
"request": {...}, "created": "...",
"progress": {"phase": "transcode", "done": 123, "total": 456, "percent": 26.9},
"outputs": [{"file": "out.opus", "mediaType": "audio/ogg", "container": "opus", "bytes": 1, "samples": 1, "rate": 48000}],
"analysis": {"integratedLufs": -17.2, "loudnessRange": 4.1, "truePeakDb": -0.9, ...},
"error": {"code": "...", "message": "..."}}
outputs is a list because a split has one entry per piece, in cut order;
every other type has at most one. Its index is what GET /jobs/{id}/result/{n} takes.
States: queued, running, then one of done, failed, canceled.
analysis peak and loudness fields are null for digital silence
(negative infinity does not survive JSON). Jobs queue beyond the
jobSlots worker count, and running jobs pause between chunks while
the live pool is saturated: interactive streams always win.
GET /jobs lists all jobs ({"schemaVersion": 1, "jobs": [...]}).
DELETE /jobs/{id} cancels the job if running and removes it (204).
GET /jobs/{id}/events is a server-sent event stream (client.JobEvents):
one event: job per state or progress update, each data: line a full
job document, ending after the terminal event (comment heartbeats every
15 s keep proxies from timing it out).
GET /jobs/{id}/result/{n} serves the job's nth output file
(client.JobResult; real Content-Length, full ranges, strong
per-output ETag, Content-Disposition: attachment): a transcode's or
a merge's audio, one piece of a split, or an analyze job's
silence.json. An index the job does not have is a 404. A queued or
running job answers 400, a failed one replays its error envelope.
GET /jobs/{id}/result, no index, answers only where it cannot be
wrong: the single output of a job that has one, and a 400 naming the
indexed form for a job with several. Handing back piece 1 of 12 to a
caller who never learned the pieces existed would be a plausible-looking
wrong answer, and refusing is what the daemon does with every other
ambiguity it meets. A job whose product is not a file answers with the
product as JSON instead: an analyze job's analysis, a timeline job's
{"tl", "members", "durationSeconds"}.
Both accept signed URLs, because a browser EventSource and a plain
download link cannot set headers. /jobs/<id>/result/<n> is signable, so
a split's pieces can be handed out as N links.
?src=<ref> (plus optional id identity pin; both signable). /art
serves the source's embedded cover art verbatim, preferring the front
cover, with its own MIME type and a strong identity-derived ETag: a
remote player streaming through WaxFlow has no other channel for
artwork. /lyrics serves embedded lyrics as text/plain (unsynced
text, or an LRC rendering of synced lyrics when that is all the source
has). Sources without the datum answer 404.
GET /cache/stats returns
{"schemaVersion":1,"entries":n,"bytes":n,"hits":n,"misses":n}.
POST /cache/gc runs eviction now and returns
{"schemaVersion":1,"removed":n,"freedBytes":n,"timelinesRemoved":n}.
It also sweeps stored timelines past their expiry, which is why the last
field is there: timelines are not cache entries and free no cache bytes,
so they are counted apart, but they are too small and too rarely minted
to be worth a janitor of their own.
Re-reads the daemon's configuration and reconciles its live library roots
to match, so a root added at runtime streams without a restart
(client.ReloadRoots). Empty body; returns
{"schemaVersion":1,"added":["B"],"removed":[],"changed":[],"roots":["A","B"]}
added, removed, and changed report what moved (changed is a root
whose name stayed but whose path was re-pointed); roots is the full set
after the reload, in configuration order. All four are always arrays.
The reconcile scope is exactly the fields the resolver owns, the library
roots and sourceMaxBytes, so a reload loads byte-for-byte what a
restart would. Everything else (addr, cache, slots, TLS, ...) belongs to
subsystems built once at startup and still needs a restart. A new root is
opened, a dropped one closed, and a re-pointed one swapped; in-flight
streams already resolved from a removed or re-pointed root run to
completion, later requests for a removed root 404. A bad config edit
(missing or unreadable file, unopenable path, invalid or duplicate root
name) is refused synchronously as 400 invalid-request with nothing
changed, so the caller learns at once instead of after a 200 that did
nothing; a wired endpoint never answers 404, so that status
unambiguously means the endpoint is not served here. To avoid a reload
racing a half-written file, the writer updates the config atomically
(write a temp file, then rename).
The endpoint exists only when a reload could actually do something: the
daemon has a config file and WAXFLOW_ROOTS is not pinning roots (that
env variable replaces file roots wholesale and is read once at startup, so
a reload could never reflect a file edit). Otherwise the route 404s and
delivery.rootsReload is false, so a client detects support at
capability-probe time. An env-only or no-config deployment keeps serving
runtime-added roots by direct download instead. client.ReloadRoots
should be gated on delivery.rootsReload rather than on the status it
gets back: an unwired daemon's 404 and a proxy that lost the route are
not distinguishable from the client side.
Prometheus text: waxflow_build_info, waxflow_sessions_active,
waxflow_sessions_total{kind} (live, sync, hls),
waxflow_direct_play_total, waxflow_remux_total,
waxflow_hls_segments_total,
waxflow_cache_{hits,misses}_total, waxflow_cache_{bytes,entries},
waxflow_admission_rejects_total, waxflow_admission_in_use{pool},
waxflow_session_degradations_total, waxflow_ttfb_seconds histogram.