use std::path::Path;
use std::process::{Command, Stdio};
fn playr(db: &Path, args: &[&str]) {
Command::new(env!("CARGO_BIN_EXE_playr"))
.env("XDG_CONFIG_HOME", db.parent().unwrap())
.env("XDG_DATA_HOME", db.parent().unwrap())
.arg("--db")
.arg(db)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
}
#[test]
fn only_scan_creates_the_library() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("playr/library.db");
let file = dir.path().join("x.mp3");
std::fs::write(&file, b"x").unwrap();
for args in [
vec![file.to_str().unwrap()],
vec![],
vec!["search", "anything"],
vec!["playlist", "late"],
vec!["playlists"],
] {
playr(&db, &args);
assert!(
!db.exists(),
"`playr {}` created the library",
args.join(" ")
);
}
playr(&db, &["scan", dir.path().to_str().unwrap()]);
assert!(db.exists(), "`playr scan` did not create the library");
}
fn output(db: &Path, args: &[&str]) -> (Option<i32>, String, String) {
let out = Command::new(env!("CARGO_BIN_EXE_playr"))
.env("XDG_CONFIG_HOME", db.parent().unwrap())
.env("XDG_DATA_HOME", db.parent().unwrap())
.arg("--db")
.arg(db)
.args(args)
.stdin(Stdio::null())
.output()
.unwrap();
(
out.status.code(),
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
#[test]
fn bad_arguments_are_refused_before_anything_runs() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("library.db");
for (args, error) in [
(&["--bogus"][..], "unexpected argument '--bogus'"),
(&["playlists", "--bogus"], "unexpected argument '--bogus'"),
(&["formats", "extra"], "unexpected argument 'extra'"),
(&["search", "rock", "--db"], "a value is required for '--db"),
(&["search"], "<QUERY>"),
(&["scan", "--bogus"], "unexpected argument '--bogus'"),
] {
let (code, _, stderr) = output(&db, args);
assert_eq!(code, Some(2), "playr {args:?}: {stderr}");
assert!(stderr.contains(error), "playr {args:?}: {stderr}");
}
let (code, _, stderr) = output(&db, &["scan"]);
assert_eq!(code, Some(1), "bare scan with no library: {stderr}");
assert!(
stderr.contains("no directories to scan"),
"bare scan with no library: {stderr}"
);
let (code, _, stderr) = output(&db, &["prune"]);
assert_eq!(code, Some(1), "bare prune with no library: {stderr}");
assert!(
stderr.contains("no library"),
"bare prune with no library: {stderr}"
);
assert!(!db.exists());
}
#[test]
fn each_subcommand_has_its_own_help() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("library.db");
for (command, text) in [
("scan", "scan [OPTIONS] [DIR]..."),
("prune", "prune [OPTIONS] [DIR]..."),
("search", "--json"),
] {
let (code, stdout, _) = output(&db, &[command, "--help"]);
assert_eq!(code, Some(0));
assert!(stdout.contains(text), "playr {command} --help: {stdout}");
}
assert!(!db.exists(), "playr scan --help scanned");
}
#[test]
fn search_json_prints_every_match_with_its_columns() {
use playr_core::db::{self, Track};
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("library.db");
let conn = db::open(&db_path).unwrap();
for track in [
Track {
path: "/m/peace piece.flac".into(),
title: Some("Peace Piece".into()),
artist: Some("Bill Evans".into()),
track_no: Some(3),
duration_ms: Some(393_000),
mtime: 1,
size: 2,
..Default::default()
},
Track {
path: "/m/evans-untagged.wav".into(),
mtime: 1,
size: 2,
..Default::default()
},
Track {
path: "/m/other.flac".into(),
title: Some("Other".into()),
mtime: 1,
size: 2,
..Default::default()
},
] {
db::upsert(&conn, &track).unwrap();
}
drop(conn);
let (code, stdout, stderr) = output(&db_path, &["search", "--json", "evans"]);
assert_eq!(code, Some(0), "{stderr}");
let rows: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let rows = rows.as_array().unwrap();
let paths: Vec<&str> = rows.iter().map(|r| r["path"].as_str().unwrap()).collect();
assert_eq!(paths.len(), 2, "{stdout}");
assert!(paths.contains(&"/m/evans-untagged.wav"), "{stdout}");
let piece = rows
.iter()
.find(|r| r["path"] == "/m/peace piece.flac")
.unwrap();
assert_eq!(piece["title"], "Peace Piece");
assert_eq!(piece["artist"], "Bill Evans");
assert_eq!(piece["track_no"], 3);
assert_eq!(piece["duration_ms"], 393_000);
assert!(piece["album"].is_null(), "{piece}");
assert!(piece["id"].as_i64().unwrap() > 0);
assert_eq!(piece.as_object().unwrap().len(), 16, "{piece}");
let (code, stdout, _) = output(&db_path, &["search", "--json", "coltrane"]);
assert_eq!((code, stdout.trim()), (Some(1), "[]"));
let (code, stdout, stderr) = output(&db_path, &["search", "--json", "--", "--db"]);
assert_eq!((code, stdout.trim()), (Some(1), "[]"), "{stderr}");
}
fn with_config(dir: &Path, args: &[&str]) -> (bool, String) {
let out = Command::new(env!("CARGO_BIN_EXE_playr"))
.env("XDG_CONFIG_HOME", dir)
.env("XDG_DATA_HOME", dir)
.arg("--db")
.arg(dir.join("library.db"))
.args(args)
.stdin(Stdio::null())
.output()
.unwrap();
(
out.status.success(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
#[test]
fn bad_settings_stop_playr_before_it_starts_and_list_every_error() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("playr")).unwrap();
let default = dir.path().join("playr/settings.toml");
std::fs::write(&default, "volume = 50\nfrob = 1\n[keys]\nd = 'remove'\n").unwrap();
let (ok, stderr) = with_config(dir.path(), &[]);
assert!(!ok);
let path = default.display();
assert!(
stderr.contains(&format!("playr: {path}: line 2: unknown setting: frob")),
"{stderr}"
);
assert!(
stderr.contains(&format!("playr: {path}: line 4: :remove works")),
"{stderr}"
);
assert!(!stderr.contains("terminal"), "{stderr}");
let other = dir.path().join("other.toml");
std::fs::write(&other, "volume = 50\n").unwrap();
let (_, stderr) = with_config(dir.path(), &["--settings", other.to_str().unwrap()]);
assert!(
!stderr.contains("line 2"),
"the default file was read: {stderr}"
);
let (ok, stderr) = with_config(dir.path(), &["--settings", "/nonexistent/playr.toml"]);
assert!(!ok);
assert!(
stderr.contains("playr: /nonexistent/playr.toml:"),
"{stderr}"
);
}
#[test]
fn a_command_that_does_not_play_warns_of_bad_settings_and_runs() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("playr")).unwrap();
let default = dir.path().join("playr/settings.toml");
std::fs::write(&default, "frob = 1\n").unwrap();
let warning = format!(
"playr: warning: {}: line 1: unknown setting: frob",
default.display()
);
for args in [&["formats"][..], &["playlists"]] {
let (ok, stderr) = with_config(dir.path(), args);
assert!(ok, "`playr {}`: {stderr}", args.join(" "));
assert!(
stderr.contains(&warning),
"`playr {}`: {stderr}",
args.join(" ")
);
}
let (ok, stderr) = with_config(dir.path(), &["search", "anything"]);
assert!(!ok);
assert!(!stderr.contains("warning"), "{stderr}");
assert!(!stderr.contains("nothing matches"), "{stderr}");
}
#[test]
fn a_missing_home_variable_does_not_stop_playr() {
let dir = tempfile::tempdir().unwrap();
let settings = dir.path().join("settings.toml");
std::fs::write(&settings, "frob = 1\n").unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_playr"))
.env_remove("HOME")
.env_remove("XDG_CONFIG_HOME")
.env("XDG_DATA_HOME", dir.path())
.arg("--db")
.arg(dir.path().join("library.db"))
.arg("--settings")
.arg(&settings)
.stdin(Stdio::null())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!stderr.contains("panicked"), "{stderr}");
assert!(stderr.contains("line 1: unknown setting: frob"), "{stderr}");
}
#[test]
fn a_second_playr_is_refused_but_reading_the_library_is_not() {
use playr_app::instance::{claim_at, ALREADY_RUNNING};
let dir = tempfile::tempdir().unwrap();
let data = dir.path().join("data");
let db = dir.path().join("library.db");
let songs = dir.path().join("music");
std::fs::create_dir(&songs).unwrap();
let _running = claim_at(&data.join("playr/instance.lock")).unwrap();
let run = |args: &[&str]| {
Command::new(env!("CARGO_BIN_EXE_playr"))
.env("XDG_DATA_HOME", &data)
.env("XDG_CONFIG_HOME", dir.path())
.arg("--db")
.arg(&db)
.args(args)
.stdin(Stdio::null())
.output()
.unwrap()
};
for args in [vec!["scan", songs.to_str().unwrap()], vec![]] {
let out = run(&args);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success(), "playr {args:?} ran");
assert!(stderr.contains(ALREADY_RUNNING), "playr {args:?}: {stderr}");
}
assert!(!db.exists(), "a refused scan created the library");
for args in [&["playlists"][..], &["formats"], &["search", "--json", "x"]] {
let out = run(args);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains(ALREADY_RUNNING),
"playr {args:?}: {stderr}"
);
}
}
#[test]
fn scan_keeps_missing_tracks_and_prune_removes_them_with_their_marks() {
use playr_core::db::{self, query, Track};
let dir = tempfile::tempdir().unwrap();
let music = dir.path().canonicalize().unwrap().join("music");
std::fs::create_dir(&music).unwrap();
let db_path = dir.path().join("library.db");
let gone = music.join("gone.flac").to_string_lossy().into_owned();
let conn = db::open(&db_path).unwrap();
let track = Track {
path: gone.clone(),
mtime: 1,
size: 1,
..Default::default()
};
db::upsert(&conn, &track).unwrap();
query::add_mark(
&conn,
&gone,
query::Mark {
frame: 1,
rate: 8000,
},
)
.unwrap();
drop(conn);
let (code, stdout, stderr) = output(&db_path, &["scan", music.to_str().unwrap()]);
assert_eq!(code, Some(0), "{stderr}");
assert!(stdout.contains("1 tracks missing"), "{stdout}");
let conn = db::open(&db_path).unwrap();
assert_eq!(query::count(&conn).unwrap(), 1, "a scan removed a track");
let (code, stdout, stderr) = output(&db_path, &["prune", music.to_str().unwrap()]);
assert_eq!(code, Some(0), "{stderr}");
assert!(stdout.contains("removed 1 tracks and 1 marks"), "{stdout}");
assert_eq!(query::count(&conn).unwrap(), 0);
assert!(query::marks(&conn, &gone).unwrap().is_empty());
let other = dir.path().join("other.db");
let (code, _, stderr) = output(&other, &["prune", music.to_str().unwrap()]);
assert_eq!(code, Some(1), "{stderr}");
assert!(stderr.contains("no library"), "{stderr}");
assert!(!other.exists());
}
#[test]
fn roots_lists_what_was_scanned_and_rm_forgets_it() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("playr/library.db");
let songs = dir.path().join("music");
std::fs::create_dir(&songs).unwrap();
std::fs::write(songs.join("a.wav"), b"not audio").unwrap();
let (code, _, stderr) = output(&db, &["roots"]);
assert_eq!(code, Some(1), "{stderr}");
assert!(stderr.contains("no library"), "{stderr}");
playr(&db, &["scan", songs.to_str().unwrap()]);
let canonical = songs.canonicalize().unwrap();
let (code, stdout, _) = output(&db, &["roots"]);
assert_eq!(code, Some(0));
assert_eq!(stdout.trim(), canonical.display().to_string());
let (code, _, stderr) = output(&db, &["roots", "rm", dir.path().to_str().unwrap()]);
assert_eq!(code, Some(1), "{stderr}");
assert!(stderr.contains("not one of the library's"), "{stderr}");
let (code, stdout, stderr) = output(&db, &["roots", "rm", songs.to_str().unwrap()]);
assert_eq!(code, Some(0), "{stderr}");
assert!(stdout.contains("forgot"), "{stdout}");
let (_, stdout, _) = output(&db, &["roots"]);
assert!(stdout.trim().is_empty(), "still listed: {stdout}");
}
fn sine_wav(path: &Path, bits: u16, pad: u32) {
let spec = hound::WavSpec {
channels: 1,
sample_rate: 44100,
bits_per_sample: bits,
sample_format: hound::SampleFormat::Int,
};
let mut w = hound::WavWriter::create(path, spec).unwrap();
let full = ((1i64 << (bits - 1)) - 1) as f64;
for i in 0..44100 {
let v = (i as f64 * 0.06).sin() * 0.25 * full;
w.write_sample(((v as i64 >> pad) << pad) as i32).unwrap();
}
w.finalize().unwrap();
}
#[test]
fn analyze_records_findings_and_skips_what_is_current() {
let dir = tempfile::tempdir().unwrap();
let music = dir.path().join("music");
std::fs::create_dir(&music).unwrap();
sine_wav(&music.join("plain.wav"), 16, 0);
sine_wav(&music.join("padded.wav"), 24, 8);
let db = dir.path().join("library.db");
let (code, _, stderr) = output(&db, &["analyze"]);
assert_eq!(code, Some(1), "{stderr}");
assert!(stderr.contains("no library"), "{stderr}");
playr(&db, &["scan", music.to_str().unwrap()]);
let _running = playr_app::instance::claim_at(&dir.path().join("playr/instance.lock")).unwrap();
let (code, stdout, stderr) = output(&db, &["analyze", "--jobs", "2"]);
assert_eq!(code, Some(0), "{stderr}");
assert!(stdout.contains("analysing 2 of 2 tracks"), "{stdout}");
assert!(stdout.contains("padded"), "{stdout}");
assert!(stdout.contains("16 of 24 bits used"), "{stdout}");
assert!(stdout.contains("2 tracks analysed; 1 padded"), "{stdout}");
let (code, _, stderr) = output(&db, &["scan", music.to_str().unwrap()]);
assert_eq!(code, Some(1), "scan ran beside a playr: {stderr}");
let (_, stdout, _) = output(&db, &["analyze"]);
assert!(stdout.contains("analysing 0 of 2 tracks"), "{stdout}");
let (_, stdout, _) = output(
&db,
&[
"analyze",
"--force",
music.join("plain.wav").to_str().unwrap(),
],
);
assert!(stdout.contains("analysing 1 of 1 tracks"), "{stdout}");
let (code, stdout, stderr) = output(&db, &["analyze", "--report", "--json"]);
assert_eq!(code, Some(0), "{stderr}");
let report: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let tracks = report["tracks"].as_array().unwrap();
assert_eq!(tracks.len(), 2);
for t in tracks {
let lufs = t["loudness"].as_f64().unwrap();
assert!((lufs + 15.74).abs() < 0.05, "{t}");
}
let padded = tracks
.iter()
.find(|t| t["path"].as_str().unwrap().ends_with("padded.wav"))
.unwrap();
assert_eq!(padded["findings"][0]["finding"], "padded");
assert_eq!(report["not_analysed"], 0);
}
#[test]
fn playlists_go_out_and_come_back_as_m3u() {
use playr_core::db::{self, query, Track};
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("library.db");
let music = std::fs::canonicalize(dir.path()).unwrap().join("music");
std::fs::create_dir(&music).unwrap();
let conn = db::open(&db_path).unwrap();
let ids: Vec<i64> = ["a.flac", "b.flac"]
.iter()
.map(|name| {
let path = music.join(name);
std::fs::write(&path, b"x").unwrap();
let t = Track {
path: path.to_string_lossy().into_owned(),
mtime: 1,
size: 1,
..Default::default()
};
db::upsert(&conn, &t).unwrap()
})
.collect();
let mut conn = conn;
query::save_playlist(&mut conn, "late", &[ids[1], ids[0]]).unwrap();
drop(conn);
let list = dir.path().join("late.m3u8");
let list_arg = list.to_str().unwrap();
let (code, out, _) = output(&db_path, &["export", "late"]);
assert_eq!(code, Some(0));
let b = music.join("b.flac");
assert!(out.starts_with("#EXTM3U\n#PLAYLIST:late\n"), "{out}");
assert!(out.find(b.to_str().unwrap()) < out.find("a.flac"), "{out}");
assert_eq!(output(&db_path, &["export", "late", list_arg]).0, Some(0));
let (code, _, err) = output(&db_path, &["export", "late", list_arg]);
assert_eq!(code, Some(1));
assert!(err.contains("exists; --force replaces it"), "{err}");
assert_eq!(
output(&db_path, &["export", "late", list_arg, "--force"]).0,
Some(0)
);
let (code, _, err) = output(&db_path, &["import", list_arg]);
assert_eq!(code, Some(1));
assert!(err.contains("a playlist named \"late\" exists"), "{err}");
let other = music.join("mix.m3u");
let gone = dir.path().join("gone.flac");
let text = format!("#EXTM3U\na.flac\n{}\nb.flac\n", gone.display());
std::fs::write(&other, text).unwrap();
let (code, out, _) = output(&db_path, &["import", other.to_str().unwrap()]);
assert_eq!(code, Some(0));
assert!(out.contains("saved \"mix\", 2 tracks"), "{out}");
assert!(
out.contains("1 not in the library") && out.contains("gone.flac"),
"{out}"
);
let conn = db::open(&db_path).unwrap();
let lists = query::playlists(&conn).unwrap();
let mix = query::find_playlist(&lists, "mix").unwrap();
let paths: Vec<String> = query::playlist_tracks(&conn, mix.id)
.unwrap()
.into_iter()
.map(|t| t.path)
.collect();
assert_eq!(
paths,
[music.join("a.flac"), music.join("b.flac")].map(|p| p.to_string_lossy().into_owned())
);
let (code, out, _) = output(&db_path, &["import", list_arg, "--name", "late again"]);
assert_eq!(code, Some(0), "{out}");
assert!(output(
&db_path,
&["import", list_arg, other.to_str().unwrap(), "--name", "x"]
)
.2
.contains("--name names one playlist"));
let none = dir.path().join("none.db");
assert!(output(&none, &["export", "late"])
.2
.contains("no library at"));
assert!(output(&none, &["import", list_arg])
.2
.contains("no library at"));
assert!(!none.exists());
}