🏗️ Build and Test JellyTau / Run Tests (push) Successful in 19m31s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m28s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
Build & Release / Run Tests (push) Successful in 6m47s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m6s
Build & Release / Build Linux (push) Successful in 19m57s
Build & Release / Build Windows (push) Successful in 14m14s
Build & Release / Build Android (push) Successful in 30m26s
Build & Release / Create Release (push) Successful in 19s
The DR-149 row lost an index race with a parallel session's edit of the same file, so the previous commit carried the count assertion (DR 144, total 282) without the requirement it counts — a clean checkout of that commit failed `bun run test` against its own requirements.md. The parallel session also reached UT-143 and UT-147 for subtitle work, which collided with the UT-143 used for the client-side transcode tests. Those move to UT-148, in the table and in the device_profile TRACES comments, so no two requirements share an ID.
275 lines
11 KiB
Rust
275 lines
11 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";
|
|
|
|
/// 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 video does not play through ExoPlayer. Both Android and Linux render it
|
|
/// in a webview `<video>` element, 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.
|
|
///
|
|
/// 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 {
|
|
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(",")
|
|
}
|
|
}
|
|
|
|
/// 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: the
|
|
/// default, or the first when none is marked. 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 {
|
|
let served = streams
|
|
.iter()
|
|
.find(|(_, is_default)| *is_default)
|
|
.or_else(|| streams.first());
|
|
|
|
match served {
|
|
Some((Some(codec), _)) => !webview_can_decode_audio(codec),
|
|
// No audio at all, or a codec the server did not name: leave it alone.
|
|
Some((None, _)) | None => false,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[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)]));
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|