use std::io::IsTerminal;
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use playr::ui;
use playr_core::audio::Player;
use playr_core::db::{self, Track};
use playr_core::scan;
#[derive(Parser)]
#[command(
version,
// Each subcommand name is a file `playr <path>` cannot play; `help` need not be one.
disable_help_subcommand = true,
override_usage = "playr [OPTIONS] [PATHS]...\n playr [OPTIONS] <COMMAND>"
)]
struct Cli {
paths: Vec<PathBuf>,
#[arg(long, global = true, value_name = "PATH")]
db: Option<PathBuf>,
#[arg(long, global = true, value_name = "PATH")]
settings: Option<PathBuf>,
#[arg(long, value_name = "ID")]
device: Option<String>,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
Scan {
#[arg(value_name = "DIR")]
dirs: Vec<PathBuf>,
},
Roots {
#[command(subcommand)]
op: Option<RootsOp>,
},
Prune {
#[arg(value_name = "DIR")]
dirs: Vec<PathBuf>,
},
Search {
#[arg(long)]
json: bool,
#[arg(required = true, value_name = "QUERY")]
query: Vec<String>,
},
Playlist {
#[arg(required = true, value_name = "NAME")]
name: Vec<String>,
},
Playlists,
Formats,
Devices,
}
#[derive(Subcommand)]
enum RootsOp {
Add {
#[arg(required = true, value_name = "DIR")]
dirs: Vec<PathBuf>,
},
Rm {
#[arg(required = true, value_name = "DIR")]
dirs: Vec<PathBuf>,
},
}
fn main() -> ExitCode {
let cli = Cli::parse();
match run(cli) {
Ok(code) => code,
Err(e) => {
eprintln!("playr: {e}");
ExitCode::FAILURE
}
}
}
fn run(cli: Cli) -> Result<ExitCode, Box<dyn std::error::Error>> {
if let Some(Command::Formats) = cli.command {
print_formats();
return Ok(ExitCode::SUCCESS);
}
if let Some(Command::Devices) = cli.command {
print_devices()?;
return Ok(ExitCode::SUCCESS);
}
let reads_only = matches!(
cli.command,
Some(Command::Playlists | Command::Search { json: true, .. } | Command::Roots { op: None })
);
let _instance = if reads_only {
None
} else {
Some(playr_app::instance::claim()?)
};
let db_path = cli.db.unwrap_or_else(db::default_path);
let scanning = matches!(
cli.command,
Some(
Command::Scan { .. }
| Command::Roots {
op: Some(RootsOp::Add { .. })
}
)
);
if matches!(
cli.command,
Some(
Command::Prune { .. }
| Command::Roots {
op: None | Some(RootsOp::Rm { .. })
}
)
) && !db_path.try_exists()?
{
return Err(format!("no library at {}", db_path.display()).into());
}
if let Some(Command::Scan { ref dirs }) = cli.command {
if dirs.is_empty() && !db_path.try_exists()? {
eprintln!("{NO_SCAN_DIRS}");
return Ok(ExitCode::FAILURE);
}
}
let mut conn = if scanning || db_path.try_exists()? {
db::open(&db_path)?
} else {
db::open_memory()?
};
let start: Vec<Track> = match cli.command {
Some(Command::Formats | Command::Devices) => unreachable!("handled above"),
Some(Command::Scan { dirs }) => return cmd_scan(&mut conn, &dirs),
Some(Command::Roots {
op: Some(RootsOp::Add { dirs }),
}) => return cmd_scan(&mut conn, &dirs),
Some(Command::Roots {
op: Some(RootsOp::Rm { dirs }),
}) => return cmd_forget(&conn, &dirs),
Some(Command::Roots { op: None }) => {
for root in db::roots(&conn)? {
println!("{}", root.display());
}
return Ok(ExitCode::SUCCESS);
}
Some(Command::Prune { dirs }) => return cmd_prune(&conn, &dirs),
Some(Command::Playlists) => {
for p in db::query::playlists(&conn)? {
println!("{:<40} {:>4} tracks", p.name, p.len);
}
return Ok(ExitCode::SUCCESS);
}
Some(Command::Search { json, query }) => {
let q = query.join(" ");
if q.trim().is_empty() {
return Err("search needs a query".into());
}
let hits = db::query::search(&conn, &q)?;
if json {
println!("{}", tracks_json(&hits));
return Ok(if hits.is_empty() {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
});
}
if hits.is_empty() {
eprintln!("playr: nothing matches {q:?}");
return Ok(ExitCode::FAILURE);
}
hits
}
Some(Command::Playlist { name }) => {
let name = name.join(" ");
let lists = db::query::playlists(&conn)?;
let pl = db::query::find_playlist(&lists, name.trim())
.ok_or_else(|| format!("no single playlist named {name:?}"))?;
db::query::playlist_tracks(&conn, pl.id)?
}
None if cli.paths.is_empty() => Vec::new(),
None => collect_paths(&conn, &cli.paths)?,
};
let config = match (&cli.settings, playr_app::config::default_path()) {
(Some(path), _) => playr_app::config::Config::load(path, true),
(None, Some(path)) => playr_app::config::Config::load(&path, false),
(None, None) => Ok(playr_app::config::Config::default()),
};
let config = match config {
Ok(config) => config,
Err(errors) => {
for e in errors {
eprintln!("playr: {e}");
}
return Ok(ExitCode::FAILURE);
}
};
if !std::io::stdout().is_terminal() {
return Err("playr needs a terminal: stdout is not one".into());
}
let player = Player::new(cli.device.as_deref().or(config.settings.device.as_deref()))?;
let mut terminal = ratatui::try_init().map_err(|e| format!("playr needs a terminal: {e}"))?;
let mut app = ui::App::configured(conn, player, start, config);
app.set_colour(std::env::var_os("NO_COLOR").is_none_or(|v| v.is_empty()));
ratatui::crossterm::style::force_color_output(true);
app.set_library_path(db_path);
app.attach_media();
let result = app.run(&mut terminal);
ratatui::restore();
result?;
Ok(ExitCode::SUCCESS)
}
fn tracks_json(tracks: &[Track]) -> String {
let rows: Vec<serde_json::Value> = tracks
.iter()
.map(|t| {
serde_json::json!({
"id": t.id,
"path": t.path,
"title": t.title,
"artist": t.artist,
"album": t.album,
"album_artist": t.album_artist,
"track_no": t.track_no,
"disc_no": t.disc_no,
"year": t.year,
"genre": t.genre,
"duration_ms": t.duration_ms,
"sample_rate": t.sample_rate,
"channels": t.channels,
"bit_depth": t.bit_depth,
"mtime": t.mtime,
"size": t.size,
})
})
.collect();
serde_json::Value::Array(rows).to_string()
}
const NO_SCAN_DIRS: &str = "playr: no directories to scan; give one, as `playr scan DIR`";
fn cmd_scan(
conn: &mut rusqlite::Connection,
dirs: &[PathBuf],
) -> Result<ExitCode, Box<dyn std::error::Error>> {
let recorded;
let dirs = if dirs.is_empty() {
recorded = db::roots(conn)?;
if recorded.is_empty() {
eprintln!("{NO_SCAN_DIRS}");
return Ok(ExitCode::FAILURE);
}
recorded.as_slice()
} else {
dirs
};
let mut total = scan::ScanStats::default();
let multi = dirs.len() > 1;
for path in dirs {
if !path.is_dir() {
eprintln!("playr: not a directory: {}", path.display());
continue;
}
println!("scanning {}", path.display());
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();
}
})?;
if stats.seen > 0 {
db::add_root(conn, path)?;
}
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;
let missing = db::missing_under(conn, path)?.len();
if missing > 0 {
let path = path.display();
println!(" {missing} tracks missing; `playr prune {path}` removes them");
}
}
if multi {
println!(
"total: {} files, {} added, {} unchanged, {} unreadable",
total.seen, total.added, total.skipped, total.failed
);
}
println!("library now holds {} tracks", db::query::count(conn)?);
Ok(ExitCode::SUCCESS)
}
fn cmd_prune(
conn: &rusqlite::Connection,
dirs: &[PathBuf],
) -> Result<ExitCode, Box<dyn std::error::Error>> {
let recorded;
let dirs = if dirs.is_empty() {
recorded = db::roots(conn)?;
if recorded.is_empty() {
eprintln!("playr: no directories to prune; give one, or scan one first");
return Ok(ExitCode::FAILURE);
}
recorded.as_slice()
} else {
dirs
};
for path in dirs {
if !path.is_dir() {
eprintln!("playr: not a directory: {}", path.display());
continue;
}
let pruned = db::prune_missing(conn, path)?;
println!(
"pruning {}: removed {} tracks and {} marks of missing files",
path.display(),
pruned.tracks,
pruned.marks
);
}
println!("library now holds {} tracks", db::query::count(conn)?);
Ok(ExitCode::SUCCESS)
}
fn cmd_forget(
conn: &rusqlite::Connection,
dirs: &[PathBuf],
) -> Result<ExitCode, Box<dyn std::error::Error>> {
let mut code = ExitCode::SUCCESS;
for path in dirs {
match db::forget_root(conn, path)? {
Some(removed) => println!(
"forgot {}: removed {} tracks and {} marks",
path.display(),
removed.tracks,
removed.marks
),
None => {
eprintln!(
"playr: not one of the library's directories: {}",
path.display()
);
code = ExitCode::FAILURE;
}
}
}
println!("library now holds {} tracks", db::query::count(conn)?);
Ok(code)
}
fn collect_paths(
conn: &rusqlite::Connection,
paths: &[PathBuf],
) -> Result<Vec<Track>, Box<dyn std::error::Error>> {
let found = scan::playable(paths, |key| db::query::by_path(conn, key).ok().flatten());
for problem in &found.problems {
eprintln!("playr: {problem}");
}
if found.tracks.is_empty() {
return Err("nothing playable in those paths".into());
}
Ok(found.tracks)
}
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.");
}
fn print_devices() -> Result<(), Box<dyn std::error::Error>> {
let devices = playr_core::audio::output::devices()?;
let width = devices.iter().map(|d| d.id.len()).max().unwrap_or(0);
for d in &devices {
let mark = if d.default { '*' } else { ' ' };
println!("{mark} {:<width$} {}", d.id, d.name);
}
Ok(())
}