fix(playback): force a transcode when the webview cannot decode the audio (DR-149, 0.4.8)
Advertising a webview-shaped profile (DR-148) was necessary but not sufficient. Probing the server directly showed Jellyfin 10.11.5 enforces a DirectPlayProfile's Container and VideoCodec — excluding either returns SupportsDirectPlay:false with TranscodeReasons=ContainerNotSupported / VideoCodecNotSupported — but ignores its AudioCodec entirely: an E-AC-3 track is still offered for direct play against a profile listing only aac,flac,mp3,opus,vorbis. Neither a VideoAudio CodecProfile forbidding the codec nor MaxAudioChannels:2 against a 6-channel track changes the answer, so no profile the client can send fixes this and the picture plays silent. The client therefore stops delegating a question it can answer itself. The negotiated source's audio is checked against what the webview decodes, and an undecodable track forces the existing h264/aac HLS transcode regardless of the server calling direct play fine; direct_play and needs_transcoding are corrected to match so the frontend and the reporting path agree with the URL actually used. The track judged is the one that would be served — the default, else the first — since a supported track further down is not the one that plays. A source with no audio, or a codec the server did not name, is left alone rather than transcoded on a guess. Test-first: the new tests failed against the old behaviour before the decision existed. Verified on a motorola edge 30 by the audio HAL, not by ear — the same E-AC-3 episode logged isMusicActive=true once and 58 ACDB-LOADER lines under this build, against 0 and 0 on 0.4.6, where an AAC file in the same session produced 16 and 116. No FATAL EXCEPTION, so R8 on the signed release build is unaffected. Also carries in-flight subtitle-track work authored in a parallel session (subtitleTracks, VideoPlayer, player/media, bindings) at the user's request, so the tag matches the APK verified on device.
This commit is contained in:
@@ -6,6 +6,20 @@ Entries are grouped by the capability they change, not by commit. Requirement
|
|||||||
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
|
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
|
||||||
generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
||||||
|
|
||||||
|
## v0.4.8
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **Video with an undecodable soundtrack now transcodes instead of playing
|
||||||
|
silent.** Advertising a webview-shaped profile (v0.4.7) turned out not to be
|
||||||
|
enough: Jellyfin 10.11.5 enforces a direct-play profile's container and video
|
||||||
|
codec but ignores its audio codec, offering an E-AC-3 track for direct play
|
||||||
|
against a profile listing only AAC — and no `CodecProfile` or channel limit
|
||||||
|
changes that. The client now checks the track it would actually be served
|
||||||
|
against what its renderer can decode and forces the h264/AAC HLS transcode
|
||||||
|
when it cannot, rather than trusting the negotiation.
|
||||||
|
(UR-004 → DR-149)
|
||||||
|
|
||||||
## v0.4.7
|
## v0.4.7
|
||||||
|
|
||||||
### 🐛 Fixes
|
### 🐛 Fixes
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jellytau",
|
"name": "jellytau",
|
||||||
"version": "0.4.7",
|
"version": "0.4.8",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
|
|||||||
@@ -175,8 +175,8 @@ describe("live requirements.md", () => {
|
|||||||
|
|
||||||
expect(defined.UR).toBe(71);
|
expect(defined.UR).toBe(71);
|
||||||
expect(defined.IR).toBe(32);
|
expect(defined.IR).toBe(32);
|
||||||
expect(defined.DR).toBe(143);
|
expect(defined.DR).toBe(144);
|
||||||
expect(defined.JA).toBe(35);
|
expect(defined.JA).toBe(35);
|
||||||
expect(defined.total).toBe(281);
|
expect(defined.total).toBe(282);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+1
-1
@@ -2018,7 +2018,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.4.7"
|
version = "0.4.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.4.7"
|
version = "0.4.8"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -100,10 +100,99 @@ pub fn video_audio_codecs(detected: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the webview `<video>` element can decode this audio codec.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-004 | DR-149 | UT-143
|
||||||
|
pub fn webview_can_decode_audio(codec: &str) -> bool {
|
||||||
|
WEBVIEW_AUDIO_CODECS
|
||||||
|
.iter()
|
||||||
|
.any(|supported| supported.eq_ignore_ascii_case(codec.trim()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide whether we must transcode *regardless of what the server negotiated*,
|
||||||
|
/// given the source's audio streams as `(codec, is_default)` in source order.
|
||||||
|
///
|
||||||
|
/// Advertising a narrow profile ([`video_audio_codecs`]) is necessary but not
|
||||||
|
/// sufficient: Jellyfin 10.11.5 enforces a `DirectPlayProfile`'s container and
|
||||||
|
/// video codec but **ignores its audio codec** — an E-AC-3 track is offered for
|
||||||
|
/// direct play even when the profile lists only AAC, and neither a `VideoAudio`
|
||||||
|
/// `CodecProfile` nor `MaxAudioChannels` changes that. So the client cannot
|
||||||
|
/// delegate this decision; it knows what its own renderer can decode and must
|
||||||
|
/// apply that itself.
|
||||||
|
///
|
||||||
|
/// The track that matters is the one the server will actually serve: the
|
||||||
|
/// default, or the first when none is marked. An unknown codec is left alone —
|
||||||
|
/// forcing a transcode on a guess would burn server CPU for files that play.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-004 | DR-149 | UT-143
|
||||||
|
pub fn audio_forces_transcode(streams: &[(Option<&str>, bool)]) -> bool {
|
||||||
|
let served = streams
|
||||||
|
.iter()
|
||||||
|
.find(|(_, is_default)| *is_default)
|
||||||
|
.or_else(|| streams.first());
|
||||||
|
|
||||||
|
match served {
|
||||||
|
Some((Some(codec), _)) => !webview_can_decode_audio(codec),
|
||||||
|
// No audio at all, or a codec the server did not name: leave it alone.
|
||||||
|
Some((None, _)) | None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_undecodable_default_track_forces_a_transcode() {
|
||||||
|
// The reported bug: one E-AC-3 track, which the webview cannot decode.
|
||||||
|
assert!(audio_forces_transcode(&[(Some("eac3"), false)]));
|
||||||
|
assert!(audio_forces_transcode(&[(Some("ac3"), true)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_decodable_track_is_left_to_direct_play() {
|
||||||
|
// Never spend server CPU on a file that already plays.
|
||||||
|
assert!(!audio_forces_transcode(&[(Some("aac"), true)]));
|
||||||
|
assert!(!audio_forces_transcode(&[(Some("mp3"), false)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_default_track_decides_not_the_first() {
|
||||||
|
// The webview plays the default track, so that is the one that has to be
|
||||||
|
// decodable — a supported track further down does not save us.
|
||||||
|
assert!(audio_forces_transcode(&[
|
||||||
|
(Some("aac"), false),
|
||||||
|
(Some("eac3"), true)
|
||||||
|
]));
|
||||||
|
assert!(!audio_forces_transcode(&[
|
||||||
|
(Some("eac3"), false),
|
||||||
|
(Some("aac"), true)
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_no_default_marked_the_first_track_decides() {
|
||||||
|
// Jellyfin leaves IsDefault false on every stream for some files; the
|
||||||
|
// server then serves the first, so judge that one.
|
||||||
|
assert!(audio_forces_transcode(&[
|
||||||
|
(Some("eac3"), false),
|
||||||
|
(Some("aac"), false)
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_source_with_no_audio_is_not_transcoded() {
|
||||||
|
// Nothing to rescue, and a transcode would not create audio.
|
||||||
|
assert!(!audio_forces_transcode(&[]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_codec_is_not_second_guessed() {
|
||||||
|
// The server did not tell us the codec; assuming the worst would
|
||||||
|
// transcode files that play perfectly.
|
||||||
|
assert!(!audio_forces_transcode(&[(None, true)]));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_dolby_device_does_not_advertise_dolby_for_video() {
|
fn a_dolby_device_does_not_advertise_dolby_for_video() {
|
||||||
// The bug: a Motorola reporting c2.dolby.eac3.decoder direct-played
|
// The bug: a Motorola reporting c2.dolby.eac3.decoder direct-played
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
//! TRACES: UR-002, UR-007 | DR-013 | IR-010
|
//! TRACES: UR-002, UR-007 | DR-013 | IR-010
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
#[cfg(target_os = "android")]
|
use log::{debug, error, info, warn};
|
||||||
use log::warn;
|
|
||||||
use log::{debug, error, info};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -1342,6 +1340,9 @@ impl MediaRepository for OnlineRepository {
|
|||||||
index: i32,
|
index: i32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
codec: Option<String>,
|
codec: Option<String>,
|
||||||
|
/// The track the server serves when the client pins none.
|
||||||
|
#[serde(default)]
|
||||||
|
is_default: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get detected codecs from Android MediaCodecList or use platform defaults
|
// Get detected codecs from Android MediaCodecList or use platform defaults
|
||||||
@@ -1475,9 +1476,29 @@ impl MediaRepository for OnlineRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Jellyfin 10.11.5 honours a DirectPlayProfile's container and video codec
|
||||||
|
// but ignores its audio codec, so it offers an E-AC-3 track for direct
|
||||||
|
// play even though DR-148 advertises only AAC — and the webview renders
|
||||||
|
// the picture in silence. Judge the track we would actually be served
|
||||||
|
// against what the webview can decode, and override the server's answer.
|
||||||
|
let audio_streams: Vec<(Option<&str>, bool)> = source
|
||||||
|
.media_streams
|
||||||
|
.iter()
|
||||||
|
.filter(|stream| stream.stream_type == "Audio")
|
||||||
|
.map(|stream| (stream.codec.as_deref(), stream.is_default))
|
||||||
|
.collect();
|
||||||
|
let audio_forces_transcode = super::device_profile::audio_forces_transcode(&audio_streams);
|
||||||
|
|
||||||
// Use TranscodingUrl from response if available (Streamyfin pattern)
|
// Use TranscodingUrl from response if available (Streamyfin pattern)
|
||||||
let stream_url = if let Some(transcoding_url) = &source.transcoding_url {
|
let stream_url = if let Some(transcoding_url) = &source.transcoding_url {
|
||||||
format!("{}{}", self.server_url, transcoding_url)
|
format!("{}{}", self.server_url, transcoding_url)
|
||||||
|
} else if audio_forces_transcode {
|
||||||
|
warn!(
|
||||||
|
"[PlaybackInfo] Server offered direct play for audio the webview cannot decode ({:?}) — forcing an HLS transcode",
|
||||||
|
audio_streams.first().and_then(|(codec, _)| *codec)
|
||||||
|
);
|
||||||
|
self.get_video_stream_url(item_id, Some(&source.id), None, None)
|
||||||
|
.await?
|
||||||
} else {
|
} else {
|
||||||
// Fall back to direct stream URL. No audioStreamIndex: static=true
|
// Fall back to direct stream URL. No audioStreamIndex: static=true
|
||||||
// serves the original file untouched, and pinning index 0 (the video
|
// serves the original file untouched, and pinning index 0 (the video
|
||||||
@@ -1498,8 +1519,9 @@ impl MediaRepository for OnlineRepository {
|
|||||||
media_source_id: source.id.clone(),
|
media_source_id: source.id.clone(),
|
||||||
play_session_id: response.play_session_id,
|
play_session_id: response.play_session_id,
|
||||||
stream_url,
|
stream_url,
|
||||||
direct_play: source.supports_direct_play,
|
direct_play: source.supports_direct_play && !audio_forces_transcode,
|
||||||
needs_transcoding: !source.supports_direct_play && source.supports_transcoding,
|
needs_transcoding: audio_forces_transcode
|
||||||
|
|| (!source.supports_direct_play && source.supports_transcoding),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "jellytau",
|
"productName": "jellytau",
|
||||||
"version": "0.4.7",
|
"version": "0.4.8",
|
||||||
"identifier": "com.dtourolle.jellytau",
|
"identifier": "com.dtourolle.jellytau",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user