fix(playback): stop pinning the video stream as the audio track (DR-140)

Jellyfin's MediaStream.Index is global across every stream in a media
source, so index 0 is the video stream on virtually all files. We sent
AudioStreamIndex=0 as "the first audio track" on the HLS transcode URL,
the background audio-only handoff URL, the direct-play fallback URL and
the PlaybackInfo negotiation body — asking the server to use the video
stream as audio. Servers that honour it produce a picture with no sound;
only those that silently correct the index hid the bug, which is why it
surfaced as "some videos have no audio".

Omit the parameter unless a track was actually chosen, so the server
resolves the source's DefaultAudioStreamIndex. An explicit selection from
player_switch_audio_track still passes through unchanged. Dropped
outright from the static=true direct-play URL, which serves the original
file untouched.
This commit is contained in:
2026-08-09 14:47:03 +02:00
parent 1ef6180776
commit db520c6551
2 changed files with 45 additions and 16 deletions
+43 -16
View File
@@ -382,6 +382,8 @@ impl OnlineRepository {
/// forces the server to transcode the whole file before playback can begin —
/// which manifests as playback never starting. `StartTimeTicks` makes the
/// server begin the transcode at the requested position.
///
/// TRACES: UR-004 | DR-140 | UT-130
pub async fn get_video_stream_url(
&self,
item_id: &str,
@@ -392,9 +394,6 @@ impl OnlineRepository {
// Convert seconds to ticks (10,000,000 ticks per second)
let start_time_ticks = start_time_seconds.map(|seconds| (seconds * 10_000_000.0) as i64);
// Use provided audio stream index, or default to 0
let audio_index = audio_stream_index.unwrap_or(0).to_string();
// Build an HLS transcode URL. VideoCodec lists h264 first so the server
// transcodes HEVC/10-bit/unsupported sources to h264 the WebView can decode.
let mut params = vec![
@@ -402,7 +401,6 @@ impl OnlineRepository {
("DeviceId", "jellytau-tauri".to_string()),
("VideoCodec", "h264".to_string()),
("AudioCodec", "aac".to_string()),
("AudioStreamIndex", audio_index),
("MaxStreamingBitrate", "20000000".to_string()),
("VideoBitrate", "18000000".to_string()),
("AudioBitrate", "384000".to_string()),
@@ -412,6 +410,16 @@ impl OnlineRepository {
("TranscodingProtocol", "hls".to_string()),
];
// Only pin an audio track when the user actually picked one. Jellyfin's
// `MediaStream.Index` is global across *all* streams in a media source, so
// index 0 is the video stream on virtually every file — defaulting to 0
// asks the server to transcode the video stream as the audio track, which
// yields a picture with no sound. Omitting the param lets the server use
// the source's `DefaultAudioStreamIndex`.
if let Some(index) = audio_stream_index {
params.push(("AudioStreamIndex", index.to_string()));
}
if let Some(source_id) = media_source_id {
params.push(("MediaSourceId", source_id.to_string()));
}
@@ -438,7 +446,7 @@ impl OnlineRepository {
/// Get an **audio-only** stream URL for a *video* item, for the
/// background-audio handoff (UR-040).
///
/// TRACES: UR-040 | JA-032 | UT-059
/// TRACES: UR-040 | JA-032, DR-140 | UT-059, UT-130
///
/// This deliberately targets `/Audio/{id}/universal`, NOT the video stream:
/// the server extracts/transcodes only the item's audio track and streams
@@ -463,13 +471,10 @@ impl OnlineRepository {
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
let audio_index = audio_stream_index.unwrap_or(0).to_string();
let mut params = vec![
("UserId", self.user_id.clone()),
("api_key", self.access_token.clone()),
("DeviceId", "jellytau-tauri".to_string()),
("AudioStreamIndex", audio_index),
// Progressive mp3 over HTTP — ExoPlayer-friendly; no HLS/ts.
("Container", "mp3".to_string()),
("AudioCodec", "mp3".to_string()),
@@ -478,6 +483,12 @@ impl OnlineRepository {
("MaxStreamingBitrate", "384000".to_string()),
];
// Carry the track over only if one was actually selected — index 0 is the
// video stream, not "the first audio track" (see `get_video_stream_url`).
if let Some(index) = audio_stream_index {
params.push(("AudioStreamIndex", index.to_string()));
}
if let Some(source_id) = media_source_id {
params.push(("MediaSourceId", source_id.to_string()));
}
@@ -1239,7 +1250,11 @@ impl MediaRepository for OnlineRepository {
#[serde(rename_all = "PascalCase")]
struct PlaybackInfoRequest {
user_id: String,
audio_stream_index: i32,
/// Omitted so the server resolves the source's default audio stream.
/// Never send 0 here: the index is global across all streams, so 0 is
/// the video stream and the negotiated source comes back soundless.
#[serde(skip_serializing_if = "Option::is_none")]
audio_stream_index: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
subtitle_stream_index: Option<i32>,
start_time_ticks: i64,
@@ -1399,7 +1414,7 @@ impl MediaRepository for OnlineRepository {
// POST to PlaybackInfo with device profile containing detected codecs
let request_body = PlaybackInfoRequest {
user_id: self.user_id.clone(),
audio_stream_index: 0, // Request first audio stream
audio_stream_index: None, // Let the server pick the source default
subtitle_stream_index: None,
start_time_ticks: 0,
is_playback: true,
@@ -1430,9 +1445,11 @@ impl MediaRepository for OnlineRepository {
let stream_url = if let Some(transcoding_url) = &source.transcoding_url {
format!("{}{}", self.server_url, transcoding_url)
} else {
// Fall back to direct stream URL
// Fall back to direct stream URL. No audioStreamIndex: static=true
// serves the original file untouched, and pinning index 0 (the video
// stream) only misleads servers that do honour it.
format!(
"{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&api_key={}&audioStreamIndex=0&userId={}",
"{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&api_key={}&userId={}",
self.server_url,
item_id,
source.id,
@@ -2267,8 +2284,14 @@ mod tests {
assert!(url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"));
assert!(!url.contains("StartTimeTicks"));
assert!(!url.contains("MediaSourceId"));
// Defaults to first audio stream
assert!(url.contains("AudioStreamIndex=0"));
// With no track chosen, the param must be OMITTED so the server picks the
// source's DefaultAudioStreamIndex. `MediaStream.Index` is global across
// all streams of a source, so index 0 is the *video* stream on virtually
// every file — sending it asks for a "audio track" that has no audio.
assert!(
!url.contains("AudioStreamIndex"),
"must not pin an audio index when none was chosen: {url}"
);
}
#[tokio::test]
@@ -2325,8 +2348,12 @@ mod tests {
assert!(url.starts_with("https://test.server.com/Audio/vid-1/universal?"));
assert!(!url.contains("StartTimeTicks"));
assert!(!url.contains("MediaSourceId"));
// Defaults to first audio stream.
assert!(url.contains("AudioStreamIndex=0"));
// Same as the video path: omit rather than pin index 0 (the video stream),
// and let the server fall back to the source's default audio stream.
assert!(
!url.contains("AudioStreamIndex"),
"must not pin an audio index when none was chosen: {url}"
);
}
#[tokio::test]