1use crate::domain::album::Album;
3use crate::domain::artist::Artist;
4use crate::domain::auth::{AuthScopes, AuthStatus};
5use crate::domain::device::Device;
6use crate::domain::pin::PinnedPlaylist;
7use crate::domain::player::PlayerStatus;
8use crate::domain::playlist::{Playlist, PlaylistDetail};
9use crate::domain::search::{SearchItem, SearchResults};
10use crate::domain::track::Track;
11use crate::error::Result;
12use crate::output::{DEFAULT_MAX_WIDTH, TableConfig};
13
14pub fn auth_status(status: AuthStatus) -> Result<()> {
15 if status.logged_in {
16 println!("logged_in");
17 return Ok(());
18 }
19
20 println!("logged_out");
21 Ok(())
22}
23
24pub fn auth_scopes(scopes: AuthScopes) -> Result<()> {
25 println!("Scopes:");
26 for scope in scopes.required {
27 let status = if let Some(granted) = scopes.granted.as_ref() {
28 if granted.iter().any(|item| item == &scope) {
29 "ok"
30 } else {
31 "missing"
32 }
33 } else {
34 "unknown"
35 };
36 println!("{:<32} {}", scope, status);
37 }
38 Ok(())
39}
40
41pub fn player_status(status: PlayerStatus) -> Result<()> {
42 let state = if status.is_playing {
43 "playing"
44 } else {
45 "paused"
46 };
47 let context = playback_context_line(&status);
48
49 if let Some(track) = status.track {
50 let artists = if track.artists.is_empty() {
51 String::new()
52 } else {
53 format!(" - {}", track.artists.join(", "))
54 };
55 let album = track
56 .album
57 .as_ref()
58 .map(|album| format!(" ({})", album))
59 .unwrap_or_default();
60 let progress = format_progress(status.progress_ms, track.duration_ms);
61 println!("{}: {}{}{}{}", state, track.name, album, artists, progress);
62 if let Some(line) = context {
63 println!("{}", line);
64 }
65 return Ok(());
66 }
67
68 println!("{}", state);
69 if let Some(line) = context {
70 println!("{}", line);
71 }
72 Ok(())
73}
74
75pub fn now_playing(status: PlayerStatus) -> Result<()> {
76 if let Some(track) = status.track {
77 let artists = if track.artists.is_empty() {
78 String::new()
79 } else {
80 format!(" - {}", track.artists.join(", "))
81 };
82 let album = track
83 .album
84 .as_ref()
85 .map(|album| format!(" ({})", album))
86 .unwrap_or_default();
87 let progress = format_progress(status.progress_ms, track.duration_ms);
88 println!(
89 "Now Playing: {}{}{}{}",
90 track.name, album, artists, progress
91 );
92 return Ok(());
93 }
94
95 println!("Now Playing: (no active track)");
96 Ok(())
97}
98
99fn playback_context_line(status: &PlayerStatus) -> Option<String> {
100 let repeat = status.repeat_state.as_deref();
101 let shuffle = status.shuffle_state;
102
103 if repeat.is_none() && shuffle.is_none() {
104 return None;
105 }
106
107 let repeat_text = repeat.unwrap_or("unknown");
108 let shuffle_text = match shuffle {
109 Some(true) => "on",
110 Some(false) => "off",
111 None => "unknown",
112 };
113
114 Some(format!(
115 "repeat: {}, shuffle: {}",
116 repeat_text, shuffle_text
117 ))
118}
119
120pub fn action(message: &str) -> Result<()> {
121 println!("{}", message);
122 Ok(())
123}
124
125pub fn album_info(album: Album, table: TableConfig) -> Result<()> {
126 let artists = if album.artists.is_empty() {
127 String::new()
128 } else {
129 format!(" - {}", album.artists.join(", "))
130 };
131 let details = format_optional_details(&[
132 album.release_date,
133 album.total_tracks.map(|t| t.to_string()),
134 album.duration_ms.map(|ms| format_duration(ms)),
135 ]);
136 if details.is_empty() {
137 println!("{}{}", album.name, artists);
138 } else {
139 println!("{}{} ({})", album.name, artists, details);
140 }
141 let mut rows = Vec::new();
142 for track in album.tracks {
143 rows.push(vec![
144 format!("{:02}.", track.track_number),
145 track.name,
146 format_duration(track.duration_ms as u64),
147 ]);
148 }
149 print_table_with_header(&rows, &["NO", "TRACK", "DURATION"], table);
150 Ok(())
151}
152
153pub fn artist_info(artist: Artist) -> Result<()> {
154 let mut parts = Vec::new();
155 if !artist.genres.is_empty() {
156 parts.push(artist.genres.join(", "));
157 }
158 if let Some(followers) = artist.followers {
159 parts.push(format!("followers {}", followers));
160 }
161 if parts.is_empty() {
162 println!("{}", artist.name);
163 } else {
164 println!("{} ({})", artist.name, parts.join(" | "));
165 }
166 Ok(())
167}
168
169pub fn playlist_list(
170 playlists: Vec<Playlist>,
171 user_name: Option<&str>,
172 table: TableConfig,
173) -> Result<()> {
174 let mut rows = Vec::new();
175 for playlist in playlists {
176 let mut tags = Vec::new();
177 if playlist.collaborative {
178 tags.push("collaborative");
179 }
180 if let Some(public) = playlist.public {
181 tags.push(if public { "public" } else { "private" });
182 }
183
184 let tag_text = tags.join(", ");
185 if let Some(owner) = playlist.owner.as_ref() {
186 rows.push(vec![
187 playlist.name,
188 display_owner(owner, user_name),
189 tag_text,
190 ]);
191 } else {
192 rows.push(vec![playlist.name, String::new(), tag_text]);
193 }
194 }
195 print_table_with_header(&rows, &["NAME", "OWNER", "TAGS"], table);
196 Ok(())
197}
198
199pub fn playlist_list_with_pins(
200 playlists: Vec<Playlist>,
201 pins: Vec<PinnedPlaylist>,
202 user_name: Option<&str>,
203 table: TableConfig,
204) -> Result<()> {
205 let mut rows = Vec::new();
206 for playlist in playlists {
207 let mut tags = Vec::new();
208 if playlist.collaborative {
209 tags.push("collaborative");
210 }
211 if let Some(public) = playlist.public {
212 tags.push(if public { "public" } else { "private" });
213 }
214 let tag_text = tags.join(", ");
215 if let Some(owner) = playlist.owner.as_ref() {
216 rows.push(vec![
217 playlist.name,
218 display_owner(owner, user_name),
219 tag_text,
220 ]);
221 } else {
222 rows.push(vec![playlist.name, String::new(), tag_text]);
223 }
224 }
225 for pin in pins {
226 rows.push(vec![pin.name, "pinned".to_string(), String::new()]);
227 }
228 print_table_with_header(&rows, &["NAME", "OWNER", "TAGS"], table);
229 Ok(())
230}
231
232pub fn help() -> Result<()> {
233 println!("spotify-cli <object> <verb> [target] [flags]");
234 println!(
235 "objects: auth, device, info, search, nowplaying, player, playlist, pin, sync, queue, recentlyplayed"
236 );
237 println!("flags: --json");
238 println!("examples:");
239 println!(" spotify-cli auth status");
240 println!(" spotify-cli search track \"boards of canada\" --play");
241 println!(" spotify-cli search \"boards of canada\"");
242 println!(" spotify-cli info album \"geogaddi\"");
243 println!(" spotify-cli nowplaying");
244 println!(" spotify-cli nowplaying like");
245 println!(" spotify-cli nowplaying addto \"MyRadar\"");
246 println!(" spotify-cli playlist list");
247 println!(" spotify-cli pin add \"Release Radar\" \"<url>\"");
248 Ok(())
249}
250
251pub fn playlist_info(playlist: PlaylistDetail, user_name: Option<&str>) -> Result<()> {
252 let owner = playlist
253 .owner
254 .as_ref()
255 .map(|owner| display_owner(owner, user_name))
256 .unwrap_or_else(|| "unknown".to_string());
257 let mut tags = Vec::new();
258 if playlist.collaborative {
259 tags.push("collaborative");
260 }
261 if let Some(public) = playlist.public {
262 tags.push(if public { "public" } else { "private" });
263 }
264 let suffix = if tags.is_empty() {
265 String::new()
266 } else {
267 format!(" [{}]", tags.join(", "))
268 };
269 if let Some(total) = playlist.tracks_total {
270 println!("{} ({}) - {} tracks{}", playlist.name, owner, total, suffix);
271 } else {
272 println!("{} ({}){}", playlist.name, owner, suffix);
273 }
274 Ok(())
275}
276
277pub fn device_list(devices: Vec<Device>, table: TableConfig) -> Result<()> {
278 let mut rows = Vec::new();
279 for device in devices {
280 let volume = device
281 .volume_percent
282 .map(|v| v.to_string())
283 .unwrap_or_default();
284 rows.push(vec![device.name, volume]);
285 }
286 print_table_with_header(&rows, &["NAME", "VOLUME"], table);
287 Ok(())
288}
289
290fn format_optional_details(parts: &[Option<String>]) -> String {
291 let filtered: Vec<String> = parts.iter().filter_map(|part| part.clone()).collect();
292 filtered.join(" | ")
293}
294
295fn display_owner(owner: &str, user_name: Option<&str>) -> String {
296 if let Some(user_name) = user_name {
297 if user_name.eq_ignore_ascii_case(owner) {
298 return "You".to_string();
299 }
300 }
301 owner.to_string()
302}
303
304fn format_progress(progress_ms: Option<u32>, duration_ms: Option<u32>) -> String {
305 let Some(progress_ms) = progress_ms else {
306 return String::new();
307 };
308 let duration_ms = duration_ms.unwrap_or(0);
309 if duration_ms == 0 {
310 return format!(" [{}]", format_time(progress_ms));
311 }
312 format!(
313 " [{} / {}]",
314 format_time(progress_ms),
315 format_time(duration_ms)
316 )
317}
318
319fn format_time(ms: u32) -> String {
320 let total_seconds = ms / 1000;
321 let minutes = total_seconds / 60;
322 let seconds = total_seconds % 60;
323 format!("{minutes}:{seconds:02}")
324}
325
326fn format_duration(ms: u64) -> String {
327 let total_seconds = ms / 1000;
328 let minutes = total_seconds / 60;
329 let seconds = total_seconds % 60;
330 format!("{minutes}:{seconds:02}")
331}
332
333pub fn search_results(results: SearchResults, table: TableConfig) -> Result<()> {
334 let mut rows = Vec::new();
335 let show_kind = results.kind == crate::domain::search::SearchType::All;
336 for (index, item) in results.items.into_iter().enumerate() {
337 if show_kind {
338 let name = item.name;
339 let by = if !item.artists.is_empty() {
340 item.artists.join(", ")
341 } else if let Some(owner) = item.owner {
342 owner
343 } else {
344 String::new()
345 };
346 let score = item
347 .score
348 .map(|score| format!("{:.2}", score))
349 .unwrap_or_default();
350 rows.push(vec![
351 (index + 1).to_string(),
352 format_search_kind(item.kind),
353 name,
354 by,
355 score,
356 ]);
357 continue;
358 }
359
360 match results.kind {
361 crate::domain::search::SearchType::Track => {
362 let artists = item.artists.join(", ");
363 let album = item.album.unwrap_or_default();
364 let duration = item
365 .duration_ms
366 .map(|ms| format_duration(ms as u64))
367 .unwrap_or_default();
368 let score = item
369 .score
370 .map(|score| format!("{:.2}", score))
371 .unwrap_or_default();
372 rows.push(vec![
373 (index + 1).to_string(),
374 item.name,
375 artists,
376 album,
377 duration,
378 score,
379 ]);
380 }
381 crate::domain::search::SearchType::Album => {
382 let artists = item.artists.join(", ");
383 let score = item
384 .score
385 .map(|score| format!("{:.2}", score))
386 .unwrap_or_default();
387 rows.push(vec![(index + 1).to_string(), item.name, artists, score]);
388 }
389 crate::domain::search::SearchType::Artist => {
390 let score = item
391 .score
392 .map(|score| format!("{:.2}", score))
393 .unwrap_or_default();
394 rows.push(vec![(index + 1).to_string(), item.name, score]);
395 }
396 crate::domain::search::SearchType::Playlist => {
397 let owner = item.owner.unwrap_or_default();
398 let score = item
399 .score
400 .map(|score| format!("{:.2}", score))
401 .unwrap_or_default();
402 rows.push(vec![(index + 1).to_string(), item.name, owner, score]);
403 }
404 crate::domain::search::SearchType::All => {}
405 }
406 }
407 if show_kind {
408 print_table_with_header(&rows, &["#", "TYPE", "NAME", "BY", "SCORE"], table);
409 } else {
410 match results.kind {
411 crate::domain::search::SearchType::Track => {
412 print_table_with_header(
413 &rows,
414 &["#", "TRACK", "ARTIST", "ALBUM", "DURATION", "SCORE"],
415 table,
416 );
417 }
418 crate::domain::search::SearchType::Album => {
419 print_table_with_header(&rows, &["#", "ALBUM", "ARTIST", "SCORE"], table);
420 }
421 crate::domain::search::SearchType::Artist => {
422 print_table_with_header(&rows, &["#", "ARTIST", "SCORE"], table);
423 }
424 crate::domain::search::SearchType::Playlist => {
425 print_table_with_header(&rows, &["#", "PLAYLIST", "OWNER", "SCORE"], table);
426 }
427 crate::domain::search::SearchType::All => {}
428 }
429 }
430 Ok(())
431}
432
433pub fn queue(items: Vec<Track>, now_playing_id: Option<&str>, table: TableConfig) -> Result<()> {
434 let mut rows = Vec::new();
435 for (index, track) in items.into_iter().enumerate() {
436 let Track {
437 id,
438 name,
439 artists,
440 album,
441 duration_ms,
442 ..
443 } = track;
444 let mut name = name;
445 if now_playing_id.is_some_and(|needle| needle == id) {
446 name = format!("* {}", name);
447 }
448 let artists = artists.join(", ");
449 let album = album.unwrap_or_default();
450 let duration = duration_ms
451 .map(|ms| format_duration(ms as u64))
452 .unwrap_or_default();
453 rows.push(vec![
454 (index + 1).to_string(),
455 name,
456 artists,
457 album,
458 duration,
459 ]);
460 }
461 print_table_with_header(&rows, &["#", "TRACK", "ARTIST", "ALBUM", "DURATION"], table);
462 Ok(())
463}
464
465pub fn recently_played(
466 items: Vec<SearchItem>,
467 now_playing_id: Option<&str>,
468 table: TableConfig,
469) -> Result<()> {
470 let mut rows = Vec::new();
471 for (index, item) in items.into_iter().enumerate() {
472 let mut name = item.name;
473 if now_playing_id.is_some_and(|id| id == item.id) {
474 name = format!("* {}", name);
475 }
476 let artists = item.artists.join(", ");
477 let album = item.album.unwrap_or_default();
478 let duration = item
479 .duration_ms
480 .map(|ms| format_duration(ms as u64))
481 .unwrap_or_default();
482 rows.push(vec![
483 (index + 1).to_string(),
484 name,
485 artists,
486 album,
487 duration,
488 ]);
489 }
490 print_table_with_header(&rows, &["#", "TRACK", "ARTIST", "ALBUM", "DURATION"], table);
491 Ok(())
492}
493
494fn format_search_kind(kind: crate::domain::search::SearchType) -> String {
495 match kind {
496 crate::domain::search::SearchType::Track => "track",
497 crate::domain::search::SearchType::Album => "album",
498 crate::domain::search::SearchType::Artist => "artist",
499 crate::domain::search::SearchType::Playlist => "playlist",
500 crate::domain::search::SearchType::All => "all",
501 }
502 .to_string()
503}
504
505fn print_table_with_header(rows: &[Vec<String>], headers: &[&str], table: TableConfig) {
506 let mut all_rows = Vec::new();
507 if !headers.is_empty() {
508 all_rows.push(headers.iter().map(|text| text.to_string()).collect());
509 }
510 all_rows.extend_from_slice(rows);
511 print_table(&all_rows, table);
512}
513
514fn print_table(rows: &[Vec<String>], table: TableConfig) {
515 if rows.is_empty() {
516 return;
517 }
518 let columns = rows.iter().map(|row| row.len()).max().unwrap_or(0);
519 let mut widths = vec![0usize; columns];
520 let mut processed = Vec::with_capacity(rows.len());
521 let max_width = table.max_width.unwrap_or(DEFAULT_MAX_WIDTH);
522
523 for row in rows {
524 let mut new_row = Vec::with_capacity(row.len());
525 for (index, cell) in row.iter().enumerate() {
526 let truncated = if table.truncate {
527 truncate_cell(cell, max_width)
528 } else {
529 cell.to_string()
530 };
531 widths[index] = widths[index].max(truncated.len());
532 new_row.push(truncated);
533 }
534 processed.push(new_row);
535 }
536
537 for row in processed {
538 let mut line = String::new();
539 for (index, cell) in row.iter().enumerate() {
540 if index > 0 {
541 line.push_str(" ");
542 }
543 let width = widths[index];
544 line.push_str(&format!("{:<width$}", cell, width = width));
545 }
546 println!("{}", line.trim_end());
547 }
548}
549
550pub(crate) fn truncate_cell(text: &str, max: usize) -> String {
551 if text.chars().count() <= max {
552 return text.to_string();
553 }
554 if max <= 3 {
555 return "...".to_string();
556 }
557 let mut truncated: String = text.chars().take(max - 3).collect();
558 truncated.push_str("...");
559 truncated
560}
561
562#[cfg(test)]
563mod tests {
564 use super::{
565 format_duration, format_optional_details, format_progress, format_time, truncate_cell,
566 };
567
568 #[test]
569 fn truncate_cell_keeps_short_values() {
570 assert_eq!(truncate_cell("short", 10), "short");
571 }
572
573 #[test]
574 fn truncate_cell_adds_ellipsis() {
575 assert_eq!(truncate_cell("0123456789", 8), "01234...");
576 }
577
578 #[test]
579 fn format_progress_with_duration() {
580 assert_eq!(format_progress(Some(61000), Some(120000)), " [1:01 / 2:00]");
581 }
582
583 #[test]
584 fn format_progress_without_duration() {
585 assert_eq!(format_progress(Some(61000), None), " [1:01]");
586 }
587
588 #[test]
589 fn format_time_minutes_seconds() {
590 assert_eq!(format_time(61000), "1:01");
591 }
592
593 #[test]
594 fn format_duration_minutes_seconds() {
595 assert_eq!(format_duration(125000), "2:05");
596 }
597
598 #[test]
599 fn format_optional_details_joins() {
600 let value =
601 format_optional_details(&[Some("2024".to_string()), None, Some("10".to_string())]);
602 assert_eq!(value, "2024 | 10");
603 }
604}