use std::cell::RefCell;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::io::Write;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
#[derive(Debug, Clone, PartialEq)]
pub struct Player {
cmd: String,
args: Vec<String>,
feed: Feed,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Feed {
Stdin,
Path,
}
#[derive(Debug)]
pub struct Sound {
bytes: Vec<u8>,
player: Player,
path_for_player: Option<PathBuf>,
children: RefCell<Vec<Child>>,
}
fn candidates() -> Vec<Player> {
let s = |v: &str| v.to_string();
let argv = |v: &[&str]| v.iter().map(|a| a.to_string()).collect();
vec![
Player {
cmd: s("ffplay"),
args: argv(&["-nodisp", "-autoexit", "-loglevel", "quiet", "-i", "pipe:0"]),
feed: Feed::Stdin,
},
Player {
cmd: s("mpv"),
args: argv(&["--no-video", "--really-quiet", "-"]),
feed: Feed::Stdin,
},
Player {
cmd: s("aplay"),
args: argv(&["-q", "-"]),
feed: Feed::Stdin,
},
Player {
cmd: s("pw-cat"),
args: argv(&["-p", "-"]),
feed: Feed::Stdin,
},
Player {
cmd: s("afplay"),
args: Vec::new(),
feed: Feed::Path,
},
]
}
pub fn load(spec: &str) -> Option<Sound> {
let bytes = if is_url(spec) {
load_url(spec)?
} else {
match std::fs::read(spec) {
Ok(b) if !b.is_empty() => b,
Ok(_) => {
note(&format!("sound file `{spec}` is empty; playing silently"));
return None;
}
Err(e) => {
note(&format!(
"cannot read sound `{spec}`: {e}; playing silently"
));
return None;
}
}
};
let player = match detect_player() {
Some(p) => p,
None => {
note("no audio player found (ffplay/mpv/aplay/pw-cat/afplay); playing silently");
return None;
}
};
let (bytes, path_for_player) = match player.feed {
Feed::Path => {
let p = temp_path(&format!("jiwa-play-{}", hash_hex(spec)), spec);
match std::fs::write(&p, &bytes) {
Ok(()) => (Vec::new(), Some(p)),
Err(e) => {
note(&format!(
"cannot stage sound for player: {e}; playing silently"
));
return None;
}
}
}
Feed::Stdin => (bytes, None),
};
Some(Sound {
bytes,
player,
path_for_player,
children: RefCell::new(Vec::new()),
})
}
impl Sound {
pub fn play(&self) {
let mut children = self.children.borrow_mut();
children.retain_mut(|c| matches!(c.try_wait(), Ok(None)));
let mut cmd = Command::new(&self.player.cmd);
cmd.args(&self.player.args)
.stdout(Stdio::null())
.stderr(Stdio::null());
match self.player.feed {
Feed::Stdin => {
cmd.stdin(Stdio::piped());
if let Ok(mut child) = cmd.spawn() {
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(&self.bytes);
}
children.push(child);
}
}
Feed::Path => {
cmd.stdin(Stdio::null());
if let Some(path) = &self.path_for_player {
cmd.arg(path);
if let Ok(child) = cmd.spawn() {
children.push(child);
}
}
}
}
}
}
pub fn is_url(spec: &str) -> bool {
let lower = spec.to_ascii_lowercase();
lower.starts_with("http://") || lower.starts_with("https://")
}
fn load_url(url: &str) -> Option<Vec<u8>> {
let path = temp_path(&format!("jiwa-sound-{}", hash_hex(url)), url);
if let Ok(b) = std::fs::read(&path) {
if !b.is_empty() {
return Some(b);
}
}
if !fetch(url, &path) {
note(&format!(
"could not download sound `{url}` (need curl or wget); playing silently"
));
return None;
}
match std::fs::read(&path) {
Ok(b) if !b.is_empty() => Some(b),
_ => {
note(&format!(
"downloaded sound `{url}` was empty; playing silently"
));
None
}
}
}
fn fetch(url: &str, dest: &std::path::Path) -> bool {
let curl = Command::new("curl")
.args(["-fsSL", "-o"])
.arg(dest)
.arg(url)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
if matches!(curl, Ok(s) if s.success()) {
return true;
}
let wget = Command::new("wget")
.arg("-qO")
.arg(dest)
.arg(url)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
matches!(wget, Ok(s) if s.success())
}
pub fn detect_player() -> Option<Player> {
candidates().into_iter().find(|p| player_exists(&p.cmd))
}
fn player_exists(cmd: &str) -> bool {
Command::new(cmd)
.arg("--version")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map(|mut child| {
let _ = child.wait();
true
})
.unwrap_or(false)
}
pub fn temp_path(stem: &str, source: &str) -> PathBuf {
let dir = std::env::var_os("XDG_RUNTIME_DIR")
.or_else(|| std::env::var_os("TMPDIR"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/tmp"));
let mut name = stem.to_string();
if let Some(ext) = guess_extension(source) {
name.push('.');
name.push_str(&ext);
}
dir.join(name)
}
fn guess_extension(source: &str) -> Option<String> {
let trimmed = source
.split(['?', '#'])
.next()
.unwrap_or(source)
.trim_end_matches('/');
let tail = trimmed.rsplit(['/', '\\']).next().unwrap_or(trimmed);
let (_, ext) = tail.rsplit_once('.')?;
if ext.is_empty() || ext.len() > 5 || !ext.chars().all(|c| c.is_ascii_alphanumeric()) {
return None;
}
Some(ext.to_ascii_lowercase())
}
pub fn hash_hex(s: &str) -> String {
let mut h = DefaultHasher::new();
s.hash(&mut h);
format!("{:016x}", h.finish())
}
fn note(msg: &str) {
eprintln!("jiwa: {msg}");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_url_detects_http_and_https() {
assert!(is_url("http://example.com/a.wav"));
assert!(is_url("https://example.com/a.wav"));
assert!(!is_url("/tmp/a.wav"));
assert!(!is_url("a.wav"));
assert!(!is_url("ftp://example.com/a.wav"));
assert!(!is_url("./my-http-sound.wav"));
}
#[test]
fn hash_hex_is_stable_and_16_hex_digits() {
let a = hash_hex("https://example.com/clack.wav");
let b = hash_hex("https://example.com/clack.wav");
assert_eq!(a, b, "same input -> same hash");
assert_eq!(a.len(), 16, "u64 as zero-padded hex is 16 chars");
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(a, hash_hex("https://example.com/blip.wav"));
}
#[test]
fn guess_extension_from_path_and_url() {
assert_eq!(guess_extension("clack.wav").as_deref(), Some("wav"));
assert_eq!(guess_extension("/a/b/blip.OGG").as_deref(), Some("ogg"));
assert_eq!(
guess_extension("https://x.test/s.mp3?token=1").as_deref(),
Some("mp3")
);
assert_eq!(
guess_extension("https://x.test/s.wav#frag").as_deref(),
Some("wav")
);
assert_eq!(guess_extension("https://x.test/sound"), None);
assert_eq!(guess_extension("https://x.test/dir/"), None);
assert_eq!(guess_extension("noext"), None);
assert_eq!(guess_extension("a.verylongext"), None);
assert_eq!(guess_extension("a.b_c"), None);
}
#[test]
fn temp_path_uses_xdg_runtime_dir_when_set() {
let p = temp_path("jiwa-sound-deadbeef", "https://x.test/clack.wav");
let name = p.file_name().unwrap().to_string_lossy();
assert_eq!(name, "jiwa-sound-deadbeef.wav");
let p2 = temp_path("jiwa-sound-cafe", "https://x.test/clack");
assert_eq!(p2.file_name().unwrap().to_string_lossy(), "jiwa-sound-cafe");
}
#[test]
fn candidates_are_in_documented_priority_order() {
let cands = candidates();
let names: Vec<&str> = cands.iter().map(|p| p.cmd.as_str()).collect();
assert_eq!(names, ["ffplay", "mpv", "aplay", "pw-cat", "afplay"]);
assert_eq!(cands.last().unwrap().feed, Feed::Path);
assert!(cands[..cands.len() - 1]
.iter()
.all(|p| p.feed == Feed::Stdin));
}
#[test]
fn ffplay_args_read_from_stdin_pipe() {
let cands = candidates();
let ffplay = &cands[0];
assert_eq!(ffplay.cmd, "ffplay");
assert_eq!(ffplay.feed, Feed::Stdin);
assert!(ffplay.args.contains(&"pipe:0".to_string()));
assert!(ffplay.args.contains(&"-nodisp".to_string()));
}
#[test]
fn load_missing_local_path_is_none() {
assert!(load("/nonexistent/jiwa-test-sound.wav").is_none());
}
#[test]
fn guess_extension_edge_cases() {
assert_eq!(guess_extension("a."), None);
assert_eq!(guess_extension(".bashrc"), None);
assert_eq!(guess_extension(".wav").as_deref(), Some("wav"));
assert_eq!(guess_extension("a.tar.gz").as_deref(), Some("gz"));
assert_eq!(guess_extension("C:\\x\\y.WAV").as_deref(), Some("wav"));
assert_eq!(guess_extension(""), None);
assert_eq!(guess_extension("."), None);
assert_eq!(guess_extension("a.123").as_deref(), Some("123"));
assert_eq!(guess_extension("a.fffff").as_deref(), Some("fffff"));
assert_eq!(guess_extension("a.ffffff"), None);
}
#[test]
fn hash_hex_handles_empty_string() {
let a = hash_hex("");
assert_eq!(a, hash_hex(""), "deterministic for the empty string");
assert_eq!(a.len(), 16);
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn is_url_is_case_insensitive() {
assert!(is_url("HTTP://x"));
assert!(is_url("HTTPS://X"));
assert!(is_url("HtTpS://Example.com/a.wav"));
assert!(!is_url("ftp://example.com/a.wav"));
assert!(!is_url("./my-http-sound.wav"));
}
#[test]
fn load_empty_local_file_is_none() {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("jiwa-empty-{}-{}.wav", std::process::id(), n));
std::fs::write(&path, b"").expect("create empty temp file");
let got = load(path.to_str().unwrap());
let _ = std::fs::remove_file(&path);
assert!(got.is_none(), "empty local file must load as None");
}
}