fix(player): strip the burn-in the server puts back into its own transcode URL

The negotiation asks for no subtitle stream (DR-176), but when PlaybackInfo
answers with a TranscodingUrl we played that URL verbatim — and the server
built it from its own subtitle verdict. Jellyfin's StreamInfo.ToUrl appends
SubtitleStreamIndex and SubtitleMethod whenever it picked a track, so the
burn-in we had just declined came straight back through the URL, turning a
remux into a full frame-by-frame re-encode.

Live TV never declined it at all: open_live_stream sent no index, so the
server applied the channel's default track, and broadcast subtitles are DVB
bitmaps that NormalizeSubtitleEmbed converts to burn-in on sight.

without_server_chosen_subtitle() drops SubtitleStreamIndex, SubtitleMethod,
SubtitleCodec and alwaysBurnInSubtitleWhenTranscoding from any URL the server
built — matched case-insensitively, as Jellyfin binds query keys — and
re-appends the -1 sentinel, because an absent index is not "none", it is
"you choose". Applied at both adoption points, plus the sentinel in the
live-stream negotiation body and its fallback URL.
This commit is contained in:
2026-08-16 14:56:40 +02:00
parent d9e1e256e9
commit f0f98feae8
2 changed files with 151 additions and 3 deletions
+124
View File
@@ -122,6 +122,55 @@ pub fn playback_subtitle_stream_index() -> i32 {
NO_SUBTITLE_STREAM
}
/// Query keys through which a stream URL can carry a subtitle decision.
///
/// Jellyfin binds query keys case-insensitively, so the match has to be too —
/// the server itself mixes casing (`SubtitleStreamIndex` but
/// `alwaysBurnInSubtitleWhenTranscoding`).
const SUBTITLE_QUERY_KEYS: &[&str] = &[
"subtitlestreamindex",
"subtitlemethod",
"subtitlecodec",
"alwaysburninsubtitlewhentranscoding",
];
/// Rewrite a stream URL so it asks for no subtitle, whoever built it.
///
/// [`playback_subtitle_stream_index`] only governs the URLs *this app* builds.
/// When `PlaybackInfo` answers with a `TranscodingUrl`, the URL was built by the
/// server from its own subtitle verdict, and we play it verbatim — so a server
/// that picked a track anyway (a live channel opened without an index, a source
/// whose default is image-based) hands us `SubtitleMethod=Encode`, and the
/// burn-in the negotiation just declined comes back through the URL. Burn-in is
/// a *video* cost: it rules out remuxing and forces a full re-encode.
///
/// Stripping the keys is not enough on its own — an absent index is not "none",
/// it is "you choose" — so the sentinel is always appended.
///
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
pub fn without_server_chosen_subtitle(url: &str) -> String {
let (path, query) = match url.split_once('?') {
Some((path, query)) => (path, query),
None => (url, ""),
};
let mut kept: Vec<&str> = query
.split('&')
.filter(|param| !param.is_empty())
.filter(|param| {
let key = param.split_once('=').map_or(*param, |(key, _)| key);
!SUBTITLE_QUERY_KEYS
.iter()
.any(|subtitle_key| key.eq_ignore_ascii_case(subtitle_key))
})
.collect();
let sentinel = format!("SubtitleStreamIndex={}", NO_SUBTITLE_STREAM);
kept.push(&sentinel);
format!("{}?{}", path, kept.join("&"))
}
/// Whether a subtitle in this format can reach the app as a sidecar it draws
/// itself — the same verdict as [`subtitle_forces_burn_in`], from the reader's
/// side, and the one a subtitle picker needs.
@@ -249,6 +298,81 @@ mod tests {
assert_eq!(playback_subtitle_stream_index(), -1);
}
/// A transcode URL the *server* built carries the server's own subtitle
/// verdict. Adopting it verbatim re-introduces the burn-in
/// [`playback_subtitle_stream_index`] exists to prevent — the negotiation
/// asks for no subtitle, and then we play a URL that asks for one anyway.
///
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
#[test]
fn a_server_built_transcode_url_has_its_burn_in_stripped() {
// Shape taken from Jellyfin's `StreamInfo.ToUrl`: it appends
// `SubtitleStreamIndex` and `SubtitleMethod` whenever it picked a track.
let served = "/videos/abc/master.m3u8?DeviceId=jt&MediaSourceId=src1\
&VideoCodec=h264&SubtitleMethod=Encode&SubtitleStreamIndex=2\
&PlaySessionId=xyz";
let url = without_server_chosen_subtitle(served);
assert!(
url.contains("SubtitleStreamIndex=-1"),
"the adopted URL must ask for no subtitle: {url}"
);
assert!(
!url.contains("SubtitleStreamIndex=2"),
"the server's chosen track must not survive: {url}"
);
assert!(
!url.contains("SubtitleMethod"),
"burn-in must not be requested: {url}"
);
// Everything else identifies the job and must survive untouched.
for kept in [
"DeviceId=jt",
"MediaSourceId=src1",
"VideoCodec=h264",
"PlaySessionId=xyz",
] {
assert!(url.contains(kept), "{kept} must survive: {url}");
}
}
/// The server may also be told to burn in unconditionally
/// (`alwaysBurnInSubtitleWhenTranscoding`), which is appended to the URL
/// rather than expressed as a method — and its keys are not PascalCase.
///
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
#[test]
fn an_unconditional_burn_in_flag_is_stripped_whatever_its_casing() {
let url = without_server_chosen_subtitle(
"/videos/abc/master.m3u8?api_key=k&alwaysBurnInSubtitleWhenTranscoding=true\
&subtitlestreamindex=3&SubtitleCodec=ass",
);
assert!(!url.to_lowercase().contains("alwaysburnin"), "{url}");
assert!(!url.to_lowercase().contains("subtitlecodec"), "{url}");
assert!(!url.contains("subtitlestreamindex=3"), "{url}");
assert!(url.contains("SubtitleStreamIndex=-1"), "{url}");
assert!(url.contains("api_key=k"), "{url}");
}
/// A URL the server built without any subtitle in it still has to *say* so:
/// omitting the index is what makes the server apply the source's default.
///
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
#[test]
fn a_url_with_no_subtitle_params_is_still_made_to_ask_for_none() {
let url = without_server_chosen_subtitle("/videos/abc/master.m3u8?api_key=k");
assert_eq!(
url,
"/videos/abc/master.m3u8?api_key=k&SubtitleStreamIndex=-1"
);
// A bare URL is rare but must not come out malformed.
let bare = without_server_chosen_subtitle("/videos/abc/master.m3u8");
assert_eq!(bare, "/videos/abc/master.m3u8?SubtitleStreamIndex=-1");
}
/// TRACES: UR-020 | DR-176 | UT-168
#[test]
fn text_subtitles_are_advertised_as_external_sidecars() {