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::SearchResults;
10use crate::error::Result;
11use crate::output::{TableConfig, DEFAULT_MAX_WIDTH};
12
13pub fn auth_status(status: AuthStatus) -> Result<()> {
14 if status.logged_in {
15 println!("logged_in");
16 return Ok(());
17 }
18
19 println!("logged_out");
20 Ok(())
21}
22
23pub fn auth_scopes(scopes: AuthScopes) -> Result<()> {
24 println!("Scopes:");
25 for scope in scopes.required {
26 let status = if let Some(granted) = scopes.granted.as_ref() {
27 if granted.iter().any(|item| item == &scope) {
28 "ok"
29 } else {
30 "missing"
31 }
32 } else {
33 "unknown"
34 };
35 println!("{:<32} {}", scope, status);
36 }
37 Ok(())
38}
39
40pub fn player_status(status: PlayerStatus) -> Result<()> {
41 let state = if status.is_playing { "playing" } else { "paused" };
42 let context = playback_context_line(&status);
43
44 if let Some(track) = status.track {
45 let artists = if track.artists.is_empty() {
46 String::new()
47 } else {
48 format!(" - {}", track.artists.join(", "))
49 };
50 let album = track
51 .album
52 .as_ref()
53 .map(|album| format!(" ({})", album))
54 .unwrap_or_default();
55 let progress = format_progress(status.progress_ms, track.duration_ms);
56 println!("{}: {}{}{}{}", state, track.name, album, artists, progress);
57 if let Some(line) = context {
58 println!("{}", line);
59 }
60 return Ok(());
61 }
62
63 println!("{}", state);
64 if let Some(line) = context {
65 println!("{}", line);
66 }
67 Ok(())
68}
69
70pub fn now_playing(status: PlayerStatus) -> Result<()> {
71 if let Some(track) = status.track {
72 let artists = if track.artists.is_empty() {
73 String::new()
74 } else {
75 format!(" - {}", track.artists.join(", "))
76 };
77 let album = track
78 .album
79 .as_ref()
80 .map(|album| format!(" ({})", album))
81 .unwrap_or_default();
82 let progress = format_progress(status.progress_ms, track.duration_ms);
83 println!("Now Playing: {}{}{}{}", track.name, album, artists, progress);
84 return Ok(());
85 }
86
87 println!("Now Playing: (no active track)");
88 Ok(())
89}
90
91fn playback_context_line(status: &PlayerStatus) -> Option<String> {
92 let repeat = status.repeat_state.as_deref();
93 let shuffle = status.shuffle_state;
94
95 if repeat.is_none() && shuffle.is_none() {
96 return None;
97 }
98
99 let repeat_text = repeat.unwrap_or("unknown");
100 let shuffle_text = match shuffle {
101 Some(true) => "on",
102 Some(false) => "off",
103 None => "unknown",
104 };
105
106 Some(format!("repeat: {}, shuffle: {}", repeat_text, shuffle_text))
107}
108
109pub fn action(message: &str) -> Result<()> {
110 println!("{}", message);
111 Ok(())
112}
113
114pub fn album_info(album: Album, table: TableConfig) -> Result<()> {
115 let artists = if album.artists.is_empty() {
116 String::new()
117 } else {
118 format!(" - {}", album.artists.join(", "))
119 };
120 let details = format_optional_details(&[
121 album.release_date,
122 album.total_tracks.map(|t| t.to_string()),
123 album.duration_ms.map(|ms| format_duration(ms)),
124 ]);
125 if details.is_empty() {
126 println!("{}{}", album.name, artists);
127 } else {
128 println!("{}{} ({})", album.name, artists, details);
129 }
130 let mut rows = Vec::new();
131 for track in album.tracks {
132 rows.push(vec![
133 format!("{:02}.", track.track_number),
134 track.name,
135 format_duration(track.duration_ms as u64),
136 ]);
137 }
138 print_table_with_header(&rows, &["NO", "TRACK", "DURATION"], table);
139 Ok(())
140}
141
142pub fn artist_info(artist: Artist) -> Result<()> {
143 let mut parts = Vec::new();
144 if !artist.genres.is_empty() {
145 parts.push(artist.genres.join(", "));
146 }
147 if let Some(followers) = artist.followers {
148 parts.push(format!("followers {}", followers));
149 }
150 if parts.is_empty() {
151 println!("{}", artist.name);
152 } else {
153 println!("{} ({})", artist.name, parts.join(" | "));
154 }
155 Ok(())
156}
157
158pub fn playlist_list(
159 playlists: Vec<Playlist>,
160 user_name: Option<&str>,
161 table: TableConfig,
162) -> Result<()> {
163 let mut rows = Vec::new();
164 for playlist in playlists {
165 let mut tags = Vec::new();
166 if playlist.collaborative {
167 tags.push("collaborative");
168 }
169 if let Some(public) = playlist.public {
170 tags.push(if public { "public" } else { "private" });
171 }
172
173 let tag_text = tags.join(", ");
174 if let Some(owner) = playlist.owner.as_ref() {
175 rows.push(vec![
176 playlist.name,
177 display_owner(owner, user_name),
178 tag_text,
179 ]);
180 } else {
181 rows.push(vec![playlist.name, String::new(), tag_text]);
182 }
183 }
184 print_table_with_header(&rows, &["NAME", "OWNER", "TAGS"], table);
185 Ok(())
186}
187
188pub fn playlist_list_with_pins(
189 playlists: Vec<Playlist>,
190 pins: Vec<PinnedPlaylist>,
191 user_name: Option<&str>,
192 table: TableConfig,
193) -> Result<()> {
194 let mut rows = Vec::new();
195 for playlist in playlists {
196 let mut tags = Vec::new();
197 if playlist.collaborative {
198 tags.push("collaborative");
199 }
200 if let Some(public) = playlist.public {
201 tags.push(if public { "public" } else { "private" });
202 }
203 let tag_text = tags.join(", ");
204 if let Some(owner) = playlist.owner.as_ref() {
205 rows.push(vec![
206 playlist.name,
207 display_owner(owner, user_name),
208 tag_text,
209 ]);
210 } else {
211 rows.push(vec![playlist.name, String::new(), tag_text]);
212 }
213 }
214 for pin in pins {
215 rows.push(vec![pin.name, "pinned".to_string(), String::new()]);
216 }
217 print_table_with_header(&rows, &["NAME", "OWNER", "TAGS"], table);
218 Ok(())
219}
220
221pub fn help() -> Result<()> {
222 println!("spotify-cli <object> <verb> [target] [flags]");
223 println!("objects: auth, player, track, search, album, artist, playlist, device, system");
224 println!("flags: --json, -v, --width N, --no-trunc");
225 println!("examples:");
226 println!(" spotify-cli player play");
227 println!(" spotify-cli player shuffle on");
228 println!(" spotify-cli player repeat context");
229 println!(" spotify-cli track like");
230 println!(" spotify-cli search \"boards of canada\" --type track --play");
231 println!(" spotify-cli playlist list");
232 println!(" spotify-cli playlist add \"MyRadar\"");
233 println!(" spotify-cli auth scopes");
234 println!(" spotify-cli playlist pin add \"Release Radar\" \"<url>\"");
235 Ok(())
236}
237
238pub fn playlist_info(playlist: PlaylistDetail, user_name: Option<&str>) -> Result<()> {
239 let owner = playlist
240 .owner
241 .as_ref()
242 .map(|owner| display_owner(owner, user_name))
243 .unwrap_or_else(|| "unknown".to_string());
244 let mut tags = Vec::new();
245 if playlist.collaborative {
246 tags.push("collaborative");
247 }
248 if let Some(public) = playlist.public {
249 tags.push(if public { "public" } else { "private" });
250 }
251 let suffix = if tags.is_empty() {
252 String::new()
253 } else {
254 format!(" [{}]", tags.join(", "))
255 };
256 if let Some(total) = playlist.tracks_total {
257 println!("{} ({}) - {} tracks{}", playlist.name, owner, total, suffix);
258 } else {
259 println!("{} ({}){}", playlist.name, owner, suffix);
260 }
261 Ok(())
262}
263
264pub fn device_list(devices: Vec<Device>, table: TableConfig) -> Result<()> {
265 let mut rows = Vec::new();
266 for device in devices {
267 let volume = device
268 .volume_percent
269 .map(|v| v.to_string())
270 .unwrap_or_default();
271 rows.push(vec![device.name, volume]);
272 }
273 print_table_with_header(&rows, &["NAME", "VOLUME"], table);
274 Ok(())
275}
276
277
278fn format_optional_details(parts: &[Option<String>]) -> String {
279 let filtered: Vec<String> = parts.iter().filter_map(|part| part.clone()).collect();
280 filtered.join(" | ")
281}
282
283fn display_owner(owner: &str, user_name: Option<&str>) -> String {
284 if let Some(user_name) = user_name {
285 if user_name.eq_ignore_ascii_case(owner) {
286 return "You".to_string();
287 }
288 }
289 owner.to_string()
290}
291
292fn format_progress(progress_ms: Option<u32>, duration_ms: Option<u32>) -> String {
293 let Some(progress_ms) = progress_ms else {
294 return String::new();
295 };
296 let duration_ms = duration_ms.unwrap_or(0);
297 if duration_ms == 0 {
298 return format!(" [{}]", format_time(progress_ms));
299 }
300 format!(
301 " [{} / {}]",
302 format_time(progress_ms),
303 format_time(duration_ms)
304 )
305}
306
307fn format_time(ms: u32) -> String {
308 let total_seconds = ms / 1000;
309 let minutes = total_seconds / 60;
310 let seconds = total_seconds % 60;
311 format!("{minutes}:{seconds:02}")
312}
313
314fn format_duration(ms: u64) -> String {
315 let total_seconds = ms / 1000;
316 let minutes = total_seconds / 60;
317 let seconds = total_seconds % 60;
318 format!("{minutes}:{seconds:02}")
319}
320
321pub fn search_results(results: SearchResults, table: TableConfig) -> Result<()> {
322 let mut rows = Vec::new();
323 let show_kind = results.kind == crate::domain::search::SearchType::All;
324 for (index, item) in results.items.into_iter().enumerate() {
325 let name = item.name;
326 let by = if !item.artists.is_empty() {
327 item.artists.join(", ")
328 } else if let Some(owner) = item.owner {
329 owner
330 } else {
331 String::new()
332 };
333 let score = item.score.map(|score| format!("{:.2}", score)).unwrap_or_default();
334 if show_kind {
335 rows.push(vec![
336 (index + 1).to_string(),
337 format_search_kind(item.kind),
338 name,
339 by,
340 score,
341 ]);
342 } else {
343 rows.push(vec![(index + 1).to_string(), name, by, score]);
344 }
345 }
346 if show_kind {
347 print_table_with_header(&rows, &["#", "TYPE", "NAME", "BY", "SCORE"], table);
348 } else {
349 print_table_with_header(&rows, &["#", "NAME", "BY", "SCORE"], table);
350 }
351 Ok(())
352}
353
354fn format_search_kind(kind: crate::domain::search::SearchType) -> String {
355 match kind {
356 crate::domain::search::SearchType::Track => "track",
357 crate::domain::search::SearchType::Album => "album",
358 crate::domain::search::SearchType::Artist => "artist",
359 crate::domain::search::SearchType::Playlist => "playlist",
360 crate::domain::search::SearchType::All => "all",
361 }
362 .to_string()
363}
364
365fn print_table_with_header(rows: &[Vec<String>], headers: &[&str], table: TableConfig) {
366 let mut all_rows = Vec::new();
367 if !headers.is_empty() {
368 all_rows.push(headers.iter().map(|text| text.to_string()).collect());
369 }
370 all_rows.extend_from_slice(rows);
371 print_table(&all_rows, table);
372}
373
374fn print_table(rows: &[Vec<String>], table: TableConfig) {
375 if rows.is_empty() {
376 return;
377 }
378 let columns = rows.iter().map(|row| row.len()).max().unwrap_or(0);
379 let mut widths = vec![0usize; columns];
380 let mut processed = Vec::with_capacity(rows.len());
381 let max_width = table.max_width.unwrap_or(DEFAULT_MAX_WIDTH);
382
383 for row in rows {
384 let mut new_row = Vec::with_capacity(row.len());
385 for (index, cell) in row.iter().enumerate() {
386 let truncated = if table.truncate {
387 truncate_cell(cell, max_width)
388 } else {
389 cell.to_string()
390 };
391 widths[index] = widths[index].max(truncated.len());
392 new_row.push(truncated);
393 }
394 processed.push(new_row);
395 }
396
397 for row in processed {
398 let mut line = String::new();
399 for (index, cell) in row.iter().enumerate() {
400 if index > 0 {
401 line.push_str(" ");
402 }
403 let width = widths[index];
404 line.push_str(&format!("{:<width$}", cell, width = width));
405 }
406 println!("{}", line.trim_end());
407 }
408}
409
410pub(crate) fn truncate_cell(text: &str, max: usize) -> String {
411 if text.chars().count() <= max {
412 return text.to_string();
413 }
414 if max <= 3 {
415 return "...".to_string();
416 }
417 let mut truncated: String = text.chars().take(max - 3).collect();
418 truncated.push_str("...");
419 truncated
420}
421
422#[cfg(test)]
423mod tests {
424 use super::{format_duration, format_optional_details, format_progress, format_time, truncate_cell};
425
426 #[test]
427 fn truncate_cell_keeps_short_values() {
428 assert_eq!(truncate_cell("short", 10), "short");
429 }
430
431 #[test]
432 fn truncate_cell_adds_ellipsis() {
433 assert_eq!(truncate_cell("0123456789", 8), "01234...");
434 }
435
436 #[test]
437 fn format_progress_with_duration() {
438 assert_eq!(format_progress(Some(61000), Some(120000)), " [1:01 / 2:00]");
439 }
440
441 #[test]
442 fn format_progress_without_duration() {
443 assert_eq!(format_progress(Some(61000), None), " [1:01]");
444 }
445
446 #[test]
447 fn format_time_minutes_seconds() {
448 assert_eq!(format_time(61000), "1:01");
449 }
450
451 #[test]
452 fn format_duration_minutes_seconds() {
453 assert_eq!(format_duration(125000), "2:05");
454 }
455
456 #[test]
457 fn format_optional_details_joins() {
458 let value = format_optional_details(&[
459 Some("2024".to_string()),
460 None,
461 Some("10".to_string()),
462 ]);
463 assert_eq!(value, "2024 | 10");
464 }
465}