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