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.
640 lines
26 KiB
Rust
640 lines
26 KiB
Rust
//! Device-profile policy: turning what a device *reports* about its audio
|
|
//! output into the constraints we send Jellyfin.
|
|
//!
|
|
//! The platform layer reports raw facts (what `MediaCodecList` enumerates, how
|
|
//! many channels the current audio route accepts); deciding what those facts
|
|
//! mean for a `DeviceProfile` is domain logic and lives here, on the Rust side
|
|
//! of the boundary, where it is testable without a device.
|
|
|
|
/// Channel count assumed when the platform cannot tell us — every audio route
|
|
/// can voice stereo, so it is the only safe floor.
|
|
const FALLBACK_AUDIO_CHANNELS: u32 = 2;
|
|
|
|
/// Upper bound we are willing to claim. Jellyfin profiles top out at 7.1, and a
|
|
/// nonsense reading from a driver should not become a nonsense profile.
|
|
const MAX_SUPPORTED_AUDIO_CHANNELS: u32 = 8;
|
|
|
|
/// Decide the `MaxAudioChannels` to advertise, given what the current audio
|
|
/// route reported.
|
|
///
|
|
/// Without this constraint Jellyfin is free to direct-play a 5.1 or 7.1 track to
|
|
/// a sink that only has two channels. What the user hears then is device
|
|
/// dependent and rarely correct — a failed `AudioSink` configuration (silence),
|
|
/// or centre-channel dialogue folded away to near-inaudibility. Naming the real
|
|
/// channel count makes the server downmix instead, which is always audible.
|
|
///
|
|
/// A missing or zero reading means "route not established yet", not "no audio":
|
|
/// fall back to stereo rather than claiming a capability we have not seen.
|
|
///
|
|
/// TRACES: UR-004 | DR-141 | UT-141
|
|
pub fn clamp_max_audio_channels(reported: Option<u32>) -> u32 {
|
|
match reported {
|
|
Some(channels) if channels >= 1 => channels.min(MAX_SUPPORTED_AUDIO_CHANNELS),
|
|
_ => FALLBACK_AUDIO_CHANNELS,
|
|
}
|
|
}
|
|
|
|
/// The channel cap for this device, reading the platform's report where one
|
|
/// exists.
|
|
///
|
|
/// TRACES: UR-004 | DR-141 | UT-141
|
|
pub fn max_audio_channels() -> u32 {
|
|
#[cfg(target_os = "android")]
|
|
let reported = crate::player::get_detected_codecs().and_then(|(_, _, channels)| channels);
|
|
|
|
// Desktop plays video through the WebKitGTK HTML5 <video> element, which we
|
|
// do not interrogate for a channel count; stereo is the safe assumption.
|
|
#[cfg(not(target_os = "android"))]
|
|
let reported: Option<u32> = None;
|
|
|
|
clamp_max_audio_channels(reported)
|
|
}
|
|
|
|
/// Audio codecs the webview's `<video>` element can decode.
|
|
///
|
|
/// Deliberately narrower than what the platform reports: see
|
|
/// [`video_audio_codecs`].
|
|
const WEBVIEW_AUDIO_CODECS: &[&str] = &["aac", "mp3", "opus", "vorbis", "flac"];
|
|
|
|
/// The codec claimed when a device reports nothing we can use. Every renderer
|
|
/// decodes AAC, and claiming *something* is what makes the server transcode to
|
|
/// it rather than give up.
|
|
const FALLBACK_AUDIO_CODEC: &str = "aac";
|
|
|
|
/// Jellyfin's sentinel for "negotiate no subtitle stream at all".
|
|
///
|
|
/// Omitting `SubtitleStreamIndex` does **not** mean this: the server then applies
|
|
/// the source's default/forced flags and picks a track itself. See
|
|
/// [`playback_subtitle_stream_index`] for why that is never what we want.
|
|
pub const NO_SUBTITLE_STREAM: i32 = -1;
|
|
|
|
/// Subtitle formats we can render ourselves, delivered as an external sidecar
|
|
/// track rather than painted into the video.
|
|
///
|
|
/// Every entry here is *text*. Image-based subtitles (PGS, DVD, DVB) are
|
|
/// deliberately absent: they are bitmaps, so the only way a server can show them
|
|
/// on a client that cannot composite them is to burn them into the picture.
|
|
const EXTERNAL_SUBTITLE_FORMATS: &[&str] = &["srt", "subrip", "ass", "ssa", "vtt"];
|
|
|
|
/// The `SubtitleProfile` entries to advertise, as `(format, method)`.
|
|
///
|
|
/// All `External`: the app fetches subtitle tracks itself and renders them over
|
|
/// the video (UR-020), so it never needs the server to composite them.
|
|
///
|
|
/// TRACES: UR-020 | DR-176 | UT-168
|
|
pub fn subtitle_profiles() -> Vec<(&'static str, &'static str)> {
|
|
EXTERNAL_SUBTITLE_FORMATS
|
|
.iter()
|
|
.map(|format| (*format, "External"))
|
|
.collect()
|
|
}
|
|
|
|
/// Whether asking the server to serve this subtitle codec forces it to burn the
|
|
/// subtitle into the picture.
|
|
///
|
|
/// Burn-in is not a subtitle cost — it is a *video* cost. It rules out remuxing
|
|
/// the video stream, so a source we would otherwise have passed through untouched
|
|
/// gets fully re-encoded frame by frame.
|
|
///
|
|
/// TRACES: UR-020 | DR-176 | UT-168
|
|
pub fn subtitle_forces_burn_in(codec: &str) -> bool {
|
|
!EXTERNAL_SUBTITLE_FORMATS
|
|
.iter()
|
|
.any(|format| format.eq_ignore_ascii_case(codec.trim()))
|
|
}
|
|
|
|
/// The `SubtitleStreamIndex` to negotiate with: always "none".
|
|
///
|
|
/// The reported bug: a source with an E-AC-3 track and a **PGSSUB** default
|
|
/// subtitle track. Sending no index let the server honour that default, and since
|
|
/// PGS cannot go out as a sidecar it chose `SubtitleMethod=Encode` — burn-in.
|
|
/// That turned an audio-only transcode (the HEVC video was directly supported)
|
|
/// into a full HEVC→h264 re-encode, which the server could not sustain in real
|
|
/// time: the buffer never grew beyond one segment and playback stalled every few
|
|
/// seconds, taking seeking down with it.
|
|
///
|
|
/// Asking for no subtitle stream costs nothing, because the app never wanted the
|
|
/// server's composited version — it fetches the text tracks separately and
|
|
/// renders them itself (UR-020).
|
|
///
|
|
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
|
|
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.
|
|
///
|
|
/// Since the app asks for burn-in nowhere (see
|
|
/// [`playback_subtitle_stream_index`]), a format that only burn-in could deliver
|
|
/// is one it can never display. An unnamed format is treated as undeliverable
|
|
/// rather than guessed at: offering a track and drawing nothing is worse than
|
|
/// not offering it.
|
|
///
|
|
/// TRACES: UR-020 | DR-176 | UT-168
|
|
pub fn subtitle_supports_external_delivery(codec: Option<&str>) -> bool {
|
|
codec.is_some_and(|codec| !subtitle_forces_burn_in(codec))
|
|
}
|
|
|
|
/// Narrow a detected audio-codec list to what the renderer that will actually
|
|
/// play the **video** can decode.
|
|
///
|
|
/// The platform list comes from `MediaCodecList`, which describes ExoPlayer —
|
|
/// but the webview `<video>` element may be what renders the video, and
|
|
/// Chromium/WebKit decode a much smaller set than the platform does. Advertising
|
|
/// the raw list makes Jellyfin direct-play a track the webview cannot decode, and
|
|
/// the user gets picture with no sound.
|
|
///
|
|
/// Which renderer gets it is not fixed: Linux is always the element, and Android
|
|
/// follows `experimentalNativeVideo`, which took ExoPlayer as its default in
|
|
/// DR-161 but is a user setting either way. So the *narrow* list is the only one
|
|
/// that holds on both sides of that switch. The cost is a Dolby-licensed Android
|
|
/// device transcoding an E-AC-3 track its ExoPlayer could have direct-played;
|
|
/// the alternative is silence for everyone the switch lands the other way, which
|
|
/// is the bug this exists to prevent.
|
|
///
|
|
/// The gap is widest on devices whose vendor licenses Dolby: a phone with
|
|
/// `c2.dolby.eac3.decoder` reports `eac3`, so it — and only it — gets a silent
|
|
/// direct play where a leaner device is transcoded to AAC and plays fine.
|
|
///
|
|
/// This applies to the *video* direct-play profile only. Audio-only playback
|
|
/// really is ExoPlayer's, so its profile keeps the full platform list.
|
|
///
|
|
/// TRACES: UR-004 | DR-148 | UT-142
|
|
pub fn video_audio_codecs(detected: &str) -> String {
|
|
// Where the video renderer decodes the audio itself (ExoPlayer), the
|
|
// platform list *is* the answer and narrowing it to the webview's throws
|
|
// away codecs the device genuinely plays — dts, on the tablet this was
|
|
// found on. TRACES: UR-004, UR-080 | DR-233
|
|
#[cfg(target_os = "android")]
|
|
{
|
|
return detected.to_string();
|
|
}
|
|
|
|
#[allow(unreachable_code)]
|
|
let kept: Vec<&str> = detected
|
|
.split(',')
|
|
.filter_map(|codec| {
|
|
let codec = codec.trim();
|
|
// Match case-insensitively but emit our own spelling: the platform
|
|
// list is assembled from MIME strings and its casing is not ours to
|
|
// forward to the server.
|
|
WEBVIEW_AUDIO_CODECS
|
|
.iter()
|
|
.copied()
|
|
.find(|supported| supported.eq_ignore_ascii_case(codec))
|
|
})
|
|
.collect();
|
|
|
|
if kept.is_empty() {
|
|
FALLBACK_AUDIO_CODEC.to_string()
|
|
} else {
|
|
kept.join(",")
|
|
}
|
|
}
|
|
|
|
/// What the renderer that will actually decode video on this platform can play.
|
|
///
|
|
/// Returns `(video_codecs, audio_codecs)` as Jellyfin-style comma lists.
|
|
///
|
|
/// This exists because the answer was previously derived in four places and
|
|
/// hardcoded in a fifth, each of them assuming the *webview* was decoding:
|
|
/// the device profile, the transcoding targets, the direct-play audio
|
|
/// narrowing, the client-side audio override, and `get_video_stream_url`'s
|
|
/// `VideoCodec`. On Android the decoder is ExoPlayer, so every one of those was
|
|
/// wrong there — the observed cost being an hevc source re-encoded to h264
|
|
/// because its *audio* was eac3, and dts forced to transcode though the device
|
|
/// decodes it.
|
|
///
|
|
/// One source, so the copies cannot disagree again.
|
|
///
|
|
/// TRACES: UR-004, UR-080 | DR-233
|
|
pub fn renderer_codecs() -> (String, String) {
|
|
#[cfg(target_os = "android")]
|
|
{
|
|
// ExoPlayer, and the device itself answers via MediaCodecList.
|
|
crate::player::get_detected_codecs()
|
|
.map(|(video, audio, _channels)| (video, audio))
|
|
.unwrap_or_else(|| {
|
|
log::warn!(
|
|
"[DeviceProfile] Codec detection not complete, using conservative defaults"
|
|
);
|
|
("h264,hevc".to_string(), "aac,mp3".to_string())
|
|
})
|
|
}
|
|
|
|
// Linux desktop draws video in the WebKitGTK HTML5 <video> element, which
|
|
// cannot reliably decode HEVC/AV1/VP9. Claim only what it decodes, so
|
|
// Jellyfin transcodes the rest to h264 HLS. (Audio-only playback goes
|
|
// through MPV and is unaffected — that is a different renderer and a
|
|
// different profile.)
|
|
//
|
|
// When mpv draws the picture here this stops being a platform constant and
|
|
// becomes a question about the active renderer — which is the whole point of
|
|
// returning it from a function rather than a `cfg` block.
|
|
#[cfg(all(not(target_os = "android"), target_os = "linux"))]
|
|
{
|
|
("h264".to_string(), "aac,mp3,opus,vorbis,flac".to_string())
|
|
}
|
|
|
|
#[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
|
|
{
|
|
(
|
|
"h264,hevc,vp8,vp9,av1,mpeg4".to_string(),
|
|
"aac,mp3,opus,vorbis,flac".to_string(),
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Whether the renderer that decodes *video* on this platform can also decode
|
|
/// this audio codec.
|
|
///
|
|
/// On a webview platform this is the webview's narrow list, because the element
|
|
/// decodes both halves. On Android it is the device's own list: ExoPlayer plays
|
|
/// the audio, so judging it against the webview's capabilities transcodes files
|
|
/// that would have played.
|
|
///
|
|
/// TRACES: UR-004, UR-080 | DR-233
|
|
pub fn renderer_can_decode_audio(codec: &str) -> bool {
|
|
let codec = codec.trim();
|
|
#[cfg(target_os = "android")]
|
|
{
|
|
let (_video, audio) = renderer_codecs();
|
|
return audio
|
|
.split(',')
|
|
.any(|supported| supported.trim().eq_ignore_ascii_case(codec));
|
|
}
|
|
#[cfg(not(target_os = "android"))]
|
|
{
|
|
webview_can_decode_audio(codec)
|
|
}
|
|
}
|
|
|
|
/// Whether the webview `<video>` element can decode this audio codec.
|
|
///
|
|
/// TRACES: UR-004 | DR-149 | UT-148
|
|
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 (see
|
|
/// [`served_audio_codec`]). 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-148
|
|
pub fn audio_forces_transcode(streams: &[(Option<&str>, bool)]) -> bool {
|
|
match served_audio_codec(streams) {
|
|
// The renderer that will decode it, not always the webview — see
|
|
// `renderer_can_decode_audio`. TRACES: UR-004, UR-080 | DR-233
|
|
Some(codec) => !renderer_can_decode_audio(codec),
|
|
// No audio at all, or a codec the server did not name: leave it alone.
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
/// The codec of the audio track the server will actually serve, given the
|
|
/// source's audio streams as `(codec, is_default)` in source order: the default,
|
|
/// or the first when none is marked.
|
|
///
|
|
/// `None` means "nothing to judge" — no audio streams, or the server named no
|
|
/// codec for the one it would serve. Both callers of this rule treat that as
|
|
/// leave-well-alone, never as a licence to assume compatibility.
|
|
///
|
|
/// TRACES: UR-004, UR-071 | DR-149, DR-171 | UT-148, UT-166
|
|
pub fn served_audio_codec<'a>(streams: &[(Option<&'a str>, bool)]) -> Option<&'a str> {
|
|
streams
|
|
.iter()
|
|
.find(|(_, is_default)| *is_default)
|
|
.or_else(|| streams.first())
|
|
.and_then(|(codec, _)| *codec)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The reported bug, at the level it was decided: a source whose default
|
|
/// subtitle track is PGSSUB must not drag the video into a re-encode.
|
|
///
|
|
/// TRACES: UR-020 | DR-176 | UT-168
|
|
#[test]
|
|
fn no_subtitle_stream_is_negotiated_so_the_server_never_burns_one_in() {
|
|
assert_eq!(playback_subtitle_stream_index(), NO_SUBTITLE_STREAM);
|
|
// Not `None`/omitted: that is what let the server pick the PGS track.
|
|
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() {
|
|
let profiles = subtitle_profiles();
|
|
for format in ["srt", "subrip", "ass", "ssa", "vtt"] {
|
|
let entry = profiles.iter().find(|(f, _)| *f == format);
|
|
assert!(
|
|
entry.is_some(),
|
|
"{format} must be advertised or the server burns it into the picture"
|
|
);
|
|
assert_eq!(entry.unwrap().1, "External");
|
|
}
|
|
}
|
|
|
|
/// TRACES: UR-020 | DR-176 | UT-168
|
|
#[test]
|
|
fn text_subtitles_never_force_burn_in_but_image_ones_do() {
|
|
// Text: deliverable as a sidecar, so the video can still be remuxed.
|
|
assert!(!subtitle_forces_burn_in("subrip"));
|
|
assert!(!subtitle_forces_burn_in("ASS"));
|
|
assert!(!subtitle_forces_burn_in("ssa"));
|
|
// Image formats are bitmaps — the server can only composite them.
|
|
assert!(subtitle_forces_burn_in("PGSSUB"));
|
|
assert!(subtitle_forces_burn_in("dvdsub"));
|
|
}
|
|
|
|
#[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)]));
|
|
}
|
|
|
|
/// The download path needs the codec itself, not just the verdict, so it can
|
|
/// tell the server what to re-encode. It picks the same track the streaming
|
|
/// verdict is formed from — one rule, one place.
|
|
///
|
|
/// TRACES: UR-071 | DR-171 | UT-166
|
|
#[test]
|
|
fn the_served_codec_is_the_one_the_verdict_is_formed_from() {
|
|
assert_eq!(
|
|
served_audio_codec(&[(Some("aac"), false), (Some("eac3"), true)]),
|
|
Some("eac3")
|
|
);
|
|
assert_eq!(
|
|
served_audio_codec(&[(Some("eac3"), false), (Some("aac"), false)]),
|
|
Some("eac3")
|
|
);
|
|
assert_eq!(served_audio_codec(&[]), None);
|
|
assert_eq!(served_audio_codec(&[(None, true)]), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_dolby_device_does_not_advertise_dolby_for_video() {
|
|
// The bug: a Motorola reporting c2.dolby.eac3.decoder direct-played
|
|
// E-AC-3 into a webview that cannot decode it — silent video, on that
|
|
// device only.
|
|
let codecs = video_audio_codecs("aac,ac3,amrnb,amrwb,eac3,flac,mp3,opus,pcm,vorbis");
|
|
assert_eq!(codecs, "aac,flac,mp3,opus,vorbis");
|
|
}
|
|
|
|
#[test]
|
|
fn codecs_the_webview_cannot_decode_are_dropped() {
|
|
// AMR and raw PCM come from the AOSP set, so this is not a Dolby-only
|
|
// problem — it is just rarer content.
|
|
assert_eq!(video_audio_codecs("amrnb,amrwb,pcm,aac"), "aac");
|
|
assert_eq!(video_audio_codecs("dts,truehd,mp3"), "mp3");
|
|
}
|
|
|
|
#[test]
|
|
fn a_list_the_webview_fully_supports_is_untouched() {
|
|
assert_eq!(
|
|
video_audio_codecs("aac,mp3,opus,vorbis,flac"),
|
|
"aac,mp3,opus,vorbis,flac"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn nothing_decodable_still_claims_aac() {
|
|
// Claiming an empty list invites the server to give up rather than
|
|
// transcode. AAC is universally decodable, so ask for it.
|
|
assert_eq!(video_audio_codecs("eac3,dts"), "aac");
|
|
assert_eq!(video_audio_codecs(""), "aac");
|
|
}
|
|
|
|
#[test]
|
|
fn spacing_and_case_in_the_platform_list_are_tolerated() {
|
|
// The list is assembled from MediaCodecList strings; do not let
|
|
// whitespace decide whether the user gets sound.
|
|
assert_eq!(video_audio_codecs("aac, EAC3 , Mp3"), "aac,mp3");
|
|
}
|
|
|
|
#[test]
|
|
fn an_unknown_route_falls_back_to_stereo() {
|
|
// Codec detection has not run yet, or the platform has no answer. Never
|
|
// claim surround we have not seen — every sink can do stereo.
|
|
assert_eq!(clamp_max_audio_channels(None), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn a_zero_reading_is_not_a_capability() {
|
|
// A route that has not been established reports 0; taking that literally
|
|
// would advertise a device with no audio at all.
|
|
assert_eq!(clamp_max_audio_channels(Some(0)), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn a_stereo_sink_is_reported_as_stereo() {
|
|
// The phone speaker / Bluetooth headset case: the server must downmix
|
|
// 5.1 rather than direct-play it.
|
|
assert_eq!(clamp_max_audio_channels(Some(2)), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn a_surround_route_keeps_its_channels() {
|
|
// HDMI to an AVR: 5.1 and 7.1 direct play stay available.
|
|
assert_eq!(clamp_max_audio_channels(Some(6)), 6);
|
|
assert_eq!(clamp_max_audio_channels(Some(8)), 8);
|
|
}
|
|
|
|
#[test]
|
|
fn an_absurd_reading_is_capped_rather_than_forwarded() {
|
|
// Some drivers report the AudioTrack maximum rather than the route's.
|
|
assert_eq!(clamp_max_audio_channels(Some(32)), 8);
|
|
}
|
|
|
|
#[test]
|
|
fn a_mono_route_is_taken_at_its_word() {
|
|
assert_eq!(clamp_max_audio_channels(Some(1)), 1);
|
|
}
|
|
}
|