this repo has no description
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}