//! What counts as audio, and what the player can actually decode. //! //! These were seven separate hand-kept lists — the library scanner, the share //! index, the row pruner, the loudness pass, and three in the frontend — and //! they disagreed. The scanner indexed `.aiff`, the pruner did not recognise it //! and DELETED the rows on the next startup. The share index knew `.ape`, the //! scanner did not. Every list named `.opus`, which nothing here can decode. //! //! One table now, with a distinction the old lists could not express: //! //! - [`PLAYABLE`] — the decoder can turn this into sound. Derived from the //! codecs symphonia is actually built with (`all-codecs` + `all-formats`: //! aac, adpcm, alac, flac, mp1/2/3, pcm, vorbis; caf, isomp4, mkv, ogg, //! aiff, wav) and verified against real files by //! `cargo run -p player --example probe_formats`. //! - [`RECOGNISED`] — audio we can index, share, download and show, but cannot //! play. Naming these is the point: a file the app can see and refuses to //! play with a clear reason is honest, whereas leaving it out of the tables //! makes it invisible, and claiming it plays makes the player fail at the //! moment the user presses play. //! //! Adding a codec means adding a decoder, not editing a list. /// Extensions the player can decode. Verified, not assumed. pub const PLAYABLE: &[&str] = &[ // FLAC "flac", // MPEG audio — the bundle provides layers I, II and III "mp3", "mp2", "mp1", // ISO/MP4 container: AAC and ALAC live here "m4a", "m4b", "mp4", "m4r", // Raw AAC (ADTS) "aac", // Ogg container, Vorbis inside "ogg", "oga", // RIFF: PCM and ADPCM "wav", "wave", // AIFF / AIFF-C, also via the RIFF reader "aiff", "aif", "aifc", // Core Audio Format "caf", // Matroska — FLAC/Vorbis/AAC in a different wrapper "mka", "webm", ]; /// Audio we recognise but cannot decode. Indexed and shared; never played. /// /// `wv` earns its place here more than any of them. A WavPack file that /// carries the original RIFF header in its wrapper is probed as raw PCM, so /// the decoder does not fail — it reads compressed bytes as samples and emits /// NaN into the mixer. Refusing it by extension is the only thing that stops /// that, because the decoder itself reports success. pub const RECOGNISED: &[&str] = &[ "opus", // no Opus decoder is built in "wma", "ape", "wv", "mpc", "tta", "shn", "dsf", "dff", "spx", "ra", "au", "amr", ]; /// Is this an audio file at all — playable or merely recognised? /// /// The scanner, the share index and the row pruner must all answer this the /// same way, or a file is indexed by one and deleted by another. pub fn is_audio_ext(ext: &str) -> bool { let e = ext.to_ascii_lowercase(); PLAYABLE.contains(&e.as_str()) || RECOGNISED.contains(&e.as_str()) } /// Can the player turn this into sound? pub fn is_playable_ext(ext: &str) -> bool { PLAYABLE.contains(&ext.to_ascii_lowercase().as_str()) } /// Why this file will not play, in words that can go on screen. pub fn unplayable_reason(ext: &str) -> Option<&'static str> { match ext.to_ascii_lowercase().as_str() { "opus" => Some("Opus isn't supported yet — this build has no Opus decoder"), "wv" => Some("WavPack isn't supported yet"), "ape" => Some("Monkey's Audio (APE) isn't supported yet"), "wma" => Some("Windows Media Audio isn't supported yet"), "mpc" => Some("Musepack isn't supported yet"), "tta" => Some("True Audio isn't supported yet"), "shn" => Some("Shorten isn't supported yet"), "dsf" | "dff" => Some("DSD isn't supported yet"), e if RECOGNISED.contains(&e) => Some("this format isn't supported yet"), _ => None, } } fn ext_of(path: &std::path::Path) -> Option { path.extension() .and_then(|e| e.to_str()) .map(|e| e.to_ascii_lowercase()) } pub fn is_audio_path(path: &std::path::Path) -> bool { ext_of(path).is_some_and(|e| is_audio_ext(&e)) } pub fn is_playable_path(path: &std::path::Path) -> bool { ext_of(path).is_some_and(|e| is_playable_ext(&e)) } /// Why this path will not play. `None` means "go ahead and try". pub fn unplayable_path_reason(path: &std::path::Path) -> Option<&'static str> { unplayable_reason(&ext_of(path)?) } #[cfg(test)] mod tests { use super::*; use std::path::Path; /// The bug the single table exists to prevent: the scanner indexed a /// format the pruner did not recognise, so importing an AIFF album put it /// in the library and the next startup silently deleted it. #[test] fn everything_indexed_survives_the_pruner() { for ext in PLAYABLE.iter().chain(RECOGNISED.iter()) { assert!(is_audio_ext(ext), "{ext} indexed but not recognised as audio"); } // The extensions that were actually in the divergence. for ext in ["aiff", "aif", "ape", "wv", "opus"] { assert!(is_audio_ext(ext), "{ext} must be recognised"); } } /// A format cannot be both — every entry has one answer. #[test] fn no_extension_is_in_both_tables() { for e in PLAYABLE { assert!(!RECOGNISED.contains(e), "{e} is in both tables"); } } /// Recognised-but-not-playable must carry a reason, or the UI has nothing /// to say when it refuses. #[test] fn every_unplayable_format_can_explain_itself() { for e in RECOGNISED { assert!(unplayable_reason(e).is_some(), "{e} has no reason"); } for e in PLAYABLE { assert!(unplayable_reason(e).is_none(), "{e} plays but claims it cannot"); } } /// WavPack is the dangerous one: the decoder does NOT fail on it, so only /// an extension check keeps NaN out of the mixer. #[test] fn wavpack_is_refused_before_the_decoder_sees_it() { assert!(!is_playable_ext("wv")); assert!(is_audio_ext("wv")); assert_eq!( unplayable_path_reason(Path::new("C:\\m\\a.WV")), Some("WavPack isn't supported yet"), "case must not decide whether audio plays" ); } #[test] fn case_and_paths() { assert!(is_playable_ext("FLAC")); assert!(is_playable_path(Path::new("C:\\m\\01 - Song.Mp3"))); assert!(!is_audio_path(Path::new("C:\\m\\cover.jpg"))); assert!(!is_audio_path(Path::new("C:\\m\\noext"))); } }