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(format_duration),
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
295#[allow(clippy::collapsible_if)]
296fn display_owner(owner: &str, user_name: Option<&str>) -> String {
297 if let Some(user_name) = user_name {
298 if user_name.eq_ignore_ascii_case(owner) {
299 return "You".to_string();
300 }
301 }
302 owner.to_string()
303}
304
305fn format_progress(progress_ms: Option<u32>, duration_ms: Option<u32>) -> String {
306 let Some(progress_ms) = progress_ms else {
307 return String::new();
308 };
309 let duration_ms = duration_ms.unwrap_or(0);
310 if duration_ms == 0 {
311 return format!(" [{}]", format_time(progress_ms));
312 }
313 format!(
314 " [{} / {}]",
315 format_time(progress_ms),
316 format_time(duration_ms)
317 )
318}
319
320fn format_time(ms: u32) -> String {
321 let total_seconds = ms / 1000;
322 let minutes = total_seconds / 60;
323 let seconds = total_seconds % 60;
324 format!("{minutes}:{seconds:02}")
325}
326
327fn format_duration(ms: u64) -> String {
328 let total_seconds = ms / 1000;
329 let minutes = total_seconds / 60;
330 let seconds = total_seconds % 60;
331 format!("{minutes}:{seconds:02}")
332}
333
334pub fn search_results(results: SearchResults, table: TableConfig) -> Result<()> {
335 let mut rows = Vec::new();
336 let show_kind = results.kind == crate::domain::search::SearchType::All;
337 for (index, item) in results.items.into_iter().enumerate() {
338 if show_kind {
339 let name = item.name;
340 let by = if !item.artists.is_empty() {
341 item.artists.join(", ")
342 } else {
343 item.owner.unwrap_or_default()
344 };
345 let score = item
346 .score
347 .map(|score| format!("{:.2}", score))
348 .unwrap_or_default();
349 rows.push(vec![
350 (index + 1).to_string(),
351 format_search_kind(item.kind),
352 name,
353 by,
354 score,
355 ]);
356 continue;
357 }
358
359 match results.kind {
360 crate::domain::search::SearchType::Track => {
361 let artists = item.artists.join(", ");
362 let album = item.album.unwrap_or_default();
363 let duration = item
364 .duration_ms
365 .map(|ms| format_duration(ms as u64))
366 .unwrap_or_default();
367 let score = item
368 .score
369 .map(|score| format!("{:.2}", score))
370 .unwrap_or_default();
371 rows.push(vec![
372 (index + 1).to_string(),
373 item.name,
374 artists,
375 album,
376 duration,
377 score,
378 ]);
379 }
380 crate::domain::search::SearchType::Album => {
381 let artists = item.artists.join(", ");
382 let score = item
383 .score
384 .map(|score| format!("{:.2}", score))
385 .unwrap_or_default();
386 rows.push(vec![(index + 1).to_string(), item.name, artists, score]);
387 }
388 crate::domain::search::SearchType::Artist => {
389 let score = item
390 .score
391 .map(|score| format!("{:.2}", score))
392 .unwrap_or_default();
393 rows.push(vec![(index + 1).to_string(), item.name, score]);
394 }
395 crate::domain::search::SearchType::Playlist => {
396 let owner = item.owner.unwrap_or_default();
397 let score = item
398 .score
399 .map(|score| format!("{:.2}", score))
400 .unwrap_or_default();
401 rows.push(vec![(index + 1).to_string(), item.name, owner, score]);
402 }
403 crate::domain::search::SearchType::All => {}
404 }
405 }
406 if show_kind {
407 print_table_with_header(&rows, &["#", "TYPE", "NAME", "BY", "SCORE"], table);
408 } else {
409 match results.kind {
410 crate::domain::search::SearchType::Track => {
411 print_table_with_header(
412 &rows,
413 &["#", "TRACK", "ARTIST", "ALBUM", "DURATION", "SCORE"],
414 table,
415 );
416 }
417 crate::domain::search::SearchType::Album => {
418 print_table_with_header(&rows, &["#", "ALBUM", "ARTIST", "SCORE"], table);
419 }
420 crate::domain::search::SearchType::Artist => {
421 print_table_with_header(&rows, &["#", "ARTIST", "SCORE"], table);
422 }
423 crate::domain::search::SearchType::Playlist => {
424 print_table_with_header(&rows, &["#", "PLAYLIST", "OWNER", "SCORE"], table);
425 }
426 crate::domain::search::SearchType::All => {}
427 }
428 }
429 Ok(())
430}
431
432pub fn queue(items: Vec<Track>, now_playing_id: Option<&str>, table: TableConfig) -> Result<()> {
433 let mut rows = Vec::new();
434 for (index, track) in items.into_iter().enumerate() {
435 let Track {
436 id,
437 name,
438 artists,
439 album,
440 duration_ms,
441 ..
442 } = track;
443 let mut name = name;
444 if now_playing_id.is_some_and(|needle| needle == id) {
445 name = format!("* {}", name);
446 }
447 let artists = artists.join(", ");
448 let album = album.unwrap_or_default();
449 let duration = duration_ms
450 .map(|ms| format_duration(ms as u64))
451 .unwrap_or_default();
452 rows.push(vec![
453 (index + 1).to_string(),
454 name,
455 artists,
456 album,
457 duration,
458 ]);
459 }
460 print_table_with_header(&rows, &["#", "TRACK", "ARTIST", "ALBUM", "DURATION"], table);
461 Ok(())
462}
463
464pub fn recently_played(
465 items: Vec<SearchItem>,
466 now_playing_id: Option<&str>,
467 table: TableConfig,
468) -> Result<()> {
469 let mut rows = Vec::new();
470 for (index, item) in items.into_iter().enumerate() {
471 let mut name = item.name;
472 if now_playing_id.is_some_and(|id| id == item.id) {
473 name = format!("* {}", name);
474 }
475 let artists = item.artists.join(", ");
476 let album = item.album.unwrap_or_default();
477 let duration = item
478 .duration_ms
479 .map(|ms| format_duration(ms as u64))
480 .unwrap_or_default();
481 rows.push(vec![
482 (index + 1).to_string(),
483 name,
484 artists,
485 album,
486 duration,
487 ]);
488 }
489 print_table_with_header(&rows, &["#", "TRACK", "ARTIST", "ALBUM", "DURATION"], table);
490 Ok(())
491}
492
493fn format_search_kind(kind: crate::domain::search::SearchType) -> String {
494 match kind {
495 crate::domain::search::SearchType::Track => "track",
496 crate::domain::search::SearchType::Album => "album",
497 crate::domain::search::SearchType::Artist => "artist",
498 crate::domain::search::SearchType::Playlist => "playlist",
499 crate::domain::search::SearchType::All => "all",
500 }
501 .to_string()
502}
503
504fn print_table_with_header(rows: &[Vec<String>], headers: &[&str], table: TableConfig) {
505 let mut all_rows = Vec::new();
506 if !headers.is_empty() {
507 all_rows.push(headers.iter().map(|text| text.to_string()).collect());
508 }
509 all_rows.extend_from_slice(rows);
510 print_table(&all_rows, table);
511}
512
513fn print_table(rows: &[Vec<String>], table: TableConfig) {
514 if rows.is_empty() {
515 return;
516 }
517 let columns = rows.iter().map(|row| row.len()).max().unwrap_or(0);
518 let mut widths = vec![0usize; columns];
519 let mut processed = Vec::with_capacity(rows.len());
520 let max_width = table.max_width.unwrap_or(DEFAULT_MAX_WIDTH);
521
522 for row in rows {
523 let mut new_row = Vec::with_capacity(row.len());
524 for (index, cell) in row.iter().enumerate() {
525 let truncated = if table.truncate {
526 truncate_cell(cell, max_width)
527 } else {
528 cell.to_string()
529 };
530 widths[index] = widths[index].max(truncated.len());
531 new_row.push(truncated);
532 }
533 processed.push(new_row);
534 }
535
536 for row in processed {
537 let mut line = String::new();
538 for (index, cell) in row.iter().enumerate() {
539 if index > 0 {
540 line.push_str(" ");
541 }
542 let width = widths[index];
543 line.push_str(&format!("{:<width$}", cell, width = width));
544 }
545 println!("{}", line.trim_end());
546 }
547}
548
549pub(crate) fn truncate_cell(text: &str, max: usize) -> String {
550 if text.chars().count() <= max {
551 return text.to_string();
552 }
553 if max <= 3 {
554 return "...".to_string();
555 }
556 let mut truncated: String = text.chars().take(max - 3).collect();
557 truncated.push_str("...");
558 truncated
559}
560
561#[cfg(test)]
562mod tests {
563 use super::{
564 format_duration, format_optional_details, format_progress, format_time, truncate_cell,
565 };
566
567 #[test]
568 fn truncate_cell_keeps_short_values() {
569 assert_eq!(truncate_cell("short", 10), "short");
570 }
571
572 #[test]
573 fn truncate_cell_adds_ellipsis() {
574 assert_eq!(truncate_cell("0123456789", 8), "01234...");
575 }
576
577 #[test]
578 fn format_progress_with_duration() {
579 assert_eq!(format_progress(Some(61000), Some(120000)), " [1:01 / 2:00]");
580 }
581
582 #[test]
583 fn format_progress_without_duration() {
584 assert_eq!(format_progress(Some(61000), None), " [1:01]");
585 }
586
587 #[test]
588 fn format_time_minutes_seconds() {
589 assert_eq!(format_time(61000), "1:01");
590 }
591
592 #[test]
593 fn format_duration_minutes_seconds() {
594 assert_eq!(format_duration(125000), "2:05");
595 }
596
597 #[test]
598 fn format_optional_details_joins() {
599 let value =
600 format_optional_details(&[Some("2024".to_string()), None, Some("10".to_string())]);
601 assert_eq!(value, "2024 | 10");
602 }
603}