fix(playback): ask the renderer what it can decode, in one place

Four bugs, one cause. "What can this device decode" was answered in five
places, four of which assumed the webview was decoding:

  - the device profile's direct-play codecs      (cfg per platform, inline)
  - the transcoding targets                       (hardcoded "h264,hevc")
  - the direct-play audio narrowing               (webview list, all platforms)
  - the client-side audio override                (webview list, all platforms)
  - get_video_stream_url's VideoCodec             (hardcoded "h264")

On Android the decoder is ExoPlayer, so four of those were simply wrong there,
and the costs were invisible without a device:

  - dts is in the tablet's own codec list, gets stripped from the profile, and
    is then forced to transcode by a rule about a renderer that is not playing
    it.
  - An hevc source whose *audio* is eac3 had its **picture fully re-encoded**.
    The server's own transcoding URL got this right — VideoCodec=h264,hevc,
    TranscodeReasons=AudioCodecNotSupported, video copied — but the moment a
    quality change or track switch re-opened the stream through our builder,
    the hardcoded h264 turned a cheap audio remux into a full transcode. That
    is a quality change silently making playback more expensive, on the exact
    path a viewer uses when playback is already struggling.

`renderer_codecs()` and `renderer_can_decode_audio()` are now the single
source, and all five sites read them. On the webview path every value resolves
exactly as before, so desktop behaviour is unchanged by construction; on
Android the profile becomes the device's own.

The list is also what lets the server *copy* rather than re-encode: naming
every codec the renderer can decode is what turns a transcode into a
passthrough when the source is already playable. That is the whole of "use the
best format available".

Also corrects this branch's headline number where it is asserted — the
architecture doc, the desktop-native-video spec and the spike. The measured 85%
Android direct-play rate used a profile containing ac3/eac3; the device it was
later verified on reports neither, so eac3 content correctly transcodes there.
It is a ceiling for an ExoPlayer-appropriate profile, not what the app achieves,
and realising any of it depends on this change. Left in place with the caveat
rather than deleted, because the measurement is real — it just measures
something narrower than it was quoted as measuring.

Unverified: this changes what Android negotiates and has not been exercised on
the tablet yet. Desktop is unchanged by construction but also unre-tested.
This commit is contained in:
2026-08-22 13:45:03 +02:00
parent 4f6cf22419
commit 156b9e3684
6 changed files with 156 additions and 50 deletions
+33 -39
View File
@@ -611,13 +611,29 @@ impl OnlineRepository {
self.stop_transcode(&previous).await;
}
// Build an HLS transcode URL. VideoCodec lists h264 first so the server
// transcodes HEVC/10-bit/unsupported sources to h264 the WebView can decode.
// Build an HLS transcode URL, naming every codec this renderer can
// decode rather than only h264.
//
// The list is what lets the server *copy* the video stream instead of
// re-encoding it. Hardcoding h264 meant an hevc source whose only
// problem was its audio — eac3 on a device with no Dolby licence — got
// its picture fully re-encoded to satisfy a sound problem. The server's
// own transcoding URL already did the right thing (`h264,hevc`, video
// copied, `TranscodeReasons=AudioCodecNotSupported`); this builder,
// which takes over whenever a quality change or track switch re-opens
// the stream, quietly did not — so changing quality turned a cheap
// remux into a full transcode.
//
// On the webview path this still resolves to "h264" alone, so nothing
// changes there.
//
// TRACES: UR-004, UR-080 | DR-233
let (renderer_video_codecs, _) = super::device_profile::renderer_codecs();
let mut params = vec![
("api_key", self.access_token.clone()),
("DeviceId", DEVICE_ID.to_string()),
("PlaySessionId", play_session_id),
("VideoCodec", "h264".to_string()),
("VideoCodec", renderer_video_codecs),
("AudioCodec", "aac".to_string()),
("MaxStreamingBitrate", max_bitrate.to_string()),
("VideoBitrate", video_bitrate.to_string()),
@@ -769,30 +785,11 @@ impl OnlineRepository {
) -> Result<(NegotiatedSource, String), RepoError> {
let endpoint = format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id));
// Get detected codecs from Android MediaCodecList or use platform defaults
#[cfg(target_os = "android")]
let (video_codecs, audio_codecs) = crate::player::get_detected_codecs()
.map(|(video, audio, _channels)| (video, audio))
.unwrap_or_else(|| {
warn!("[DeviceProfile] Codec detection not complete, using conservative defaults");
("h264,hevc".to_string(), "aac,mp3".to_string())
});
// Linux desktop plays video through the WebKitGTK HTML5 <video> element,
// which cannot reliably decode HEVC/AV1/VP9. Advertise only codecs the
// WebView can decode so Jellyfin transcodes anything else to h264 HLS.
// (Audio-only files still direct-play via MPV; these codecs are what
// both renderers handle, and the audio profile keeps them in full while
// the video profile is narrowed below.)
#[cfg(all(not(target_os = "android"), target_os = "linux"))]
let (video_codecs, audio_codecs) =
("h264".to_string(), "aac,mp3,opus,vorbis,flac".to_string());
#[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
let (video_codecs, audio_codecs) = (
"h264,hevc,vp8,vp9,av1,mpeg4".to_string(),
"aac,mp3,opus,vorbis,flac".to_string(),
);
// What the renderer that will decode this can play. One source, shared
// with the transcode URL builder and the client-side audio override, so
// the profile we advertise and the stream we then ask for cannot
// disagree. TRACES: UR-004, UR-080 | DR-233
let (video_codecs, audio_codecs) = super::device_profile::renderer_codecs();
// Video plays in a webview <video> element on every platform, which
// decodes a narrower audio set than the platform does — so the video
@@ -864,19 +861,16 @@ impl OnlineRepository {
container: "ts".to_string(),
// The server may only transcode *to* something this renderer
// can decode. This said "h264,hevc" unconditionally while the
// direct-play profile above claims h264 alone on the webview
// path — a straight contradiction: it tells the server "I
// cannot play hevc, so re-encode it" and then "re-encoding it
// to hevc is fine". When the server took that option the
// webview got a stream it could not decode, which presents as
// video stuck on its first frame rather than as an error.
// direct-play profile claims h264 alone on the webview path —
// a straight contradiction: it tells the server "I cannot
// play hevc, so re-encode it" and then "re-encoding it to
// hevc is fine". When the server took that option the webview
// got a stream it could not decode, which presents as video
// stuck on its first frame rather than as an error.
//
// Derived from the same codec list as direct play, so the two
// halves of the profile cannot disagree again. Capped at the
// two codecs a Jellyfin server actually encodes, so widening
// the decode list never asks it for an av1 encode.
//
// TRACES: UR-004, UR-080 | DR-233
// Capped at the two codecs a Jellyfin server actually
// encodes, so a wider decode list never asks it for an av1
// encode. TRACES: UR-004, UR-080 | DR-233
video_codec: Some(
if video_codecs.contains("hevc") {
"h264,hevc"