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