mpv now decodes video on Linux, drawn into a framebuffer we own and blitted
into the default vbox's draw handler. Tauri's widget tree is untouched, so an
upgrade that assumes its own layout cannot invalidate this. Direct play means
the original file, hardware decoding, and no server transcode at all — where
previously every desktop video was re-encoded to h264 for the browser engine,
whatever the file actually was. Off by default: JELLYTAU_NATIVE_VIDEO=1.
That settles finding 2 of playback-backend-unification.md — "native video
cannot be composited with a Tauri webview" — by demonstration rather than
argument, on X11 and Wayland both.
Turning it on exposed nine defects, none of them mpv's. Each was the same
mistake in a different place: a capability written down as a compile-time fact
about the platform, or a state asserted instead of confirmed.
DR-238/246 a seek routed by the stream's container rather than by what the
engine could do with it - correct only while one player handled
those streams, silent the moment another did
DR-239 a property handled but never observed, so the play/pause button
waited for an event that could not arrive
DR-240 fullscreen expanding the document while the window stayed put
DR-241 a seek issued before the engine had a file, failed, and discarded
- which is why resume began at zero
DR-247 a Linux-only gate outliving the caller that made it Linux-only,
breaking the Android build outright
DR-250 a stop aimed at whichever renderer bookkeeping believed was in
charge, missing the one actually making sound
DR-251 a duration of zero believed, leaving the seek bar no scale
DR-252 a junk float converted to a Duration, panicking the backend the
instant a length-less stream appeared
So the MediaPlayer contract (DR-242 … DR-247): `open` carries a start position,
so no caller sequences load-then-seek and none can race an engine's load;
`seek` states a destination and leaves in-place-versus-re-open to the engine;
`snapshot` is one coherent read; and `Phase::Opening` names the window where
intent used to be lost. One conformance suite runs against every engine —
FakePlayer and mpv under cargo test, ExoPlayer instrumented on a device — so an
engine is either correct or visibly failing.
Two of the nine were introduced during this work and caught on hardware, not by
any suite: an over-broad capability that grouped ExoPlayer with mpv, and the
Duration panic. The suites test engines that behave. That is recorded in
docs/native-player-verification.md, which asks for the exact action sequences
that found them.
Verified: all automated gates, conformance (mpv 9/9, legacy 8/9 by design,
ExoPlayer 7/7 on device), and manual desktop and Android passes on real
hardware.
Known open and deliberately shipped: resume reads local progress and never the
server's; the background-audio handoff still declares a state swap it does not
confirm (the symptom is now impossible, the race is not); and `bun run
android:dev` builds an APK carrying the release application id, whose failure
message advises an uninstall that would destroy app data. Fix that last one
before anyone else builds for Android.
Squashed from worktree-linux-native-video, which keeps the per-defect history.
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-234
|
|
#[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-234
|
|
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-234
|
|
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-234
|
|
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);
|
|
}
|
|
}
|