spotify_cli/output/
human.rs

1//! Human-readable output formatting.
2use 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}