this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 605 lines 18 kB view raw
1use owo_colors::OwoColorize; 2use serde::{Deserialize, Serialize}; 3use std::path::PathBuf; 4 5use crate::{ 6 auth::{AuthMethod, Authenticator, GenericSession}, 7 error::OnyxError, 8 record::{Artist, Play, PlayView, Status}, 9 scrobble::Scrobbler, 10 status::StatusManager, 11}; 12use clap::{ 13 CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum, 14 builder::{ 15 Styles, 16 styling::{AnsiColor, Effects}, 17 }, 18}; 19 20mod auth; 21mod error; 22mod parser; 23mod record; 24mod scrobble; 25mod status; 26 27fn args_styles() -> Styles { 28 Styles::styled() 29 .header(AnsiColor::BrightGreen.on_default().effects(Effects::BOLD)) 30 .usage(AnsiColor::BrightGreen.on_default().effects(Effects::BOLD)) 31 .literal(AnsiColor::BrightCyan.on_default().effects(Effects::BOLD)) 32 .placeholder(AnsiColor::BrightYellow.on_default()) 33 .valid(AnsiColor::BrightGreen.on_default()) 34 .invalid(AnsiColor::BrightRed.on_default()) 35} 36 37#[derive(Parser, Debug)] 38struct Args { 39 #[command(subcommand)] 40 command: Commands, 41} 42 43#[allow(clippy::large_enum_variant)] 44#[derive(Subcommand, Debug)] 45enum Commands { 46 /// Authentication related commands 47 Auth { 48 #[command(subcommand)] 49 command: AuthCommands, 50 }, 51 52 /// Scrobble tracks 53 Scrobble { 54 #[command(subcommand)] 55 command: ScrobbleCommands, 56 }, 57 58 /// View and manage listening status 59 Status { 60 #[command(subcommand)] 61 command: StatusCommands, 62 }, 63} 64 65#[derive(Subcommand, Debug)] 66enum AuthCommands { 67 /// Login with an ATProto handle or DID 68 Login { 69 /// Handle or DID for login 70 handle: String, 71 72 /// Preferred method of storing credentials 73 #[arg(short, long, default_value = "keyring")] 74 store: StoreMethod, 75 76 /// App password to use, OAuth used if left blank 77 #[arg(short, long)] 78 password: Option<String>, 79 }, 80 81 /// Logout of your account 82 Logout, 83 84 /// Display logged-in user information 85 Whoami, 86} 87 88#[derive(Debug, Clone, ValueEnum, Serialize, Deserialize, PartialEq)] 89enum StoreMethod { 90 /// Use the system keyring, if available 91 Keyring, 92 93 /// Save credentials to a file 94 File, 95} 96 97#[allow(clippy::large_enum_variant)] 98#[derive(Subcommand, Debug)] 99enum ScrobbleCommands { 100 /// Scrobble a single track 101 Track { 102 /// The name of the track 103 track_name: String, 104 105 /// The MusicBrainz ID of the track 106 #[arg(long)] 107 track_mb_id: Option<String>, 108 109 /// The MusicBrainz ID of the recording 110 #[arg(long)] 111 recording_mb_id: Option<String>, 112 113 /// The track duration in seconds 114 #[arg(short, long)] 115 duration: Option<i64>, 116 117 /// A comma-separated list of artist name 118 #[arg(short, long)] 119 artist_names: Option<String>, 120 121 /// A comma-separated list of artist MusicBrainz IDs 122 #[arg(long)] 123 artist_mb_ids: Option<String>, 124 125 /// The name of the release/album 126 #[arg(short, long)] 127 release_name: Option<String>, 128 129 /// The MusicBrainz ID of the release/album 130 #[arg(long)] 131 release_mb_id: Option<String>, 132 133 /// The URL associated with the track 134 #[arg(short, long)] 135 origin_url: Option<String>, 136 137 /// The ISRC accosiated with the recording 138 #[arg(long)] 139 isrc: Option<String>, 140 141 /// Time the track was played (RFC 3339 format) 142 #[arg(short, long)] 143 played_time: Option<chrono::DateTime<chrono::FixedOffset>>, 144 145 /// Distinguishing information for track variants 146 #[arg(long)] 147 track_discriminant: Option<String>, 148 149 /// Distinguishing information for release variants 150 #[arg(long)] 151 release_discriminant: Option<String>, 152 }, 153 154 /// Scrobble tracks from a log file 155 Logfile { 156 /// Log file path 157 log: PathBuf, 158 159 /// Log file format 160 log_format: LogFormat, 161 162 /// Delete the log file after processing 163 #[arg(short, long, action)] 164 delete: bool, 165 }, 166} 167 168#[derive(Debug, Clone, ValueEnum)] 169enum LogFormat { 170 /// Use AudioScrobbler log format 171 AudioScrobbler, 172} 173 174#[allow(clippy::large_enum_variant)] 175#[derive(Subcommand, Debug)] 176enum StatusCommands { 177 /// Display user playing status 178 Show { 179 /// Handle or DID to query 180 #[arg(long)] 181 handle: Option<String>, 182 183 /// Display raw status without processing 184 #[arg(short, long, action)] 185 raw: bool, 186 187 /// Display all status fields 188 #[arg(short, long, action)] 189 full: bool, 190 }, 191 192 /// Set user playing status 193 Set { 194 /// The name of the track 195 track_name: String, 196 197 /// The MusicBrainz ID of the track 198 #[arg(long)] 199 track_mb_id: Option<String>, 200 201 /// The MusicBrainz ID of the recording 202 #[arg(long)] 203 recording_mb_id: Option<String>, 204 205 /// The track duration in seconds 206 #[arg(short, long)] 207 duration: Option<i64>, 208 209 /// A comma-separated list of artist name 210 #[arg(short, long)] 211 artist_names: Option<String>, 212 213 /// A comma-separated list of artist MusicBrainz IDs 214 #[arg(long)] 215 artist_mb_ids: Option<String>, 216 217 /// The name of the release/album 218 #[arg(short, long)] 219 release_name: Option<String>, 220 221 /// The MusicBrainz ID of the release/album 222 #[arg(long)] 223 release_mb_id: Option<String>, 224 225 /// The URL associated with the track 226 #[arg(short, long)] 227 origin_url: Option<String>, 228 229 /// The ISRC accosiated with the recording 230 #[arg(long)] 231 isrc: Option<String>, 232 233 /// Time the track was played (RFC 3339 format) 234 #[arg(short, long)] 235 played_time: Option<chrono::DateTime<chrono::FixedOffset>>, 236 237 /// Time of status creation, defaults to current time 238 #[arg(short, long)] 239 time: Option<chrono::DateTime<chrono::FixedOffset>>, 240 241 /// Time of status expiry, defaults to start time + 10 minutes 242 #[arg(short, long)] 243 expiry: Option<chrono::DateTime<chrono::FixedOffset>>, 244 }, 245 246 /// Clear current playing status 247 Clear, 248} 249 250fn get_auth() -> Result<Authenticator, OnyxError> { 251 let config_dir = dirs::config_dir().unwrap().join("onyx"); 252 Authenticator::try_new("onyx", &config_dir) 253} 254 255async fn get_session() -> Result<GenericSession, OnyxError> { 256 let auth = get_auth()?; 257 auth.restore().await 258} 259 260fn get_command() -> clap::Command { 261 Args::command().styles(args_styles()) 262} 263 264fn generate_client_version() -> String { 265 format!("v{}", env!("CARGO_PKG_VERSION")) 266} 267 268fn parse_artist_list( 269 artist_names: Option<String>, 270 artist_mb_ids: Option<String>, 271) -> Result<Option<Vec<Artist>>, OnyxError> { 272 Ok(match artist_names { 273 Some(names) => { 274 let mut artists = Vec::new(); 275 276 let names: Vec<&str> = names.split(",").collect(); 277 for name in names { 278 let name = name.trim(); 279 280 if name.is_empty() { 281 continue; 282 } 283 284 artists.push(Artist { 285 artist_name: name.to_owned(), 286 artist_mb_id: None, 287 }); 288 } 289 290 if let Some(mb_ids) = artist_mb_ids { 291 let mb_ids: Vec<&str> = mb_ids.split(",").collect(); 292 293 if mb_ids.len() > artists.len() { 294 return Err(OnyxError::Parse( 295 "cannot be more `artist_mb_ids` than `artist_names`".into(), 296 )); 297 } 298 299 for i in 0..mb_ids.len() { 300 let id = mb_ids[i].trim(); 301 302 if !id.is_empty() { 303 artists[i].artist_mb_id = Some(id.to_owned()); 304 } 305 } 306 } 307 308 Some(artists) 309 } 310 None => None, 311 }) 312} 313 314async fn run_onyx() -> Result<(), OnyxError> { 315 let mut matches = get_command().get_matches(); 316 let args = Args::from_arg_matches_mut(&mut matches).unwrap(); 317 318 match args.command { 319 Commands::Auth { command } => match command { 320 AuthCommands::Login { 321 handle, 322 store, 323 password, 324 } => { 325 let auth = get_auth()?; 326 auth.login(&handle, store, password).await?; 327 328 let session_info = auth.get_session_info()?; 329 330 println!( 331 "{}: logged in {}{}", 332 "success".green().bold(), 333 (session_info 334 .handles 335 .first() 336 .unwrap_or(&"(no handle)".red().to_string())) 337 .magenta(), 338 format!(", {}", session_info.did).dimmed() 339 ); 340 } 341 AuthCommands::Logout => { 342 let auth = get_auth()?; 343 let session_info = auth.get_session_info()?; 344 345 auth.logout().await?; 346 347 println!( 348 "{}: logged out {}, {}", 349 "success".green().bold(), 350 (session_info 351 .handles 352 .first() 353 .unwrap_or(&"(no handle)".red().to_string())), 354 session_info.did, 355 ); 356 } 357 AuthCommands::Whoami => { 358 let auth = get_auth()?; 359 let session = auth.restore().await; 360 let session_info = auth.get_session_info()?; 361 362 let method_str = if session_info.auth == AuthMethod::OAuth { 363 "oauth" 364 } else { 365 "app password" 366 }; 367 368 if session.is_ok() { 369 println!("status: {} via {}", "logged in".green().bold(), method_str); 370 } else { 371 println!("status: {} via {}", "logged out".red().bold(), method_str); 372 } 373 374 print!("handles: "); 375 376 if session_info.handles.is_empty() { 377 println!("{}", "(no handle)".red()); 378 } else { 379 for handle in &session_info.handles { 380 print!("{} ", handle); 381 } 382 println!(); 383 } 384 385 println!("did: {}", session_info.did); 386 } 387 }, 388 Commands::Scrobble { command } => match command { 389 ScrobbleCommands::Track { 390 track_name, 391 track_mb_id, 392 recording_mb_id, 393 duration, 394 artist_names, 395 artist_mb_ids, 396 release_name, 397 release_mb_id, 398 origin_url, 399 isrc, 400 played_time, 401 track_discriminant, 402 release_discriminant, 403 } => { 404 let artists = parse_artist_list(artist_names, artist_mb_ids)?; 405 406 let track = Play { 407 track_name, 408 track_mb_id, 409 recording_mb_id, 410 duration, 411 artists, 412 release_name, 413 release_mb_id, 414 origin_url, 415 isrc, 416 played_time, 417 track_discriminant, 418 release_discriminant, 419 music_service_base_domain: None, 420 submission_client_agent: None, 421 artist_names: None, 422 artist_mb_ids: None, 423 }; 424 425 let version = generate_client_version(); 426 let session = get_session().await?; 427 let scrobbler = Scrobbler::new("onyx", &version, session); 428 scrobbler.scrobble_track(track).await?; 429 430 println!("{}: track submitted", "success".green().bold()); 431 } 432 ScrobbleCommands::Logfile { 433 log, 434 log_format, 435 delete, 436 } => { 437 let version = generate_client_version(); 438 let session = get_session().await?; 439 let scrobbler = Scrobbler::new("onyx", &version, session); 440 scrobbler.scrobble_logfile(log.clone(), log_format).await?; 441 442 if delete { 443 std::fs::remove_file(&log)?; 444 println!( 445 "{}", 446 format!("deleted log: {}", log.to_str().unwrap()).dimmed() 447 ); 448 } 449 } 450 }, 451 Commands::Status { command } => match command { 452 StatusCommands::Show { handle, raw, full } => { 453 let ident = match handle { 454 Some(s) => s, 455 None => { 456 let auth = get_auth()?; 457 let session_info = auth.get_session_info()?; 458 session_info.did 459 } 460 }; 461 462 let status_man = StatusManager::new(&ident); 463 let status = status_man.get_status().await?; 464 status.display(raw, full); 465 } 466 StatusCommands::Set { 467 track_name, 468 track_mb_id, 469 recording_mb_id, 470 duration, 471 artist_names, 472 artist_mb_ids, 473 release_name, 474 release_mb_id, 475 origin_url, 476 isrc, 477 played_time, 478 time, 479 expiry, 480 } => { 481 let artists = parse_artist_list(artist_names, artist_mb_ids)?.unwrap_or(Vec::new()); 482 483 let play = PlayView { 484 track_name, 485 track_mb_id, 486 recording_mb_id, 487 duration, 488 artists, 489 release_name, 490 release_mb_id, 491 origin_url, 492 isrc, 493 played_time, 494 music_service_base_domain: None, 495 submission_client_agent: None, 496 }; 497 498 let time = time.unwrap_or(chrono::Local::now().into()); 499 500 let status = Status { 501 time, 502 expiry: Some(expiry.unwrap_or(time + std::time::Duration::from_mins(10))), 503 item: play, 504 }; 505 506 let auth = get_auth()?; 507 let session_info = auth.get_session_info()?; 508 let session = auth.restore().await?; 509 510 let status_man = StatusManager::new(&session_info.did); 511 status_man.set_status(session, status).await?; 512 513 println!( 514 "{}: set status for {}, {}", 515 "success".green().bold(), 516 (session_info 517 .handles 518 .first() 519 .unwrap_or(&"(no handle)".red().to_string())), 520 session_info.did 521 ); 522 } 523 StatusCommands::Clear => { 524 let auth = get_auth()?; 525 let session_info = auth.get_session_info()?; 526 let session = auth.restore().await?; 527 528 let status_man = StatusManager::new(&session_info.did); 529 status_man.clear_status(session).await?; 530 531 println!( 532 "{}: cleared status for {}, {}", 533 "success".green().bold(), 534 (session_info 535 .handles 536 .first() 537 .unwrap_or(&"(no handle)".red().to_string())), 538 session_info.did, 539 ); 540 } 541 }, 542 } 543 544 Ok(()) 545} 546 547fn print_error(e: &OnyxError) { 548 println!("{}: {}", "error".red().bold(), e); 549} 550 551fn handle_error(e: OnyxError) { 552 match e { 553 OnyxError::Auth(_) => { 554 print_error(&e); 555 println!( 556 "{}: try logging in with '{}'", 557 "hint".green().bold(), 558 "onyx auth login".cyan().bold() 559 ); 560 } 561 _ => print_error(&e), 562 } 563} 564 565#[tokio::main] 566async fn main() { 567 if let Err(e) = run_onyx().await { 568 handle_error(e); 569 std::process::exit(1); 570 } 571} 572 573#[cfg(test)] 574mod tests { 575 use crate::*; 576 577 #[test] 578 fn test_parse_artists() { 579 let artist_names = "Test 1 , Test 2 , Test 3, Test 4, "; 580 let artist_mb_ids = "ABCD, 1234, DCBA"; 581 582 match parse_artist_list( 583 Some(artist_names.to_string()), 584 Some(artist_mb_ids.to_string()), 585 ) { 586 Ok(l) => { 587 let artists = l.unwrap(); 588 589 assert!(artists.len() == 4); 590 591 assert!(artists[0].artist_name == "Test 1"); 592 assert!(artists[0].artist_mb_id.as_ref().unwrap() == "ABCD"); 593 assert!(artists[1].artist_name == "Test 2"); 594 assert!(artists[1].artist_mb_id.as_ref().unwrap() == "1234"); 595 assert!(artists[2].artist_name == "Test 3"); 596 assert!(artists[2].artist_mb_id.as_ref().unwrap() == "DCBA"); 597 assert!(artists[3].artist_name == "Test 4"); 598 assert!(artists[3].artist_mb_id.is_none()); 599 } 600 Err(e) => { 601 panic!("parse_artist_list: {e}"); 602 } 603 } 604 } 605}