use std::io::IsTerminal;
use std::path::PathBuf;
use std::process::ExitCode;
mod analyze;
use playr_app::config::Program;
use clap::{Parser, Subcommand};
use playr::ui;
use playr_core::audio::Player;
use playr_core::db::{self, Track};
use playr_core::m3u;
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,
Export {
name: String,
file: Option<PathBuf>,
#[arg(long)]
force: bool,
},
Import {
#[arg(required = true, value_name = "FILE")]
files: Vec<PathBuf>,
#[arg(long)]
name: Option<String>,
},
Formats,
Devices,
Analyze {
#[arg(value_name = "PATH")]
paths: Vec<PathBuf>,
#[arg(long)]
force: bool,
#[arg(long)]
report: bool,
#[arg(long)]
json: bool,
#[arg(long, value_name = "N")]
jobs: Option<usize>,
},
}
#[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>> {
let plays = matches!(
cli.command,
None | Some(Command::Search { json: false, .. } | Command::Playlist { .. })
);
let config = match (&cli.settings, playr_app::config::default_path()) {
(Some(path), _) => playr_app::config::Config::load_for(Program::Terminal, path, true),
(None, Some(path)) => playr_app::config::Config::load_for(Program::Terminal, &path, false),
(None, None) => Ok(playr_app::config::Config::default()),
};
let config = match config {
Ok(config) => config,
Err(errors) => {
let label = if plays { "playr" } else { "playr: warning" };
for e in errors {
eprintln!("{label}: {e}");
}
if plays {
return Ok(ExitCode::FAILURE);
}
playr_app::config::Config::default()
}
};
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::Export { .. }
| Command::Search { json: true, .. }
| Command::Roots { op: None }
| Command::Analyze { .. }
)
);
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::Analyze { .. }
| Command::Export { .. }
| Command::Import { .. }
| 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::Analyze {
paths,
force,
report,
json,
jobs,
}) => {
let opts = analyze::Options {
paths,
force,
report,
json,
jobs,
};
return analyze::run(&mut conn, opts);
}
Some(Command::Playlists) => {
for p in db::query::playlists(&conn)? {
println!("{:<40} {:>4} tracks", p.name, p.len);
}
return Ok(ExitCode::SUCCESS);
}
Some(Command::Export { name, file, force }) => {
return cmd_export(&conn, &name, file.as_deref(), force)
}
Some(Command::Import { files, name }) => return cmd_import(&mut conn, &files, name),
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)?,
};
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 cmd_export(
conn: &rusqlite::Connection,
name: &str,
file: Option<&std::path::Path>,
force: bool,
) -> Result<ExitCode, Box<dyn std::error::Error>> {
let lists = db::query::playlists(conn)?;
let pl = db::query::find_playlist(&lists, name.trim())
.ok_or_else(|| format!("no single playlist named {name:?}"))?;
let tracks = db::query::playlist_tracks(conn, pl.id)?;
let (text, left_out) = m3u::write(&pl.name, &tracks);
if left_out > 0 {
eprintln!("playr: {left_out} tracks left out: their paths hold a line break");
}
match file {
None => print!("{text}"),
Some(file) => {
let mut out = std::fs::OpenOptions::new();
match force {
true => out.write(true).create(true).truncate(true),
false => out.write(true).create_new(true),
};
let mut out = out.open(file).map_err(|e| match e.kind() {
std::io::ErrorKind::AlreadyExists => {
format!("{} exists; --force replaces it", file.display())
}
_ => format!("{}: {e}", file.display()),
})?;
std::io::Write::write_all(&mut out, text.as_bytes())
.map_err(|e| format!("{}: {e}", file.display()))?;
}
}
Ok(ExitCode::SUCCESS)
}
fn cmd_import(
conn: &mut rusqlite::Connection,
files: &[PathBuf],
name: Option<String>,
) -> Result<ExitCode, Box<dyn std::error::Error>> {
if name.is_some() && files.len() > 1 {
return Err("--name names one playlist; import one file with it".into());
}
let mut failed = false;
for file in files {
let shown = file.display();
let text = match std::fs::read(file) {
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
Err(e) => {
eprintln!("playr: {shown}: {e}");
failed = true;
continue;
}
};
let dir = file.parent().unwrap_or(std::path::Path::new("."));
let (tracks, missing) = m3u::resolve(conn, &m3u::paths(&text, dir))?;
let stem = file.file_stem().map(|s| s.to_string_lossy().into_owned());
let Some(title) = name.clone().or_else(|| m3u::name(&text)).or(stem) else {
eprintln!("playr: {shown}: no name; give one with --name");
failed = true;
continue;
};
let lists = db::query::playlists(conn)?;
if lists.iter().any(|p| p.name == title) {
eprintln!("playr: {shown}: a playlist named {title:?} exists; --name gives another");
failed = true;
continue;
}
if tracks.is_empty() {
eprintln!(
"playr: {shown}: none of its {} tracks is in the library",
missing.len()
);
failed = true;
continue;
}
let ids: Vec<i64> = tracks.iter().map(|t| t.id).collect();
db::query::save_playlist(conn, &title, &ids)?;
println!("saved {title:?}, {} tracks", ids.len());
if !missing.is_empty() {
println!(" {} not in the library, left out:", missing.len());
for path in &missing {
println!(" {}", path.display());
}
}
}
Ok(if failed {
ExitCode::FAILURE
} else {
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)?);
let waiting = playr_core::analysis::pending(conn, &db::query::all(conn)?)?.len();
if waiting > 0 {
println!(" {waiting} tracks not analysed; `playr analyze` measures them");
}
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(())
}