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 depends on the platform. Linux draws video in the
199/// element (unless mpv native video is switched on), so it gets the narrow list.
200/// Android draws video only in ExoPlayer: it used to follow the
201/// `experimentalNativeVideo` setting, which could send video to the webview, and
202/// while that switch existed the narrow list was the only one true on both sides
203/// of it. DR-293 removed the webview video path on Android, so there the
204/// platform list is the whole answer — it includes the FFmpeg extension's
205/// AC-3/E-AC-3/DTS/TrueHD, which `CodecDetector` reports alongside the
206/// `MediaCodecList` decoders.
207///
208/// This applies to the *video* direct-play profile only. Audio-only playback
209/// really is ExoPlayer's, so its profile keeps the full platform list.
210///
211/// TRACES: UR-004 | DR-148 | UT-142
212pub fn video_audio_codecs(detected: &str) -> String {
213    // Where the video renderer decodes the audio itself (ExoPlayer), the
214    // platform list *is* the answer and narrowing it to the webview's throws
215    // away codecs the device genuinely plays — dts, on the tablet this was
216    // found on. TRACES: UR-004, UR-080 | DR-234
217    #[cfg(target_os = "android")]
218    {
219        return detected.to_string();
220    }
221
222    #[allow(unreachable_code)]
223    let kept: Vec<&str> = detected
224        .split(',')
225        .filter_map(|codec| {
226            let codec = codec.trim();
227            // Match case-insensitively but emit our own spelling: the platform
228            // list is assembled from MIME strings and its casing is not ours to
229            // forward to the server.
230            WEBVIEW_AUDIO_CODECS
231                .iter()
232                .copied()
233                .find(|supported| supported.eq_ignore_ascii_case(codec))
234        })
235        .collect();
236
237    if kept.is_empty() {
238        FALLBACK_AUDIO_CODEC.to_string()
239    } else {
240        kept.join(",")
241    }
242}
243
244/// What the renderer that will actually decode video on this platform can play.
245///
246/// Returns `(video_codecs, audio_codecs)` as Jellyfin-style comma lists.
247///
248/// This exists because the answer was previously derived in four places and
249/// hardcoded in a fifth, each of them assuming the *webview* was decoding:
250/// the device profile, the transcoding targets, the direct-play audio
251/// narrowing, the client-side audio override, and `get_video_stream_url`'s
252/// `VideoCodec`. On Android the decoder is ExoPlayer, so every one of those was
253/// wrong there — the observed cost being an hevc source re-encoded to h264
254/// because its *audio* was eac3, and dts forced to transcode though the device
255/// decodes it.
256///
257/// One source, so the copies cannot disagree again.
258///
259/// TRACES: UR-004, UR-080 | DR-234
260pub fn renderer_codecs() -> (String, String) {
261    #[cfg(target_os = "android")]
262    {
263        // ExoPlayer, and the device itself answers via MediaCodecList.
264        crate::player::get_detected_codecs()
265            .map(|(video, audio, _channels)| (video, audio))
266            .unwrap_or_else(|| {
267                log::warn!(
268                    "[DeviceProfile] Codec detection not complete, using conservative defaults"
269                );
270                ("h264,hevc".to_string(), "aac,mp3".to_string())
271            })
272    }
273
274    // Linux desktop draws video in the WebKitGTK HTML5 <video> element, which
275    // cannot reliably decode HEVC/AV1/VP9. Claim only what it decodes, so
276    // Jellyfin transcodes the rest to h264 HLS. (Audio-only playback goes
277    // through MPV and is unaffected — that is a different renderer and a
278    // different profile.)
279    //
280    // When mpv draws the picture here this stops being a platform constant and
281    // becomes a question about the active renderer — which is the whole point of
282    // returning it from a function rather than a `cfg` block.
283    #[cfg(all(not(target_os = "android"), target_os = "linux"))]
284    {
285        ("h264".to_string(), "aac,mp3,opus,vorbis,flac".to_string())
286    }
287
288    #[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
289    {
290        (
291            "h264,hevc,vp8,vp9,av1,mpeg4".to_string(),
292            "aac,mp3,opus,vorbis,flac".to_string(),
293        )
294    }
295}
296
297/// Whether the renderer that decodes *video* on this platform can also decode
298/// this audio codec.
299///
300/// On a webview platform this is the webview's narrow list, because the element
301/// decodes both halves. On Android it is the device's own list: ExoPlayer plays
302/// the audio, so judging it against the webview's capabilities transcodes files
303/// that would have played.
304///
305/// TRACES: UR-004, UR-080 | DR-234
306pub fn renderer_can_decode_audio(codec: &str) -> bool {
307    let codec = codec.trim();
308    #[cfg(target_os = "android")]
309    {
310        let (_video, audio) = renderer_codecs();
311        return audio
312            .split(',')
313            .any(|supported| supported.trim().eq_ignore_ascii_case(codec));
314    }
315    #[cfg(not(target_os = "android"))]
316    {
317        webview_can_decode_audio(codec)
318    }
319}
320
321/// Whether the webview `<video>` element can decode this audio codec.
322///
323/// TRACES: UR-004 | DR-149 | UT-148
324// Unreachable on Android since DR-293: video renders only in ExoPlayer there,
325// so every caller goes through `renderer_can_decode_audio`'s device-list arm.
326#[cfg_attr(target_os = "android", allow(dead_code))]
327pub fn webview_can_decode_audio(codec: &str) -> bool {
328    WEBVIEW_AUDIO_CODECS
329        .iter()
330        .any(|supported| supported.eq_ignore_ascii_case(codec.trim()))
331}
332
333/// Decide whether we must transcode *regardless of what the server negotiated*,
334/// given the source's audio streams as `(codec, is_default)` in source order.
335///
336/// Advertising a narrow profile ([`video_audio_codecs`]) is necessary but not
337/// sufficient: Jellyfin 10.11.5 enforces a `DirectPlayProfile`'s container and
338/// video codec but **ignores its audio codec** — an E-AC-3 track is offered for
339/// direct play even when the profile lists only AAC, and neither a `VideoAudio`
340/// `CodecProfile` nor `MaxAudioChannels` changes that. So the client cannot
341/// delegate this decision; it knows what its own renderer can decode and must
342/// apply that itself.
343///
344/// The track that matters is the one the server will actually serve (see
345/// [`served_audio_codec`]). An unknown codec is left alone — forcing a transcode
346/// on a guess would burn server CPU for files that play.
347///
348/// TRACES: UR-004 | DR-149 | UT-148
349pub fn audio_forces_transcode(streams: &[(Option<&str>, bool)]) -> bool {
350    match served_audio_codec(streams) {
351        // The renderer that will decode it, not always the webview — see
352        // `renderer_can_decode_audio`. TRACES: UR-004, UR-080 | DR-234
353        Some(codec) => !renderer_can_decode_audio(codec),
354        // No audio at all, or a codec the server did not name: leave it alone.
355        None => false,
356    }
357}
358
359/// The codec of the audio track the server will actually serve, given the
360/// source's audio streams as `(codec, is_default)` in source order: the default,
361/// or the first when none is marked.
362///
363/// `None` means "nothing to judge" — no audio streams, or the server named no
364/// codec for the one it would serve. Both callers of this rule treat that as
365/// leave-well-alone, never as a licence to assume compatibility.
366///
367/// TRACES: UR-004, UR-071 | DR-149, DR-171 | UT-148, UT-166
368pub fn served_audio_codec<'a>(streams: &[(Option<&'a str>, bool)]) -> Option<&'a str> {
369    streams
370        .iter()
371        .find(|(_, is_default)| *is_default)
372        .or_else(|| streams.first())
373        .and_then(|(codec, _)| *codec)
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    /// The reported bug, at the level it was decided: a source whose default
381    /// subtitle track is PGSSUB must not drag the video into a re-encode.
382    ///
383    /// TRACES: UR-020 | DR-176 | UT-168
384    #[test]
385    fn no_subtitle_stream_is_negotiated_so_the_server_never_burns_one_in() {
386        assert_eq!(playback_subtitle_stream_index(), NO_SUBTITLE_STREAM);
387        // Not `None`/omitted: that is what let the server pick the PGS track.
388        assert_eq!(playback_subtitle_stream_index(), -1);
389    }
390
391    /// A transcode URL the *server* built carries the server's own subtitle
392    /// verdict. Adopting it verbatim re-introduces the burn-in
393    /// [`playback_subtitle_stream_index`] exists to prevent — the negotiation
394    /// asks for no subtitle, and then we play a URL that asks for one anyway.
395    ///
396    /// TRACES: UR-020, UR-004 | DR-176 | UT-168
397    #[test]
398    fn a_server_built_transcode_url_has_its_burn_in_stripped() {
399        // Shape taken from Jellyfin's `StreamInfo.ToUrl`: it appends
400        // `SubtitleStreamIndex` and `SubtitleMethod` whenever it picked a track.
401        let served = "/videos/abc/master.m3u8?DeviceId=jt&MediaSourceId=src1\
402             &VideoCodec=h264&SubtitleMethod=Encode&SubtitleStreamIndex=2\
403             &PlaySessionId=xyz";
404
405        let url = without_server_chosen_subtitle(served);
406
407        assert!(
408            url.contains("SubtitleStreamIndex=-1"),
409            "the adopted URL must ask for no subtitle: {url}"
410        );
411        assert!(
412            !url.contains("SubtitleStreamIndex=2"),
413            "the server's chosen track must not survive: {url}"
414        );
415        assert!(
416            !url.contains("SubtitleMethod"),
417            "burn-in must not be requested: {url}"
418        );
419        // Everything else identifies the job and must survive untouched.
420        for kept in [
421            "DeviceId=jt",
422            "MediaSourceId=src1",
423            "VideoCodec=h264",
424            "PlaySessionId=xyz",
425        ] {
426            assert!(url.contains(kept), "{kept} must survive: {url}");
427        }
428    }
429
430    /// The server may also be told to burn in unconditionally
431    /// (`alwaysBurnInSubtitleWhenTranscoding`), which is appended to the URL
432    /// rather than expressed as a method — and its keys are not PascalCase.
433    ///
434    /// TRACES: UR-020, UR-004 | DR-176 | UT-168
435    #[test]
436    fn an_unconditional_burn_in_flag_is_stripped_whatever_its_casing() {
437        let url = without_server_chosen_subtitle(
438            "/videos/abc/master.m3u8?ApiKey=k&alwaysBurnInSubtitleWhenTranscoding=true\
439             &subtitlestreamindex=3&SubtitleCodec=ass",
440        );
441
442        assert!(!url.to_lowercase().contains("alwaysburnin"), "{url}");
443        assert!(!url.to_lowercase().contains("subtitlecodec"), "{url}");
444        assert!(!url.contains("subtitlestreamindex=3"), "{url}");
445        assert!(url.contains("SubtitleStreamIndex=-1"), "{url}");
446        assert!(url.contains("ApiKey=k"), "{url}");
447    }
448
449    /// A URL the server built without any subtitle in it still has to *say* so:
450    /// omitting the index is what makes the server apply the source's default.
451    ///
452    /// TRACES: UR-020, UR-004 | DR-176 | UT-168
453    #[test]
454    fn a_url_with_no_subtitle_params_is_still_made_to_ask_for_none() {
455        let url = without_server_chosen_subtitle("/videos/abc/master.m3u8?ApiKey=k");
456        assert_eq!(
457            url,
458            "/videos/abc/master.m3u8?ApiKey=k&SubtitleStreamIndex=-1"
459        );
460
461        // A bare URL is rare but must not come out malformed.
462        let bare = without_server_chosen_subtitle("/videos/abc/master.m3u8");
463        assert_eq!(bare, "/videos/abc/master.m3u8?SubtitleStreamIndex=-1");
464    }
465
466    /// TRACES: UR-020 | DR-176 | UT-168
467    #[test]
468    fn text_subtitles_are_advertised_as_external_sidecars() {
469        let profiles = subtitle_profiles();
470        for format in ["srt", "subrip", "ass", "ssa", "vtt"] {
471            let entry = profiles.iter().find(|(f, _)| *f == format);
472            assert!(
473                entry.is_some(),
474                "{format} must be advertised or the server burns it into the picture"
475            );
476            assert_eq!(entry.unwrap().1, "External");
477        }
478    }
479
480    /// TRACES: UR-020 | DR-176 | UT-168
481    #[test]
482    fn text_subtitles_never_force_burn_in_but_image_ones_do() {
483        // Text: deliverable as a sidecar, so the video can still be remuxed.
484        assert!(!subtitle_forces_burn_in("subrip"));
485        assert!(!subtitle_forces_burn_in("ASS"));
486        assert!(!subtitle_forces_burn_in("ssa"));
487        // Image formats are bitmaps — the server can only composite them.
488        assert!(subtitle_forces_burn_in("PGSSUB"));
489        assert!(subtitle_forces_burn_in("dvdsub"));
490    }
491
492    #[test]
493    fn an_undecodable_default_track_forces_a_transcode() {
494        // The reported bug: one E-AC-3 track, which the webview cannot decode.
495        assert!(audio_forces_transcode(&[(Some("eac3"), false)]));
496        assert!(audio_forces_transcode(&[(Some("ac3"), true)]));
497    }
498
499    #[test]
500    fn a_decodable_track_is_left_to_direct_play() {
501        // Never spend server CPU on a file that already plays.
502        assert!(!audio_forces_transcode(&[(Some("aac"), true)]));
503        assert!(!audio_forces_transcode(&[(Some("mp3"), false)]));
504    }
505
506    #[test]
507    fn the_default_track_decides_not_the_first() {
508        // The webview plays the default track, so that is the one that has to be
509        // decodable — a supported track further down does not save us.
510        assert!(audio_forces_transcode(&[
511            (Some("aac"), false),
512            (Some("eac3"), true)
513        ]));
514        assert!(!audio_forces_transcode(&[
515            (Some("eac3"), false),
516            (Some("aac"), true)
517        ]));
518    }
519
520    #[test]
521    fn with_no_default_marked_the_first_track_decides() {
522        // Jellyfin leaves IsDefault false on every stream for some files; the
523        // server then serves the first, so judge that one.
524        assert!(audio_forces_transcode(&[
525            (Some("eac3"), false),
526            (Some("aac"), false)
527        ]));
528    }
529
530    #[test]
531    fn a_source_with_no_audio_is_not_transcoded() {
532        // Nothing to rescue, and a transcode would not create audio.
533        assert!(!audio_forces_transcode(&[]));
534    }
535
536    #[test]
537    fn an_unknown_codec_is_not_second_guessed() {
538        // The server did not tell us the codec; assuming the worst would
539        // transcode files that play perfectly.
540        assert!(!audio_forces_transcode(&[(None, true)]));
541    }
542
543    /// The download path needs the codec itself, not just the verdict, so it can
544    /// tell the server what to re-encode. It picks the same track the streaming
545    /// verdict is formed from — one rule, one place.
546    ///
547    /// TRACES: UR-071 | DR-171 | UT-166
548    #[test]
549    fn the_served_codec_is_the_one_the_verdict_is_formed_from() {
550        assert_eq!(
551            served_audio_codec(&[(Some("aac"), false), (Some("eac3"), true)]),
552            Some("eac3")
553        );
554        assert_eq!(
555            served_audio_codec(&[(Some("eac3"), false), (Some("aac"), false)]),
556            Some("eac3")
557        );
558        assert_eq!(served_audio_codec(&[]), None);
559        assert_eq!(served_audio_codec(&[(None, true)]), None);
560    }
561
562    #[test]
563    fn a_dolby_device_does_not_advertise_dolby_for_video() {
564        // The bug: a Motorola reporting c2.dolby.eac3.decoder direct-played
565        // E-AC-3 into a webview that cannot decode it — silent video, on that
566        // device only.
567        let codecs = video_audio_codecs("aac,ac3,amrnb,amrwb,eac3,flac,mp3,opus,pcm,vorbis");
568        assert_eq!(codecs, "aac,flac,mp3,opus,vorbis");
569    }
570
571    #[test]
572    fn codecs_the_webview_cannot_decode_are_dropped() {
573        // AMR and raw PCM come from the AOSP set, so this is not a Dolby-only
574        // problem — it is just rarer content.
575        assert_eq!(video_audio_codecs("amrnb,amrwb,pcm,aac"), "aac");
576        assert_eq!(video_audio_codecs("dts,truehd,mp3"), "mp3");
577    }
578
579    #[test]
580    fn a_list_the_webview_fully_supports_is_untouched() {
581        assert_eq!(
582            video_audio_codecs("aac,mp3,opus,vorbis,flac"),
583            "aac,mp3,opus,vorbis,flac"
584        );
585    }
586
587    #[test]
588    fn nothing_decodable_still_claims_aac() {
589        // Claiming an empty list invites the server to give up rather than
590        // transcode. AAC is universally decodable, so ask for it.
591        assert_eq!(video_audio_codecs("eac3,dts"), "aac");
592        assert_eq!(video_audio_codecs(""), "aac");
593    }
594
595    #[test]
596    fn spacing_and_case_in_the_platform_list_are_tolerated() {
597        // The list is assembled from MediaCodecList strings; do not let
598        // whitespace decide whether the user gets sound.
599        assert_eq!(video_audio_codecs("aac, EAC3 , Mp3"), "aac,mp3");
600    }
601
602    #[test]
603    fn an_unknown_route_falls_back_to_stereo() {
604        // Codec detection has not run yet, or the platform has no answer. Never
605        // claim surround we have not seen — every sink can do stereo.
606        assert_eq!(clamp_max_audio_channels(None), 2);
607    }
608
609    #[test]
610    fn a_zero_reading_is_not_a_capability() {
611        // A route that has not been established reports 0; taking that literally
612        // would advertise a device with no audio at all.
613        assert_eq!(clamp_max_audio_channels(Some(0)), 2);
614    }
615
616    #[test]
617    fn a_stereo_sink_is_reported_as_stereo() {
618        // The phone speaker / Bluetooth headset case: the server must downmix
619        // 5.1 rather than direct-play it.
620        assert_eq!(clamp_max_audio_channels(Some(2)), 2);
621    }
622
623    #[test]
624    fn a_surround_route_keeps_its_channels() {
625        // HDMI to an AVR: 5.1 and 7.1 direct play stay available.
626        assert_eq!(clamp_max_audio_channels(Some(6)), 6);
627        assert_eq!(clamp_max_audio_channels(Some(8)), 8);
628    }
629
630    #[test]
631    fn an_absurd_reading_is_capped_rather_than_forwarded() {
632        // Some drivers report the AudioTrack maximum rather than the route's.
633        assert_eq!(clamp_max_audio_channels(Some(32)), 8);
634    }
635
636    #[test]
637    fn a_mono_route_is_taken_at_its_word() {
638        assert_eq!(clamp_max_audio_channels(Some(1)), 1);
639    }
640}