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(|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}