spotify_cli/io/formatters/
audiobook.rs

1//! Audiobook formatting functions
2
3use serde_json::Value;
4
5use crate::io::common::{DurationFormat, format_duration_as, print_table, truncate};
6
7pub fn format_audiobook_detail(payload: &Value) {
8    let name = payload
9        .get("name")
10        .and_then(|v| v.as_str())
11        .unwrap_or("Unknown");
12    let authors: Vec<&str> = payload
13        .get("authors")
14        .and_then(|a| a.as_array())
15        .map(|arr| {
16            arr.iter()
17                .filter_map(|a| a.get("name").and_then(|n| n.as_str()))
18                .collect()
19        })
20        .unwrap_or_default();
21    let narrators: Vec<&str> = payload
22        .get("narrators")
23        .and_then(|a| a.as_array())
24        .map(|arr| {
25            arr.iter()
26                .filter_map(|n| n.get("name").and_then(|v| v.as_str()))
27                .collect()
28        })
29        .unwrap_or_default();
30    let publisher = payload
31        .get("publisher")
32        .and_then(|v| v.as_str())
33        .unwrap_or("Unknown");
34    let description = payload
35        .get("description")
36        .and_then(|v| v.as_str())
37        .unwrap_or("");
38    let total_chapters = payload
39        .get("total_chapters")
40        .and_then(|v| v.as_u64())
41        .unwrap_or(0);
42    let explicit = payload
43        .get("explicit")
44        .and_then(|v| v.as_bool())
45        .unwrap_or(false);
46    let uri = payload.get("uri").and_then(|v| v.as_str()).unwrap_or("");
47
48    println!("{}", name);
49    if !authors.is_empty() {
50        println!("  Author: {}", authors.join(", "));
51    }
52    if !narrators.is_empty() {
53        println!("  Narrator: {}", narrators.join(", "));
54    }
55    println!("  Publisher: {}", publisher);
56    if !description.is_empty() {
57        let desc = if description.len() > 200 {
58            format!("{}...", &description[..200])
59        } else {
60            description.to_string()
61        };
62        println!("  Description: {}", desc);
63    }
64    println!("  Total Chapters: {}", total_chapters);
65    if explicit {
66        println!("  Explicit: Yes");
67    }
68    if !uri.is_empty() {
69        println!("  URI: {}", uri);
70    }
71}
72
73pub fn format_audiobooks(items: &[Value], message: &str) {
74    println!("{}:", message);
75    println!();
76
77    // Filter out ghost entries (audiobooks that are no longer available)
78    let valid_items: Vec<_> = items
79        .iter()
80        .filter(|item| {
81            let audiobook = item.get("audiobook").unwrap_or(*item);
82            // Check if the audiobook has a valid id (non-null)
83            audiobook.get("id").and_then(|v| v.as_str()).is_some()
84        })
85        .collect();
86
87    let rows: Vec<Vec<String>> = valid_items
88        .iter()
89        .enumerate()
90        .map(|(i, item)| {
91            // Handle both direct audiobook objects and wrapped objects
92            let audiobook = item.get("audiobook").unwrap_or(*item);
93            let name = audiobook
94                .get("name")
95                .and_then(|v| v.as_str())
96                .unwrap_or("Unknown");
97            let authors: Vec<&str> = audiobook
98                .get("authors")
99                .and_then(|a| a.as_array())
100                .map(|arr| {
101                    arr.iter()
102                        .filter_map(|a| a.get("name").and_then(|n| n.as_str()))
103                        .collect()
104                })
105                .unwrap_or_default();
106            let chapters = audiobook
107                .get("total_chapters")
108                .and_then(|v| v.as_u64())
109                .unwrap_or(0);
110            vec![
111                (i + 1).to_string(),
112                truncate(name, 30),
113                truncate(&authors.join(", "), 20),
114                chapters.to_string(),
115            ]
116        })
117        .collect();
118
119    print_table(
120        "Audiobooks",
121        &["#", "Name", "Author", "Chapters"],
122        &rows,
123        &[3, 30, 20, 8],
124    );
125}
126
127pub fn format_audiobook_chapters(items: &[Value], message: &str) {
128    println!("{}:", message);
129    println!();
130
131    let rows: Vec<Vec<String>> = items
132        .iter()
133        .enumerate()
134        .map(|(i, item)| {
135            let name = item
136                .get("name")
137                .and_then(|v| v.as_str())
138                .unwrap_or("Unknown");
139            let duration_ms = item
140                .get("duration_ms")
141                .and_then(|v| v.as_u64())
142                .unwrap_or(0);
143            let chapter_number = item.get("chapter_number").and_then(|v| v.as_u64());
144            vec![
145                chapter_number
146                    .map(|n| n.to_string())
147                    .unwrap_or_else(|| (i + 1).to_string()),
148                truncate(name, 40),
149                format_duration_as(duration_ms, DurationFormat::Long),
150            ]
151        })
152        .collect();
153
154    print_table("Chapters", &["#", "Name", "Duration"], &rows, &[3, 40, 10]);
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use serde_json::json;
161
162    #[test]
163    fn format_audiobook_detail_full() {
164        let payload = json!({
165            "name": "The Great Audiobook",
166            "authors": [{ "name": "Author One" }, { "name": "Author Two" }],
167            "narrators": [{ "name": "Narrator One" }],
168            "publisher": "Test Publisher",
169            "description": "A fascinating audiobook about testing",
170            "total_chapters": 25,
171            "explicit": true,
172            "uri": "spotify:audiobook:abc123"
173        });
174        format_audiobook_detail(&payload);
175    }
176
177    #[test]
178    fn format_audiobook_detail_minimal() {
179        let payload = json!({});
180        format_audiobook_detail(&payload);
181    }
182
183    #[test]
184    fn format_audiobook_detail_long_description() {
185        let long_desc = "A".repeat(300);
186        let payload = json!({
187            "name": "Audiobook",
188            "description": long_desc
189        });
190        format_audiobook_detail(&payload);
191    }
192
193    #[test]
194    fn format_audiobook_detail_no_authors() {
195        let payload = json!({
196            "name": "Anonymous Audiobook",
197            "publisher": "Publisher",
198            "total_chapters": 10
199        });
200        format_audiobook_detail(&payload);
201    }
202
203    #[test]
204    fn format_audiobooks_with_items() {
205        let items = vec![
206            json!({
207                "name": "Audiobook One",
208                "authors": [{ "name": "Author A" }],
209                "total_chapters": 15
210            }),
211            json!({
212                "name": "Audiobook Two",
213                "authors": [{ "name": "Author B" }, { "name": "Author C" }],
214                "total_chapters": 30
215            }),
216        ];
217        format_audiobooks(&items, "Your Audiobooks");
218    }
219
220    #[test]
221    fn format_audiobooks_empty() {
222        let items: Vec<Value> = vec![];
223        format_audiobooks(&items, "No Audiobooks");
224    }
225
226    #[test]
227    fn format_audiobooks_wrapped() {
228        let items = vec![json!({
229            "audiobook": {
230                "name": "Wrapped Audiobook",
231                "authors": [{ "name": "Author" }],
232                "total_chapters": 20
233            }
234        })];
235        format_audiobooks(&items, "Saved Audiobooks");
236    }
237
238    #[test]
239    fn format_audiobook_chapters_with_items() {
240        let items = vec![
241            json!({
242                "name": "Chapter 1: Introduction",
243                "duration_ms": 600000,
244                "chapter_number": 1
245            }),
246            json!({
247                "name": "Chapter 2: The Beginning",
248                "duration_ms": 1200000,
249                "chapter_number": 2
250            }),
251        ];
252        format_audiobook_chapters(&items, "Chapters");
253    }
254
255    #[test]
256    fn format_audiobook_chapters_empty() {
257        let items: Vec<Value> = vec![];
258        format_audiobook_chapters(&items, "No Chapters");
259    }
260
261    #[test]
262    fn format_audiobook_chapters_no_chapter_number() {
263        let items = vec![
264            json!({ "name": "Prologue", "duration_ms": 300000 }),
265            json!({ "name": "Epilogue", "duration_ms": 180000 }),
266        ];
267        format_audiobook_chapters(&items, "Chapters");
268    }
269}