[ MIRROR ] Command line tool for interacting with Teal.fm git.ngill.net/onyx.git
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656use owo_colors::OwoColorize;use serde::{Deserialize, Serialize};use std::{io::BufReader, path::PathBuf};
#[cfg(unix)]use std::os::unix::net::UnixListener;
use crate::{ auth::{AuthMethod, Authenticator, GenericSession}, error::OnyxError, record::{Artist, Play, PlayView, Status}, scrobble::Scrobbler, status::StatusManager,};use clap::{ CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum, builder::{ Styles, styling::{AnsiColor, Effects}, },};
mod auth;mod error;mod parser;mod record;mod scrobble;mod status;
fn args_styles() -> Styles { Styles::styled() .header(AnsiColor::BrightGreen.on_default().effects(Effects::BOLD)) .usage(AnsiColor::BrightGreen.on_default().effects(Effects::BOLD)) .literal(AnsiColor::BrightCyan.on_default().effects(Effects::BOLD)) .placeholder(AnsiColor::BrightYellow.on_default()) .valid(AnsiColor::BrightGreen.on_default()) .invalid(AnsiColor::BrightRed.on_default())}
#[derive(Parser, Debug)]struct Args { #[command(subcommand)] command: Commands,}
#[allow(clippy::large_enum_variant)]#[derive(Subcommand, Debug)]enum Commands { /// Authentication related commands Auth { #[command(subcommand)] command: AuthCommands, },
/// Scrobble tracks Scrobble { #[command(subcommand)] command: ScrobbleCommands, },
/// View and manage listening status Status { #[command(subcommand)] command: StatusCommands, },}
#[derive(Subcommand, Debug)]enum AuthCommands { /// Login with an ATProto handle or DID Login { /// Handle or DID for login handle: String,
/// Preferred method of storing credentials #[arg(short, long, default_value = "keyring")] store: StoreMethod,
/// App password to use, OAuth used if left blank #[arg(short, long)] password: Option<String>, },
/// Logout of your account Logout,
/// Display logged-in user information Whoami,}
#[derive(Debug, Clone, ValueEnum, Serialize, Deserialize, PartialEq)]enum StoreMethod { /// Use the system keyring, if available Keyring,
/// Save credentials to a file File,}
#[allow(clippy::large_enum_variant)]#[derive(Subcommand, Debug)]enum ScrobbleCommands { /// Scrobble a single track Track { /// The name of the track track_name: String,
/// The MusicBrainz ID of the track #[arg(long)] track_mb_id: Option<String>,
/// The MusicBrainz ID of the recording #[arg(long)] recording_mb_id: Option<String>,
/// The track duration in seconds #[arg(short, long)] duration: Option<i64>,
/// A comma-separated list of artist name #[arg(short, long)] artist_names: Option<String>,
/// A comma-separated list of artist MusicBrainz IDs #[arg(long)] artist_mb_ids: Option<String>,
/// The name of the release/album #[arg(short, long)] release_name: Option<String>,
/// The MusicBrainz ID of the release/album #[arg(long)] release_mb_id: Option<String>,
/// The URL associated with the track #[arg(short, long)] origin_url: Option<String>,
/// The ISRC accosiated with the recording #[arg(long)] isrc: Option<String>,
/// Time the track was played (RFC 3339 format) #[arg(short, long)] played_time: Option<chrono::DateTime<chrono::FixedOffset>>,
/// Distinguishing information for track variants #[arg(long)] track_discriminant: Option<String>,
/// Distinguishing information for release variants #[arg(long)] release_discriminant: Option<String>, },
/// Scrobble tracks from a log file Logfile { /// Log file path log: PathBuf,
/// Log file format log_format: LogFormat,
/// Delete the log file after processing #[arg(short, long, action)] delete: bool, },
/// Scrobble tracks interactively Interactive { /// Path to a Unix domain socket to use instead of standard input (Unix only) #[arg(short, long)] socket: Option<PathBuf>, },}
#[derive(Debug, Clone, ValueEnum)]enum LogFormat { /// Use AudioScrobbler log format AudioScrobbler, /// Use newline-delimited JSON format Json,}
#[allow(clippy::large_enum_variant)]#[derive(Subcommand, Debug)]enum StatusCommands { /// Display user playing status Show { /// Handle or DID to query #[arg(long)] handle: Option<String>,
/// Display raw status without processing #[arg(short, long, action)] raw: bool,
/// Display all status fields #[arg(short, long, action)] full: bool, },
/// Set user playing status Set { /// The name of the track track_name: String,
/// The MusicBrainz ID of the track #[arg(long)] track_mb_id: Option<String>,
/// The MusicBrainz ID of the recording #[arg(long)] recording_mb_id: Option<String>,
/// The track duration in seconds #[arg(short, long)] duration: Option<i64>,
/// A comma-separated list of artist name #[arg(short, long)] artist_names: Option<String>,
/// A comma-separated list of artist MusicBrainz IDs #[arg(long)] artist_mb_ids: Option<String>,
/// The name of the release/album #[arg(short, long)] release_name: Option<String>,
/// The MusicBrainz ID of the release/album #[arg(long)] release_mb_id: Option<String>,
/// The URL associated with the track #[arg(short, long)] origin_url: Option<String>,
/// The ISRC accosiated with the recording #[arg(long)] isrc: Option<String>,
/// Time the track was played (RFC 3339 format) #[arg(short, long)] played_time: Option<chrono::DateTime<chrono::FixedOffset>>,
/// Time of status creation, defaults to current time #[arg(short, long)] time: Option<chrono::DateTime<chrono::FixedOffset>>,
/// Time of status expiry, defaults to start time + 10 minutes #[arg(short, long)] expiry: Option<chrono::DateTime<chrono::FixedOffset>>, },
/// Clear current playing status Clear,}
fn get_auth() -> Result<Authenticator, OnyxError> { let config_dir = dirs::config_dir().unwrap().join("onyx"); Authenticator::try_new("onyx", &config_dir)}
async fn get_session() -> Result<GenericSession, OnyxError> { let auth = get_auth()?; auth.restore().await}
fn get_command() -> clap::Command { Args::command().styles(args_styles())}
fn generate_client_version() -> String { format!("v{}", env!("CARGO_PKG_VERSION"))}
fn parse_artist_list( artist_names: Option<String>, artist_mb_ids: Option<String>,) -> Result<Option<Vec<Artist>>, OnyxError> { Ok(match artist_names { Some(names) => { let mut artists = Vec::new();
let names: Vec<&str> = names.split(",").collect(); for name in names { let name = name.trim();
if name.is_empty() { continue; }
artists.push(Artist { artist_name: name.to_owned(), artist_mb_id: None, }); }
if let Some(mb_ids) = artist_mb_ids { let mb_ids: Vec<&str> = mb_ids.split(",").collect();
if mb_ids.len() > artists.len() { return Err(OnyxError::Parse( "cannot be more `artist_mb_ids` than `artist_names`".into(), )); }
for i in 0..mb_ids.len() { let id = mb_ids[i].trim();
if !id.is_empty() { artists[i].artist_mb_id = Some(id.to_owned()); } } }
Some(artists) } None => None, })}
async fn run_onyx() -> Result<(), OnyxError> { let mut matches = get_command().get_matches(); let args = Args::from_arg_matches_mut(&mut matches).unwrap();
match args.command { Commands::Auth { command } => match command { AuthCommands::Login { handle, store, password, } => { let auth = get_auth()?; auth.login(&handle, store, password).await?;
let session_info = auth.get_session_info()?;
println!( "{}: logged in {}{}", "success".green().bold(), (session_info .handles .first() .unwrap_or(&"(no handle)".red().to_string())) .magenta(), format!(", {}", session_info.did).dimmed() ); } AuthCommands::Logout => { let auth = get_auth()?; let session_info = auth.get_session_info()?;
auth.logout().await?;
println!( "{}: logged out {}, {}", "success".green().bold(), (session_info .handles .first() .unwrap_or(&"(no handle)".red().to_string())), session_info.did, ); } AuthCommands::Whoami => { let auth = get_auth()?; let session = auth.restore().await; let session_info = auth.get_session_info()?;
let method_str = if session_info.auth == AuthMethod::OAuth { "oauth" } else { "app password" };
if session.is_ok() { println!("status: {} via {}", "logged in".green().bold(), method_str); } else { println!("status: {} via {}", "logged out".red().bold(), method_str); }
print!("handles: ");
if session_info.handles.is_empty() { println!("{}", "(no handle)".red()); } else { for handle in &session_info.handles { print!("{} ", handle); } println!(); }
println!("did: {}", session_info.did); } }, Commands::Scrobble { command } => match command { ScrobbleCommands::Track { track_name, track_mb_id, recording_mb_id, duration, artist_names, artist_mb_ids, release_name, release_mb_id, origin_url, isrc, played_time, track_discriminant, release_discriminant, } => { let artists = parse_artist_list(artist_names, artist_mb_ids)?;
let track = Play { track_name, track_mb_id, recording_mb_id, duration, artists, release_name, release_mb_id, origin_url, isrc, played_time, track_discriminant, release_discriminant, music_service_base_domain: None, submission_client_agent: None, artist_names: None, artist_mb_ids: None, };
let version = generate_client_version(); let session = get_session().await?; let scrobbler = Scrobbler::new("onyx", &version, session); scrobbler.scrobble_track(track).await?;
println!("{}: track submitted", "success".green().bold()); } ScrobbleCommands::Logfile { log, log_format, delete, } => { let version = generate_client_version(); let session = get_session().await?; let scrobbler = Scrobbler::new("onyx", &version, session); scrobbler.scrobble_logfile(log.clone(), log_format).await?;
if delete { std::fs::remove_file(&log)?; println!( "{}", format!("deleted log: {}", log.to_str().unwrap()).dimmed() ); } } ScrobbleCommands::Interactive { socket } => { let version = generate_client_version(); let session = get_session().await?; let scrobbler = Scrobbler::new("onyx", &version, session);
#[cfg(unix)] async fn run_socket( socket: PathBuf, scrobbler: Scrobbler, ) -> Result<(), OnyxError> { // we can't open a socket already in use if socket.exists() { std::fs::remove_file(&socket)?; }
let listener = UnixListener::bind(socket)?;
// run the receiver forever in case client dies loop { println!("{}", "waiting for socket connection...".dimmed()); let (stream, _) = listener.accept()?; let reader = BufReader::new(stream); println!("{}", "waiting for tracks...".dimmed()); scrobbler.scrobble_lines(reader).await?; println!(); } }
if cfg!(unix) && let Some(socket) = socket { run_socket(socket, scrobbler).await?; } else { let reader = BufReader::new(std::io::stdin()); println!("{}", "waiting for tracks...".dimmed()); scrobbler.scrobble_lines(reader).await?; } } }, Commands::Status { command } => match command { StatusCommands::Show { handle, raw, full } => { let ident = match handle { Some(s) => s, None => { let auth = get_auth()?; let session_info = auth.get_session_info()?; session_info.did } };
let status_man = StatusManager::new(&ident); let status = status_man.get_status().await?; status.display(raw, full); } StatusCommands::Set { track_name, track_mb_id, recording_mb_id, duration, artist_names, artist_mb_ids, release_name, release_mb_id, origin_url, isrc, played_time, time, expiry, } => { let artists = parse_artist_list(artist_names, artist_mb_ids)?.unwrap_or(Vec::new());
let play = PlayView { track_name, track_mb_id, recording_mb_id, duration, artists, release_name, release_mb_id, origin_url, isrc, played_time, music_service_base_domain: None, submission_client_agent: None, };
let time = time.unwrap_or(chrono::Local::now().into());
let status = Status { time, expiry: Some(expiry.unwrap_or(time + std::time::Duration::from_mins(10))), item: play, };
let auth = get_auth()?; let session_info = auth.get_session_info()?; let session = auth.restore().await?;
let status_man = StatusManager::new(&session_info.did); status_man.set_status(session, status).await?;
println!( "{}: set status for {}, {}", "success".green().bold(), (session_info .handles .first() .unwrap_or(&"(no handle)".red().to_string())), session_info.did ); } StatusCommands::Clear => { let auth = get_auth()?; let session_info = auth.get_session_info()?; let session = auth.restore().await?;
let status_man = StatusManager::new(&session_info.did); status_man.clear_status(session).await?;
println!( "{}: cleared status for {}, {}", "success".green().bold(), (session_info .handles .first() .unwrap_or(&"(no handle)".red().to_string())), session_info.did, ); } }, }
Ok(())}
fn print_error(e: &OnyxError) { println!("{}: {}", "error".red().bold(), e);}
fn handle_error(e: OnyxError) { match e { OnyxError::Auth(_) => { print_error(&e); println!( "{}: try logging in with '{}'", "hint".green().bold(), "onyx auth login".cyan().bold() ); } _ => print_error(&e), }}
#[tokio::main]async fn main() { if let Err(e) = run_onyx().await { handle_error(e); std::process::exit(1); }}
#[cfg(test)]mod tests { use crate::*;
#[test] fn test_parse_artists() { let artist_names = "Test 1 , Test 2 , Test 3, Test 4, "; let artist_mb_ids = "ABCD, 1234, DCBA";
match parse_artist_list( Some(artist_names.to_string()), Some(artist_mb_ids.to_string()), ) { Ok(l) => { let artists = l.unwrap();
assert!(artists.len() == 4);
assert!(artists[0].artist_name == "Test 1"); assert!(artists[0].artist_mb_id.as_ref().unwrap() == "ABCD"); assert!(artists[1].artist_name == "Test 2"); assert!(artists[1].artist_mb_id.as_ref().unwrap() == "1234"); assert!(artists[2].artist_name == "Test 3"); assert!(artists[2].artist_mb_id.as_ref().unwrap() == "DCBA"); assert!(artists[3].artist_name == "Test 4"); assert!(artists[3].artist_mb_id.is_none()); } Err(e) => { panic!("parse_artist_list: {e}"); } } }}