/Audio/{id}/universal answers 500 for anything that is not a direct play #184

Closed
opened 2026-08-22 21:34:26 +02:00 by Claude · 1 comment
Collaborator

Found running transcoding.json over a live pair for #117.

/Audio/{id}/universal is the route a client asks for a track through, and it
answers 500 for anything the negotiation does not settle as a direct play:

GET /Audio/{flac}/universal?container=mp3&audioCodec=mp3&audioBitRate=128000
  upstream  200 audio/mpeg
  ours      500 text/plain
GET /Audio/{flac}/universal?container=aac&transcodingContainer=aac&audioCodec=aac&audioBitRate=128000
  upstream  200
  ours      500

crates/ephyra-http/src/routes/streaming/audio.rs:92-98 says so outright:

if verdict.play_method != Some(PlayMethod::DirectPlay) {
    // Upstream builds an HLS playlist or starts a progressive transcode
    // here; neither is written.
    return Err(ApiError::Internal(
        "transcoding is not implemented; ask for a container this file already is".to_owned(),
    ));
}

So a library holding anything a client cannot take as it is — a flac to a
browser wanting mp3 is the ordinary case — answers 500 where upstream serves
audio.

The encoder path it needs is already there. videos.rs:503-560 builds the
encoding request, calls start_or_join, and serves the growing file; the same
call from this route is most of the progressive half. Upstream's other branch,
TranscodingSubProtocol == hls, answers with a playlist and is the rest.

Where to start

crates/ephyra-http/src/routes/streaming/audio.rs:92, and
crates/ephyra-http/src/routes/streaming/videos.rs:503-560 for the shape the
progressive answer takes. Upstream's UniversalAudioController decides between
the two branches on the profile the negotiation settled.

Worth knowing before writing the branch: upstream builds one transcoding
profile with Container = transcodingContainer ?? "mp3"
(upstream/Jellyfin.Api/Controllers/UniversalAudioController.cs:293-303), so
a request naming container=aac and no transcodingContainer gets mp3 anyway
— and upstream then muxes AAC with the MP3 muxer and dies with its own 500.
Matching that is matching a bug, but it is what the pinned server does.

How to know it is done

transcoding.json's two audio requests answer with audio on both servers and
agree, which is measurable rather than assertable: they are two of the six the
#117 gate drops today.

House rules

Tests run under cargo nextest; upstream's source is the checkout in
upstream/ at the pin. Generated artifacts are regenerated, never edited.

Found running `transcoding.json` over a live pair for #117. `/Audio/{id}/universal` is the route a client asks for a track through, and it answers `500` for anything the negotiation does not settle as a direct play: ``` GET /Audio/{flac}/universal?container=mp3&audioCodec=mp3&audioBitRate=128000 upstream 200 audio/mpeg ours 500 text/plain GET /Audio/{flac}/universal?container=aac&transcodingContainer=aac&audioCodec=aac&audioBitRate=128000 upstream 200 ours 500 ``` `crates/ephyra-http/src/routes/streaming/audio.rs:92-98` says so outright: ```rust if verdict.play_method != Some(PlayMethod::DirectPlay) { // Upstream builds an HLS playlist or starts a progressive transcode // here; neither is written. return Err(ApiError::Internal( "transcoding is not implemented; ask for a container this file already is".to_owned(), )); } ``` So a library holding anything a client cannot take as it is — a flac to a browser wanting mp3 is the ordinary case — answers `500` where upstream serves audio. **The encoder path it needs is already there.** `videos.rs:503-560` builds the encoding request, calls `start_or_join`, and serves the growing file; the same call from this route is most of the progressive half. Upstream's other branch, `TranscodingSubProtocol == hls`, answers with a playlist and is the rest. ## Where to start `crates/ephyra-http/src/routes/streaming/audio.rs:92`, and `crates/ephyra-http/src/routes/streaming/videos.rs:503-560` for the shape the progressive answer takes. Upstream's `UniversalAudioController` decides between the two branches on the profile the negotiation settled. Worth knowing before writing the branch: upstream builds **one** transcoding profile with `Container = transcodingContainer ?? "mp3"` (`upstream/Jellyfin.Api/Controllers/UniversalAudioController.cs:293-303`), so a request naming `container=aac` and no `transcodingContainer` gets mp3 anyway — and upstream then muxes AAC with the MP3 muxer and dies with its own `500`. Matching that is matching a bug, but it is what the pinned server does. ## How to know it is done `transcoding.json`'s two audio requests answer with audio on both servers and agree, which is measurable rather than assertable: they are two of the six the `#117` gate drops today. ### House rules Tests run under `cargo nextest`; upstream's source is the checkout in `upstream/` **at the pin**. Generated artifacts are regenerated, never edited.
Author
Collaborator

Fixed in 4ee1184, with the review's findings folded in as 92c3f94.

The route now hands the request to the stream routes the way upstream hands it
to the same helper: static where the verdict was direct play, and into the
transcoding container the client named otherwise.

Measured. The transcoding workload gates every request against both
servers before timing anything. Before:

dropped GET /Audio/ac63.../universal?container=mp3&audioCodec=mp3&audioBitRate=128000
  — upstream 200 ours 500
    @content-type: upstream "audio/mpeg", ours "text/plain"
    @status: upstream 200, ours 500

After, the request is no longer dropped — it is one of the two the gate now
agrees on, with nothing unanswered by either server.

A review against UniversalAudioController.cs turned up three things worth
correcting, all in 92c3f94:

  • The audio routes were building a video command line for a video item —
    -codec:v:0 copy into an .mp3. Upstream's audio helper is audio-only
    whatever the item is, and the route decides that, not the row. This was
    already true of /Audio/{id}/stream; it is now right for both.
  • maxAudioBitDepth was not bound at all, so the audio stream-copy decision
    was made without it — a 24-bit source against maxAudioBitDepth=16 could be
    copied where upstream re-encodes.
  • transcodingAudioChannels and enableAudioVbrEncoding were being passed to
    the encoder; upstream's progressive branch carries neither.

Two things the review found are real but out of this issue's scope and are
filed separately: transcodingProtocol=hls (#186) and the audio negotiation
never answering DirectStream (#187).

Fixed in `4ee1184`, with the review's findings folded in as `92c3f94`. The route now hands the request to the stream routes the way upstream hands it to the same helper: `static` where the verdict was direct play, and into the transcoding container the client named otherwise. **Measured.** The `transcoding` workload gates every request against both servers before timing anything. Before: ``` dropped GET /Audio/ac63.../universal?container=mp3&audioCodec=mp3&audioBitRate=128000 — upstream 200 ours 500 @content-type: upstream "audio/mpeg", ours "text/plain" @status: upstream 200, ours 500 ``` After, the request is no longer dropped — it is one of the two the gate now agrees on, with nothing unanswered by either server. A review against `UniversalAudioController.cs` turned up three things worth correcting, all in `92c3f94`: - The audio routes were building a **video** command line for a video item — `-codec:v:0 copy` into an `.mp3`. Upstream's audio helper is audio-only whatever the item is, and the route decides that, not the row. This was already true of `/Audio/{id}/stream`; it is now right for both. - `maxAudioBitDepth` was not bound at all, so the audio stream-copy decision was made without it — a 24-bit source against `maxAudioBitDepth=16` could be copied where upstream re-encodes. - `transcodingAudioChannels` and `enableAudioVbrEncoding` were being passed to the encoder; upstream's progressive branch carries neither. Two things the review found are real but out of this issue's scope and are filed separately: `transcodingProtocol=hls` (#186) and the audio negotiation never answering `DirectStream` (#187).
Sign in to join this conversation.
No description provided.