use std::path::{Path, PathBuf};
use std::process::ExitCode;
use playr::audio::{Cmd, Player};
use playr::db::{self, Track};
use playr::{scan, ui};
const USAGE: &str = "\
playr - a minimal TUI music player
usage:
playr browse the library
playr <path>... play files or directories (recursively)
playr scan <dir>... add a directory to the library
playr search <query> play everything matching a search
playr playlist <name> play a saved playlist
playr playlists list saved playlists
playr formats show which formats this build can decode
options:
--db <path> use a different library file
-h, --help show this help
-V, --version show the version
";
fn main() -> ExitCode {
match run() {
Ok(code) => code,
Err(e) => {
eprintln!("playr: {e}");
ExitCode::FAILURE
}
}
}
fn run() -> Result<ExitCode, Box<dyn std::error::Error>> {
let mut args: Vec<String> = std::env::args().skip(1).collect();
let mut db_path = db::default_path();
if let Some(i) = args.iter().position(|a| a == "--db") {
let value = args.get(i + 1).ok_or("--db needs a path")?.clone();
db_path = PathBuf::from(value);
args.drain(i..=i + 1);
}
match args.first().map(String::as_str) {
Some("-h") | Some("--help") => {
print!("{USAGE}");
return Ok(ExitCode::SUCCESS);
}
Some("-V") | Some("--version") => {
println!("playr {}", env!("CARGO_PKG_VERSION"));
return Ok(ExitCode::SUCCESS);
}
Some("formats") => {
print_formats();
return Ok(ExitCode::SUCCESS);
}
_ => {}
}
let mut conn = db::open(&db_path)?;
match args.first().map(String::as_str) {
Some("scan") => {
let dirs = &args[1..];
if dirs.is_empty() {
return Err("scan needs at least one directory".into());
}
return cmd_scan(&mut conn, dirs);
}
Some("playlists") => {
for p in db::query::playlists(&conn)? {
println!("{:<40} {:>4} tracks", p.name, p.len);
}
return Ok(ExitCode::SUCCESS);
}
_ => {}
}
let start: Vec<Track> = match args.first().map(String::as_str) {
Some("search") => {
let q = args[1..].join(" ");
if q.trim().is_empty() {
return Err("search needs a query".into());
}
let hits = db::query::search(&conn, &q)?;
if hits.is_empty() {
eprintln!("playr: nothing matches {q:?}");
return Ok(ExitCode::FAILURE);
}
hits
}
Some("playlist") => {
let name = args[1..].join(" ");
let lists = db::query::playlists(&conn)?;
let pl = lists
.iter()
.find(|p| p.name.eq_ignore_ascii_case(name.trim()))
.ok_or_else(|| format!("no playlist named {name:?}"))?;
db::query::playlist_tracks(&conn, pl.id)?
}
Some(first) if !first.starts_with('-') => collect_paths(&conn, &args)?,
_ => Vec::new(),
};
let player = Player::new()?;
if !start.is_empty() {
player.send(Cmd::Play(
start.iter().map(|t| PathBuf::from(&t.path)).collect(),
0,
));
}
let mut terminal = ratatui::try_init().map_err(|e| format!("playr needs a terminal: {e}"))?;
let app = ui::App::with_queue(conn, player, start);
let result = app.run(&mut terminal);
ratatui::restore();
result?;
Ok(ExitCode::SUCCESS)
}
fn cmd_scan(
conn: &mut rusqlite::Connection,
dirs: &[String],
) -> Result<ExitCode, Box<dyn std::error::Error>> {
let mut total = scan::ScanStats::default();
let multi = dirs.len() > 1;
for dir in dirs {
let path = Path::new(dir);
if !path.is_dir() {
eprintln!("playr: not a directory: {dir}");
continue;
}
println!("scanning {dir}");
let stats = scan::scan_dir(conn, path, |s, p| {
if s.seen % 25 == 0 {
let name = p
.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default();
print!("\r {} files, {} added {:<40.40}", s.seen, s.added, name);
use std::io::Write;
let _ = std::io::stdout().flush();
}
})?;
println!(
"\r {} files, {} added, {} unchanged, {} unreadable{:<20}",
stats.seen, stats.added, stats.skipped, stats.failed, ""
);
total.seen += stats.seen;
total.added += stats.added;
total.skipped += stats.skipped;
total.failed += stats.failed;
}
if multi {
println!(
"total: {} files, {} added, {} unchanged, {} unreadable",
total.seen, total.added, total.skipped, total.failed
);
}
let removed = db::prune_missing(conn)?;
if removed > 0 {
println!("removed {removed} tracks whose files are gone");
}
println!("library now holds {} tracks", db::query::count(conn)?);
Ok(ExitCode::SUCCESS)
}
fn collect_paths(
conn: &rusqlite::Connection,
args: &[String],
) -> Result<Vec<Track>, Box<dyn std::error::Error>> {
let mut files: Vec<PathBuf> = Vec::new();
for arg in args {
let path = Path::new(arg);
if path.is_dir() {
let mut found: Vec<PathBuf> = walkdir::WalkDir::new(path)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file() && scan::is_audio(e.path()))
.map(|e| e.into_path())
.collect();
found.sort();
files.extend(found);
} else if path.is_file() {
files.push(path.to_path_buf());
} else {
eprintln!("playr: no such file: {arg}");
}
}
if files.is_empty() {
return Err("nothing playable in those paths".into());
}
Ok(files
.into_iter()
.map(|p| {
let key = p.to_string_lossy().into_owned();
db::query::by_path(conn, &key)
.ok()
.flatten()
.or_else(|| scan::read_track(&p))
.unwrap_or(Track {
path: key,
..Default::default()
})
})
.collect())
}
fn print_formats() {
let opus = cfg!(feature = "opus");
println!("containers: WAV, AIFF, CAF, ISO/MP4 (m4a), MKV/WebM, OGG, FLAC, ADTS");
print!("codecs: FLAC, ALAC, MP1/MP2/MP3, AAC-LC, Vorbis, PCM, ADPCM");
println!("{}", if opus { ", Opus" } else { "" });
println!();
if opus {
println!("Opus is decoded by playr itself, in OGG and WebM; Symphonia 0.6");
println!("demuxes it but ships no decoder. Mono and stereo only.");
} else {
println!("Opus is NOT in this build. It needs libopus, which needs cmake,");
println!("so it is opt-in. To add it:");
println!();
println!(" cargo build --release --features opus");
}
println!();
print!("not decodable: ");
if !opus {
print!("Opus, ");
}
println!("WavPack, WMA, Musepack, APE, DSD, TTA, TAK.");
println!("playr reports such files and moves to the next track.");
}