The routes tell the encoder no URL, so an audio codec is never inferred from one #189

Closed
opened 2026-08-22 23:55:07 +02:00 by Claude · 1 comment
Collaborator

Upstream reads the path a streaming request arrived on and infers an audio
codec from it when the client named none. Ours never passes the path at all,
so the inference always answers the same thing.

upstream/Jellyfin.Api/Helpers/StreamingHelpers.cs:70-75:

var url = httpRequest.Path.Value.AsSpan().RightPart('.').ToString();
if (string.IsNullOrEmpty(streamingRequest.AudioCodec))
{
    streamingRequest.AudioCodec = encodingHelper.InferAudioCodec(url);
}

RightPart('.') is the part after the last dot, or the whole path when there
is none. EncodingHelper.InferAudioCodec maps m4a/mp4/mkv to aac,
ts/avi/flv to mp3, ogg/webm to opus, an empty string to aac,
and anything else to itself, lowercased.

Ours has the same function — ephyra_encoding::codec::infer_audio_codec — and
the only caller that could feed it (Helper::attach) is given
requested_url: None by every route (crates/ephyra-app/src/streaming/encoder/jobs.rs:49).
It therefore always sees an empty string and always answers aac.

Measured

Both servers on the anchor, GET /Audio/{id}/universal?container=mp3&transcodingProtocol=hls
(no audioCodec), the master playlist each answers with:

upstream  #EXT-X-STREAM-INF:BANDWIDTH=256000,AVERAGE-BANDWIDTH=256000
          main.m3u8?...&AudioCodec=%2Faudio%2Fc0bc02df...%2Funiversal&SegmentContainer=ts&...
ours      #EXT-X-STREAM-INF:BANDWIDTH=256000,AVERAGE-BANDWIDTH=256000,CODECS="mp4a.40.2"
          main.m3u8?...&SegmentContainer=ts&...&AudioCodec=aac

The path carries no dot, so what upstream infers is the whole path — a URL
where a codec belongs, and a request nothing can encode. It is upstream's
answer nonetheless, and the bodies differ.

The interesting half is not that one: /Audio/{id}/stream.mp3 with no
audioCodec infers mp3 upstream and aac here, which is a real transcode
into the wrong codec rather than a curiosity.

Two things to settle: whether the port carries the inference at all, given
that the only shape a client really sends it in is the one with an extension;
and infer_audio_codec's fallthrough arm, which answers the container
unlowercased where upstream lowercases it.

How to know it is done

A request naming no audio codec is encoded to the codec upstream infers from
the same path, on the routes that carry an extension and on the one that does
not, and the harness agrees on what each answers.

House rules

Tests run under cargo nextest; upstream's source is the checkout in
upstream/ at the pin.

Upstream reads the path a streaming request arrived on and infers an audio codec from it when the client named none. Ours never passes the path at all, so the inference always answers the same thing. `upstream/Jellyfin.Api/Helpers/StreamingHelpers.cs:70-75`: ``` var url = httpRequest.Path.Value.AsSpan().RightPart('.').ToString(); if (string.IsNullOrEmpty(streamingRequest.AudioCodec)) { streamingRequest.AudioCodec = encodingHelper.InferAudioCodec(url); } ``` `RightPart('.')` is the part after the last dot, or the whole path when there is none. `EncodingHelper.InferAudioCodec` maps `m4a`/`mp4`/`mkv` to `aac`, `ts`/`avi`/`flv` to `mp3`, `ogg`/`webm` to `opus`, an empty string to `aac`, and anything else to **itself, lowercased**. Ours has the same function — `ephyra_encoding::codec::infer_audio_codec` — and the only caller that could feed it (`Helper::attach`) is given `requested_url: None` by every route (`crates/ephyra-app/src/streaming/encoder/jobs.rs:49`). It therefore always sees an empty string and always answers `aac`. ## Measured Both servers on the anchor, `GET /Audio/{id}/universal?container=mp3&transcodingProtocol=hls` (no `audioCodec`), the master playlist each answers with: ``` upstream #EXT-X-STREAM-INF:BANDWIDTH=256000,AVERAGE-BANDWIDTH=256000 main.m3u8?...&AudioCodec=%2Faudio%2Fc0bc02df...%2Funiversal&SegmentContainer=ts&... ours #EXT-X-STREAM-INF:BANDWIDTH=256000,AVERAGE-BANDWIDTH=256000,CODECS="mp4a.40.2" main.m3u8?...&SegmentContainer=ts&...&AudioCodec=aac ``` The path carries no dot, so what upstream infers is the whole path — a URL where a codec belongs, and a request nothing can encode. It is upstream's answer nonetheless, and the bodies differ. The interesting half is not that one: `/Audio/{id}/stream.mp3` with no `audioCodec` infers `mp3` upstream and `aac` here, which is a real transcode into the wrong codec rather than a curiosity. Two things to settle: whether the port carries the inference at all, given that the only shape a client really sends it in is the one with an extension; and `infer_audio_codec`'s fallthrough arm, which answers the container unlowercased where upstream lowercases it. ## How to know it is done A request naming no audio codec is encoded to the codec upstream infers from the same path, on the routes that carry an extension and on the one that does not, and the harness agrees on what each answers. ### House rules Tests run under `cargo nextest`; upstream's source is the checkout in `upstream/` **at the pin**.
Author
Collaborator

