Skip to main content

#
jellytau_lib/repository/
device_profile.rs

    1//! Device-profile policy: turning what a device *reports* about its audio
    2//! output into the constraints we send Jellyfin.
    3//!
    4//! The platform layer reports raw facts (what `MediaCodecList` enumerates, how
    5//! many channels the current audio route accepts); deciding what those facts
    6//! mean for a `DeviceProfile` is domain logic and lives here, on the Rust side
    7//! of the boundary, where it is testable without a device.
    8
    9/// Channel count assumed when the platform cannot tell us — every audio route
    10/// can voice stereo, so it is the only safe floor.
    11const FALLBACK_AUDIO_CHANNELS: u32 = 2;
    12
    13/// Upper bound we are willing to claim. Jellyfin profiles top out at 7.1, and a
    14/// nonsense reading from a driver should not become a nonsense profile.
    15const MAX_SUPPORTED_AUDIO_CHANNELS: u32 = 8;
    16
    17/// Decide the `MaxAudioChannels` to advertise, given what the current audio
    18/// route reported.
    19///
    20/// Without this constraint Jellyfin is free to direct-play a 5.1 or 7.1 track to
    21/// a sink that only has two channels. What the user hears then is device
    22/// dependent and rarely correct — a failed `AudioSink` configuration (silence),
    23/// or centre-channel dialogue folded away to near-inaudibility. Naming the real
    24/// channel count makes the server downmix instead, which is always audible.
    25///
    26/// A missing or zero reading means "route not established yet", not "no audio":
    27/// fall back to stereo rather than claiming a capability we have not seen.
    28///
    29/// TRACES: UR-004 | DR-141 | UT-141
    30pub fn clamp_max_audio_channels(reported: Option<u32>) -> u32 {
    31    match reported {
    32        Some(channels) if channels >= 1 => channels.min(MAX_SUPPORTED_AUDIO_CHANNELS),
    33        _ => FALLBACK_AUDIO_CHANNELS,
    34    }
    35}
    36
    37/// The channel cap for this device, reading the platform's report where one
    38/// exists.
    39///
    40/// TRACES: UR-004 | DR-141 | UT-141
    41pub fn max_audio_channels() -> u32 {
    42    #[cfg(target_os = "android")]
    43    let reported = crate::player::get_detected_codecs().and_then(|(_, _, channels)| channels);
    44
    45    // Desktop plays video through the WebKitGTK HTML5 <video> element, which we
    46    // do not interrogate for a channel count; stereo is the safe assumption.
    47    #[cfg(not(target_os = "android"))]
    48    let reported: Option<u32> = None;
    49
    50    clamp_max_audio_channels(reported)
    51}
    52
    53/// Audio codecs the webview's `<video>` element can decode.
    54///
    55/// Deliberately narrower than what the platform reports: see
    56/// [`video_audio_codecs`].
    57const WEBVIEW_AUDIO_CODECS: &[&str] = &["aac", "mp3", "opus", "vorbis", "flac"];
    58
    59/// The codec claimed when a device reports nothing we can use. Every renderer
    60/// decodes AAC, and claiming *something* is what makes the server transcode to
    61/// it rather than give up.
    62const FALLBACK_AUDIO_CODEC: &str = "aac";
    63
    64/// Jellyfin's sentinel for "negotiate no subtitle stream at all".
    65///
    66/// Omitting `SubtitleStreamIndex` does **not** mean this: the server then applies
    67/// the source's default/forced flags and picks a track itself. See
    68/// [`playback_subtitle_stream_index`] for why that is never what we want.
    69pub const NO_SUBTITLE_STREAM: i32 = -1;
    70
    71/// Subtitle formats we can render ourselves, delivered as an external sidecar
    72/// track rather than painted into the video.
    73///
    74/// Every entry here is *text*. Image-based subtitles (PGS, DVD, DVB) are
    75/// deliberately absent: they are bitmaps, so the only way a server can show them
    76/// on a client that cannot composite them is to burn them into the picture.
    77const EXTERNAL_SUBTITLE_FORMATS: &[&str] = &["srt", "subrip", "ass", "ssa", "vtt"];
    78
    79/// The `SubtitleProfile` entries to advertise, as `(format, method)`.
    80///
    81/// All `External`: the app fetches subtitle tracks itself and renders them over
    82/// the video (UR-020), so it never needs the server to composite them.
    83///
    84/// TRACES: UR-020 | DR-176 | UT-168
    85pub fn subtitle_profiles() -> Vec<(&'static str, &'static str)> {
    86    EXTERNAL_SUBTITLE_FORMATS
    87        .iter()
    88        .map(|format| (*format, "External"))
    89        .collect()
    90}
    91
    92/// Whether asking the server to serve this subtitle codec forces it to burn the
    93/// subtitle into the picture.
    94///
    95/// Burn-in is not a subtitle cost — it is a *video* cost. It rules out remuxing
    96/// the video stream, so a source we would otherwise have passed through untouched
    97/// gets fully re-encoded frame by frame.
    98///
    99/// TRACES: UR-020 | DR-176 | UT-168
    100pub fn subtitle_forces_burn_in(codec: &str) -> bool {
    101    !EXTERNAL_SUBTITLE_FORMATS
    102        .iter()
    103        .any(|format| format.eq_ignore_ascii_case(codec.trim()))
    104}
    105
    106/// The `SubtitleStreamIndex` to negotiate with: always "none".
    107///
    108/// The reported bug: a source with an E-AC-3 track and a **PGSSUB** default
    109/// subtitle track. Sending no index let the server honour that default, and since
    110/// PGS cannot go out as a sidecar it chose `SubtitleMethod=Encode` — burn-in.
    111/// That turned an audio-only transcode (the HEVC video was directly supported)
    112/// into a full HEVC→h264 re-encode, which the server could not sustain in real
    113/// time: the buffer never grew beyond one segment and playback stalled every few
    114/// seconds, taking seeking down with it.
    115///
    116/// Asking for no subtitle stream costs nothing, because the app never wanted the
    117/// server's composited version — it fetches the text tracks separately and
    118/// renders them itself (UR-020).
    119///
    120/// TRACES: UR-020, UR-004 | DR-176 | UT-168
    121pub fn playback_subtitle_stream_index() -> i32 {
    122    NO_SUBTITLE_STREAM
    123}
    124
    125/// Query keys through which a stream URL can carry a subtitle decision.
    126///
    127/// Jellyfin binds query keys case-insensitively, so the match has to be too —
    128/// the server itself mixes casing (`SubtitleStreamIndex` but
    129/// `alwaysBurnInSubtitleWhenTranscoding`).
    130const SUBTITLE_QUERY_KEYS: &[&str] = &[
    131    "subtitlestreamindex",
    132    "subtitlemethod",
    133    "subtitlecodec",
    134    "alwaysburninsubtitlewhentranscoding",
    135];
    136
    137/// Rewrite a stream URL so it asks for no subtitle, whoever built it.
    138///
    139/// [`playback_subtitle_stream_index`] only governs the URLs *this app* builds.
    140/// When `PlaybackInfo` answers with a `TranscodingUrl`, the URL was built by the
    141/// server from its own subtitle verdict, and we play it verbatim — so a server
    142/// that picked a track anyway (a live channel opened without an index, a source
    143/// whose default is image-based) hands us `SubtitleMethod=Encode`, and the
    144/// burn-in the negotiation just declined comes back through the URL. Burn-in is
    145/// a *video* cost: it rules out remuxing and forces a full re-encode.
    146///
    147/// Stripping the keys is not enough on its own — an absent index is not "none",
    148/// it is "you choose" — so the sentinel is always appended.
    149///
    150/// TRACES: UR-020, UR-004 | DR-176 | UT-168
    151pub fn without_server_chosen_subtitle(url: &str) -> String {
    152    let (path, query) = match url.split_once('?') {
    153        Some((path, query)) => (path, query),
    154        None => (url, ""),
    155    };
    156
    157    let mut kept: Vec<&str> = query
    158        .split('&')
    159        .filter(|param| !param.is_empty())
    160        .filter(|param| {
    161            let key = param.split_once('=').map_or(*param, |(key, _)| key);
    162            !SUBTITLE_QUERY_KEYS
    163                .iter()
    164                .any(|subtitle_key| key.eq_ignore_ascii_case(subtitle_key))
    165        })
    166        .collect();
    167
    168    let sentinel = format!("SubtitleStreamIndex={}", NO_SUBTITLE_STREAM);
    169    kept.push(&sentinel);
    170
    171    format!("{}?{}", path, kept.join("&"))
    172}
    173
    174/// Whether a subtitle in this format can reach the app as a sidecar it draws
    175/// itself — the same verdict as [`subtitle_forces_burn_in`], from the reader's
    176/// side, and the one a subtitle picker needs.
    177///
    178/// Since the app asks for burn-in nowhere (see
    179/// [`playback_subtitle_stream_index`]), a format that only burn-in could deliver
    180/// is one it can never display. An unnamed format is treated as undeliverable
    181/// rather than guessed at: offering a track and drawing nothing is worse than
    182/// not offering it.
    183///
    184/// TRACES: UR-020 | DR-176 | UT-168
    185pub fn subtitle_supports_external_delivery(codec: Option<&str>) -> bool {
    186    codec.is_some_and(|codec| !subtitle_forces_burn_in(codec))
    187}
    188
    189/// Narrow a detected audio-codec list to what the renderer that will actually
    190/// play the **video** can decode.
    191///
    192/// The platform list comes from `MediaCodecList`, which describes ExoPlayer —
    193/// but the webview `<video>` element may be what renders the video, and
    194/// Chromium/WebKit decode a much smaller set than the platform does. Advertising
    195/// the raw list makes Jellyfin direct-play a track the webview cannot decode, and
    196/// the user gets picture with no sound.
    197///
    198/// Which renderer gets it is not fixed: Linux is always the element, and Android
    199/// follows `experimentalNativeVideo`, which took ExoPlayer as its default in
    200/// DR-161 but is a user setting either way. So the *narrow* list is the only one
    201/// that holds on both sides of that switch. The cost is a Dolby-licensed Android
    202/// device transcoding an E-AC-3 track its ExoPlayer could have direct-played;
    203/// the alternative is silence for everyone the switch lands the other way, which
    204/// is the bug this exists to prevent.
    205///
    206/// The gap is widest on devices whose vendor licenses Dolby: a phone with
    207/// `c2.dolby.eac3.decoder` reports `eac3`, so it — and only it — gets a silent
    208/// direct play where a leaner device is transcoded to AAC and plays fine.
    209///
    210/// This applies to the *video* direct-play profile only. Audio-only playback
    211/// really is ExoPlayer's, so its profile keeps the full platform list.
    212///
    213/// TRACES: UR-004 | DR-148 | UT-142
    214pub fn video_audio_codecs(detected: &str) -> String {
    215    // Where the video renderer decodes the audio itself (ExoPlayer), the
    216    // platform list *is* the answer and narrowing it to the webview's throws
    217    // away codecs the device genuinely plays — dts, on the tablet this was
    218    // found on. TRACES: UR-004, UR-080 | DR-234
    219    #[cfg(target_os = "android")]
    220    {
    221        return detected.to_string();
    222    }
    223
    224    #[allow(unreachable_code)]
    225    let kept: Vec<&str> = detected
    226        .split(',')
    227        .filter_map(|codec| {
    228            let codec = codec.trim();
    229            // Match case-insensitively but emit our own spelling: the platform
    230            // list is assembled from MIME strings and its casing is not ours to
    231            // forward to the server.
    232            WEBVIEW_AUDIO_CODECS
    233                .iter()
    234                .copied()
    235                .find(|supported| supported.eq_ignore_ascii_case(codec))
    236        })
    237        .collect();
    238
    239    if kept.is_empty() {
    240        FALLBACK_AUDIO_CODEC.to_string()
    241    } else {
    242        kept.join(",")
    243    }
    244}
    245
    246/// What the renderer that will actually decode video on this platform can play.
    247///
    248/// Returns `(video_codecs, audio_codecs)` as Jellyfin-style comma lists.
    249///
    250/// This exists because the answer was previously derived in four places and
    251/// hardcoded in a fifth, each of them assuming the *webview* was decoding:
    252/// the device profile, the transcoding targets, the direct-play audio
    253/// narrowing, the client-side audio override, and `get_video_stream_url`'s
    254/// `VideoCodec`. On Android the decoder is ExoPlayer, so every one of those was
    255/// wrong there — the observed cost being an hevc source re-encoded to h264
    256/// because its *audio* was eac3, and dts forced to transcode though the device
    257/// decodes it.
    258///
    259/// One source, so the copies cannot disagree again.
    260///
    261/// TRACES: UR-004, UR-080 | DR-234
    262pub fn renderer_codecs() -> (String, String) {
    263    #[cfg(target_os = "android")]
    264    {
    265        // ExoPlayer, and the device itself answers via MediaCodecList.
    266        crate::player::get_detected_codecs()
    267            .map(|(video, audio, _channels)| (video, audio))
    268            .unwrap_or_else(|| {
    269                log::warn!(
    270                    "[DeviceProfile] Codec detection not complete, using conservative defaults"
    271                );
    272                ("h264,hevc".to_string(), "aac,mp3".to_string())
    273            })
    274    }
    275
    276    // Linux desktop draws video in the WebKitGTK HTML5 <video> element, which
    277    // cannot reliably decode HEVC/AV1/VP9. Claim only what it decodes, so
    278    // Jellyfin transcodes the rest to h264 HLS. (Audio-only playback goes
    279    // through MPV and is unaffected — that is a different renderer and a
    280    // different profile.)
    281    //
    282    // When mpv draws the picture here this stops being a platform constant and
    283    // becomes a question about the active renderer — which is the whole point of
    284    // returning it from a function rather than a `cfg` block.
    285    #[cfg(all(not(target_os = "android"), target_os = "linux"))]
    286    {
    287        ("h264".to_string(), "aac,mp3,opus,vorbis,flac".to_string())
    288    }
    289
    290    #[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
    291    {
    292        (
    293            "h264,hevc,vp8,vp9,av1,mpeg4".to_string(),
    294            "aac,mp3,opus,vorbis,flac".to_string(),
    295        )
    296    }
    297}
    298
    299/// Whether the renderer that decodes *video* on this platform can also decode
    300/// this audio codec.
    301///
    302/// On a webview platform this is the webview's narrow list, because the element
    303/// decodes both halves. On Android it is the device's own list: ExoPlayer plays
    304/// the audio, so judging it against the webview's capabilities transcodes files
    305/// that would have played.
    306///
    307/// TRACES: UR-004, UR-080 | DR-234
    308pub fn renderer_can_decode_audio(codec: &str) -> bool {
    309    let codec = codec.trim();
    310    #[cfg(target_os = "android")]
    311    {
    312        let (_video, audio) = renderer_codecs();
    313        return audio
    314            .split(',')
    315            .any(|supported| supported.trim().eq_ignore_ascii_case(codec));
    316    }
    317    #[cfg(not(target_os = "android"))]
    318    {
    319        webview_can_decode_audio(codec)
    320    }
    321}
    322
    323/// Whether the webview `<video>` element can decode this audio codec.
    324///
    325/// TRACES: UR-004 | DR-149 | UT-148
    326pub fn webview_can_decode_audio(codec: &str) -> bool {
    327    WEBVIEW_AUDIO_CODECS
    328        .iter()
    329        .any(|supported| supported.eq_ignore_ascii_case(codec.trim()))
    330}
    331
    332/// Decide whether we must transcode *regardless of what the server negotiated*,
    333/// given the source's audio streams as `(codec, is_default)` in source order.
    334///
    335/// Advertising a narrow profile ([`video_audio_codecs`]) is necessary but not
    336/// sufficient: Jellyfin 10.11.5 enforces a `DirectPlayProfile`'s container and
    337/// video codec but **ignores its audio codec** — an E-AC-3 track is offered for
    338/// direct play even when the profile lists only AAC, and neither a `VideoAudio`
    339/// `CodecProfile` nor `MaxAudioChannels` changes that. So the client cannot
    340/// delegate this decision; it knows what its own renderer can decode and must
    341/// apply that itself.
    342///
    343/// The track that matters is the one the server will actually serve (see
    344/// [`served_audio_codec`]). An unknown codec is left alone — forcing a transcode
    345/// on a guess would burn server CPU for files that play.
    346///
    347/// TRACES: UR-004 | DR-149 | UT-148
    348pub fn audio_forces_transcode(streams: &[(Option<&str>, bool)]) -> bool {
    349    match served_audio_codec(streams) {
    350        // The renderer that will decode it, not always the webview — see
    351        // `renderer_can_decode_audio`. TRACES: UR-004, UR-080 | DR-234
    352        Some(codec) => !renderer_can_decode_audio(codec),
    353        // No audio at all, or a codec the server did not name: leave it alone.
    354        None => false,
    355    }
    356}
    357
    358/// The codec of the audio track the server will actually serve, given the
    359/// source's audio streams as `(codec, is_default)` in source order: the default,
    360/// or the first when none is marked.
    361///
    362/// `None` means "nothing to judge" — no audio streams, or the server named no
    363/// codec for the one it would serve. Both callers of this rule treat that as
    364/// leave-well-alone, never as a licence to assume compatibility.
    365///
    366/// TRACES: UR-004, UR-071 | DR-149, DR-171 | UT-148, UT-166
    367pub fn served_audio_codec<'a>(streams: &[(Option<&'a str>, bool)]) -> Option<&'a str> {
    368    streams
    369        .iter()
    370        .find(|(_, is_default)| *is_default)
    371        .or_else(|| streams.first())
    372        .and_then(|(codec, _)| *codec)
    373}
    374
    375#[cfg(test)]
    376mod tests {
    377    use super::*;
    378
    379    /// The reported bug, at the level it was decided: a source whose default
    380    /// subtitle track is PGSSUB must not drag the video into a re-encode.
    381    ///
    382    /// TRACES: UR-020 | DR-176 | UT-168
    383    #[test]
    384    fn no_subtitle_stream_is_negotiated_so_the_server_never_burns_one_in() {
    385        assert_eq!(playback_subtitle_stream_index(), NO_SUBTITLE_STREAM);
    386        // Not `None`/omitted: that is what let the server pick the PGS track.
    387        assert_eq!(playback_subtitle_stream_index(), -1);
    388    }
    389
    390    /// A transcode URL the *server* built carries the server's own subtitle
    391    /// verdict. Adopting it verbatim re-introduces the burn-in
    392    /// [`playback_subtitle_stream_index`] exists to prevent — the negotiation
    393    /// asks for no subtitle, and then we play a URL that asks for one anyway.
    394    ///
    395    /// TRACES: UR-020, UR-004 | DR-176 | UT-168
    396    #[test]
    397    fn a_server_built_transcode_url_has_its_burn_in_stripped() {
    398        // Shape taken from Jellyfin's `StreamInfo.ToUrl`: it appends
    399        // `SubtitleStreamIndex` and `SubtitleMethod` whenever it picked a track.
    400        let served = "/videos/abc/master.m3u8?DeviceId=jt&MediaSourceId=src1\
    401             &VideoCodec=h264&SubtitleMethod=Encode&SubtitleStreamIndex=2\
    402             &PlaySessionId=xyz";
    403
    404        let url = without_server_chosen_subtitle(served);
    405
    406        assert!(
    407            url.contains("SubtitleStreamIndex=-1"),
    408            "the adopted URL must ask for no subtitle: {url}"
    409        );
    410        assert!(
    411            !url.contains("SubtitleStreamIndex=2"),
    412            "the server's chosen track must not survive: {url}"
    413        );
    414        assert!(
    415            !url.contains("SubtitleMethod"),
    416            "burn-in must not be requested: {url}"
    417        );
    418        // Everything else identifies the job and must survive untouched.
    419        for kept in [
    420            "DeviceId=jt",
    421            "MediaSourceId=src1",
    422            "VideoCodec=h264",
    423            "PlaySessionId=xyz",
    424        ] {
    425            assert!(url.contains(kept), "{kept} must survive: {url}");
    426        }
    427    }
    428
    429    /// The server may also be told to burn in unconditionally
    430    /// (`alwaysBurnInSubtitleWhenTranscoding`), which is appended to the URL
    431    /// rather than expressed as a method — and its keys are not PascalCase.
    432    ///
    433    /// TRACES: UR-020, UR-004 | DR-176 | UT-168
    434    #[test]
    435    fn an_unconditional_burn_in_flag_is_stripped_whatever_its_casing() {
    436        let url = without_server_chosen_subtitle(
    437            "/videos/abc/master.m3u8?api_key=k&alwaysBurnInSubtitleWhenTranscoding=true\
    438             &subtitlestreamindex=3&SubtitleCodec=ass",
    439        );
    440
    441        assert!(!url.to_lowercase().contains("alwaysburnin"), "{url}");
    442        assert!(!url.to_lowercase().contains("subtitlecodec"), "{url}");
    443        assert!(!url.contains("subtitlestreamindex=3"), "{url}");
    444        assert!(url.contains("SubtitleStreamIndex=-1"), "{url}");
    445        assert!(url.contains("api_key=k"), "{url}");
    446    }
    447
    448    /// A URL the server built without any subtitle in it still has to *say* so:
    449    /// omitting the index is what makes the server apply the source's default.
    450    ///
    451    /// TRACES: UR-020, UR-004 | DR-176 | UT-168
    452    #[test]
    453    fn a_url_with_no_subtitle_params_is_still_made_to_ask_for_none() {
    454        let url = without_server_chosen_subtitle("/videos/abc/master.m3u8?api_key=k");
    455        assert_eq!(
    456            url,
    457            "/videos/abc/master.m3u8?api_key=k&SubtitleStreamIndex=-1"
    458        );
    459
    460        // A bare URL is rare but must not come out malformed.
    461        let bare = without_server_chosen_subtitle("/videos/abc/master.m3u8");
    462        assert_eq!(bare, "/videos/abc/master.m3u8?SubtitleStreamIndex=-1");
    463    }
    464
    465    /// TRACES: UR-020 | DR-176 | UT-168
    466    #[test]
    467    fn text_subtitles_are_advertised_as_external_sidecars() {
    468        let profiles = subtitle_profiles();
    469        for format in ["srt", "subrip", "ass", "ssa", "vtt"] {
    470            let entry = profiles.iter().find(|(f, _)| *f == format);
    471            assert!(
    472                entry.is_some(),
    473                "{format} must be advertised or the server burns it into the picture"
    474            );
    475            assert_eq!(entry.unwrap().1, "External");
    476        }
    477    }
    478
    479    /// TRACES: UR-020 | DR-176 | UT-168
    480    #[test]
    481    fn text_subtitles_never_force_burn_in_but_image_ones_do() {
    482        // Text: deliverable as a sidecar, so the video can still be remuxed.
    483        assert!(!subtitle_forces_burn_in("subrip"));
    484        assert!(!subtitle_forces_burn_in("ASS"));
    485        assert!(!subtitle_forces_burn_in("ssa"));
    486        // Image formats are bitmaps — the server can only composite them.
    487        assert!(subtitle_forces_burn_in("PGSSUB"));
    488        assert!(subtitle_forces_burn_in("dvdsub"));
    489    }
    490
    491    #[test]
    492    fn an_undecodable_default_track_forces_a_transcode() {
    493        // The reported bug: one E-AC-3 track, which the webview cannot decode.
    494        assert!(audio_forces_transcode(&[(Some("eac3"), false)]));
    495        assert!(audio_forces_transcode(&[(Some("ac3"), true)]));
    496    }
    497
    498    #[test]
    499    fn a_decodable_track_is_left_to_direct_play() {
    500        // Never spend server CPU on a file that already plays.
    501        assert!(!audio_forces_transcode(&[(Some("aac"), true)]));
    502        assert!(!audio_forces_transcode(&[(Some("mp3"), false)]));
    503    }
    504
    505    #[test]
    506    fn the_default_track_decides_not_the_first() {
    507        // The webview plays the default track, so that is the one that has to be
    508        // decodable — a supported track further down does not save us.
    509        assert!(audio_forces_transcode(&[
    510            (Some("aac"), false),
    511            (Some("eac3"), true)
    512        ]));
    513        assert!(!audio_forces_transcode(&[
    514            (Some("eac3"), false),
    515            (Some("aac"), true)
    516        ]));
    517    }
    518
    519    #[test]
    520    fn with_no_default_marked_the_first_track_decides() {
    521        // Jellyfin leaves IsDefault false on every stream for some files; the
    522        // server then serves the first, so judge that one.
    523        assert!(audio_forces_transcode(&[
    524            (Some("eac3"), false),
    525            (Some("aac"), false)
    526        ]));
    527    }
    528
    529    #[test]
    530    fn a_source_with_no_audio_is_not_transcoded() {
    531        // Nothing to rescue, and a transcode would not create audio.
    532        assert!(!audio_forces_transcode(&[]));
    533    }
    534
    535    #[test]
    536    fn an_unknown_codec_is_not_second_guessed() {
    537        // The server did not tell us the codec; assuming the worst would
    538        // transcode files that play perfectly.
    539        assert!(!audio_forces_transcode(&[(None, true)]));
    540    }
    541
    542    /// The download path needs the codec itself, not just the verdict, so it can
    543    /// tell the server what to re-encode. It picks the same track the streaming
    544    /// verdict is formed from — one rule, one place.
    545    ///
    546    /// TRACES: UR-071 | DR-171 | UT-166
    547    #[test]
    548    fn the_served_codec_is_the_one_the_verdict_is_formed_from() {
    549        assert_eq!(
    550            served_audio_codec(&[(Some("aac"), false), (Some("eac3"), true)]),
    551            Some("eac3")
    552        );
    553        assert_eq!(
    554            served_audio_codec(&[(Some("eac3"), false), (Some("aac"), false)]),
    555            Some("eac3")
    556        );
    557        assert_eq!(served_audio_codec(&[]), None);
    558        assert_eq!(served_audio_codec(&[(None, true)]), None);
    559    }
    560
    561    #[test]
    562    fn a_dolby_device_does_not_advertise_dolby_for_video() {
    563        // The bug: a Motorola reporting c2.dolby.eac3.decoder direct-played
    564        // E-AC-3 into a webview that cannot decode it — silent video, on that
    565        // device only.
    566        let codecs = video_audio_codecs("aac,ac3,amrnb,amrwb,eac3,flac,mp3,opus,pcm,vorbis");
    567        assert_eq!(codecs, "aac,flac,mp3,opus,vorbis");
    568    }
    569
    570    #[test]
    571    fn codecs_the_webview_cannot_decode_are_dropped() {
    572        // AMR and raw PCM come from the AOSP set, so this is not a Dolby-only
    573        // problem — it is just rarer content.
    574        assert_eq!(video_audio_codecs("amrnb,amrwb,pcm,aac"), "aac");
    575        assert_eq!(video_audio_codecs("dts,truehd,mp3"), "mp3");
    576    }
    577
    578    #[test]
    579    fn a_list_the_webview_fully_supports_is_untouched() {
    580        assert_eq!(
    581            video_audio_codecs("aac,mp3,opus,vorbis,flac"),
    582            "aac,mp3,opus,vorbis,flac"
    583        );
    584    }
    585
    586    #[test]
    587    fn nothing_decodable_still_claims_aac() {
    588        // Claiming an empty list invites the server to give up rather than
    589        // transcode. AAC is universally decodable, so ask for it.
    590        assert_eq!(video_audio_codecs("eac3,dts"), "aac");
    591        assert_eq!(video_audio_codecs(""), "aac");
    592    }
    593
    594    #[test]
    595    fn spacing_and_case_in_the_platform_list_are_tolerated() {
    596        // The list is assembled from MediaCodecList strings; do not let
    597        // whitespace decide whether the user gets sound.
    598        assert_eq!(video_audio_codecs("aac, EAC3 , Mp3"), "aac,mp3");
    599    }
    600
    601    #[test]
    602    fn an_unknown_route_falls_back_to_stereo() {
    603        // Codec detection has not run yet, or the platform has no answer. Never
    604        // claim surround we have not seen — every sink can do stereo.
    605        assert_eq!(clamp_max_audio_channels(None), 2);
    606    }
    607
    608    #[test]
    609    fn a_zero_reading_is_not_a_capability() {
    610        // A route that has not been established reports 0; taking that literally
    611        // would advertise a device with no audio at all.
    612        assert_eq!(clamp_max_audio_channels(Some(0)), 2);
    613    }
    614
    615    #[test]
    616    fn a_stereo_sink_is_reported_as_stereo() {
    617        // The phone speaker / Bluetooth headset case: the server must downmix
    618        // 5.1 rather than direct-play it.
    619        assert_eq!(clamp_max_audio_channels(Some(2)), 2);
    620    }
    621
    622    #[test]
    623    fn a_surround_route_keeps_its_channels() {
    624        // HDMI to an AVR: 5.1 and 7.1 direct play stay available.
    625        assert_eq!(clamp_max_audio_channels(Some(6)), 6);
    626        assert_eq!(clamp_max_audio_channels(Some(8)), 8);
    627    }
    628
    629    #[test]
    630    fn an_absurd_reading_is_capped_rather_than_forwarded() {
    631        // Some drivers report the AudioTrack maximum rather than the route's.
    632        assert_eq!(clamp_max_audio_channels(Some(32)), 8);
    633    }
    634
    635    #[test]
    636    fn a_mono_route_is_taken_at_its_word() {
    637        assert_eq!(clamp_max_audio_channels(Some(1)), 1);
    638    }
    639}