Done in bfd35d8.

Every streaming route now hands the encoder the path the request arrived on,
and the inference is upstream's: the part after the path's last dot, or the
whole path where it has none, mapped by InferAudioCodec — with the
fallthrough lowercased, which was the second half of what this asked to settle.

The answer to the first half is that the port carries it. A request for
stream.ts naming no audio codec is encoded to mp3 rather than aac, which is
the shape a real client can land on; the odd values the rule produces for a
path with no extension are upstream's own and cost nothing, because a client
that names its codec — all of them do — never reaches them.

The master playlist's query rewriting moved to one place while this was being
done: the codec rewrite already lived in the playlist itself, and the two
parameters the universal audio route settles now sit beside it, where upstream
does all three. A value that has never been through a URL is escaped as it goes
into one.

Measured

Both servers on the anchor, five playlist shapes, byte for byte:

before  /Videos/{id}/master.m3u8?…&videoCodec=h264&segmentContainer=ts   (no audioCodec)
        upstream …&AudioCodec=m3u8            261 bytes, CODECS="avc1.640029"
        ours     …&AudioCodec=aac             270 bytes, CODECS="avc1.640029,mp4a.40.2"
after   identical, 261 bytes

before  /Audio/{id}/universal?container=mp3&transcodingProtocol=hls      (no audioCodec)
        upstream …&AudioCodec=%2Faudio%2F…%2Funiversal&SegmentContainer=ts&…
        ours     …&SegmentContainer=ts&…&AudioCodec=aac
after   identical

and the three shapes that already agreed still do.

A review of the commit found four parity gaps in the surrounding query
rewriting, three of them older than this change: the rotation refusal upstream
appends for a copied, rotated video cut into ts; the renormalisation upstream's
rebuild applies to every pair (+ for a space, lowercase hex, a repeated key);
and a trim this introduced. They are being fixed on top rather than left.

Done in `bfd35d8`. Every streaming route now hands the encoder the path the request arrived on, and the inference is upstream's: the part after the path's last dot, or the whole path where it has none, mapped by `InferAudioCodec` — with the fallthrough lowercased, which was the second half of what this asked to settle. The answer to the first half is that the port carries it. A request for `stream.ts` naming no audio codec is encoded to mp3 rather than aac, which is the shape a real client can land on; the odd values the rule produces for a path with no extension are upstream's own and cost nothing, because a client that names its codec — all of them do — never reaches them. The master playlist's query rewriting moved to one place while this was being done: the codec rewrite already lived in the playlist itself, and the two parameters the universal audio route settles now sit beside it, where upstream does all three. A value that has never been through a URL is escaped as it goes into one. ## Measured Both servers on the anchor, five playlist shapes, byte for byte: ``` before /Videos/{id}/master.m3u8?…&videoCodec=h264&segmentContainer=ts (no audioCodec) upstream …&AudioCodec=m3u8 261 bytes, CODECS="avc1.640029" ours …&AudioCodec=aac 270 bytes, CODECS="avc1.640029,mp4a.40.2" after identical, 261 bytes before /Audio/{id}/universal?container=mp3&transcodingProtocol=hls (no audioCodec) upstream …&AudioCodec=%2Faudio%2F…%2Funiversal&SegmentContainer=ts&… ours …&SegmentContainer=ts&…&AudioCodec=aac after identical ``` and the three shapes that already agreed still do. A review of the commit found four parity gaps in the surrounding query rewriting, three of them older than this change: the rotation refusal upstream appends for a copied, rotated video cut into ts; the renormalisation upstream's rebuild applies to every pair (`+` for a space, lowercase hex, a repeated key); and a trim this introduced. They are being fixed on top rather than left.
Sign in to join this conversation.
No description provided.