spotify_cli/io/registry/
media.rs

1//! Media formatters (podcasts/shows, episodes, audiobooks, chapters)
2
3use serde_json::Value;
4
5use super::PayloadFormatter;
6use crate::io::formatters;
7use crate::io::output::PayloadKind;
8
9// ============================================================================
10// Show/Podcast Formatters
11// ============================================================================
12
13pub struct ShowDetailFormatter;
14
15impl PayloadFormatter for ShowDetailFormatter {
16    fn name(&self) -> &'static str {
17        "show_detail"
18    }
19
20    fn supported_kinds(&self) -> &'static [PayloadKind] {
21        &[PayloadKind::Show]
22    }
23
24    fn matches(&self, payload: &Value) -> bool {
25        payload.get("publisher").is_some() && payload.get("total_episodes").is_some()
26    }
27
28    fn format(&self, payload: &Value, _message: &str) {
29        formatters::format_show_detail(payload);
30    }
31}
32
33pub struct SavedShowsFormatter;
34
35impl PayloadFormatter for SavedShowsFormatter {
36    fn name(&self) -> &'static str {
37        "saved_shows"
38    }
39
40    fn supported_kinds(&self) -> &'static [PayloadKind] {
41        &[PayloadKind::SavedShows, PayloadKind::ShowList]
42    }
43
44    fn matches(&self, payload: &Value) -> bool {
45        if let Some(items) = payload.get("items").and_then(|i| i.as_array()) {
46            !items.is_empty()
47                && (items[0].get("show").is_some()
48                    || (items[0].get("publisher").is_some()
49                        && items[0].get("total_episodes").is_some()))
50        } else {
51            false
52        }
53    }
54
55    fn format(&self, payload: &Value, message: &str) {
56        if let Some(items) = payload.get("items").and_then(|i| i.as_array()) {
57            formatters::format_shows(items, message);
58        }
59    }
60}
61
62// ============================================================================
63// Episode Formatters
64// ============================================================================
65
66pub struct EpisodeDetailFormatter;
67
68impl PayloadFormatter for EpisodeDetailFormatter {
69    fn name(&self) -> &'static str {
70        "episode_detail"
71    }
72
73    fn supported_kinds(&self) -> &'static [PayloadKind] {
74        &[PayloadKind::Episode]
75    }
76
77    fn matches(&self, payload: &Value) -> bool {
78        payload.get("show").is_some()
79            && payload.get("release_date").is_some()
80            && payload.get("duration_ms").is_some()
81    }
82
83    fn format(&self, payload: &Value, _message: &str) {
84        formatters::format_episode_detail(payload);
85    }
86}
87
88pub struct ShowEpisodesFormatter;
89
90impl PayloadFormatter for ShowEpisodesFormatter {
91    fn name(&self) -> &'static str {
92        "show_episodes"
93    }
94
95    fn supported_kinds(&self) -> &'static [PayloadKind] {
96        &[PayloadKind::EpisodeList]
97    }
98
99    fn matches(&self, payload: &Value) -> bool {
100        if let Some(items) = payload.get("items").and_then(|i| i.as_array()) {
101            !items.is_empty()
102                && items[0].get("release_date").is_some()
103                && items[0].get("duration_ms").is_some()
104                && items[0].get("album").is_none()
105                && items[0].get("artists").is_none()
106        } else {
107            false
108        }
109    }
110
111    fn format(&self, payload: &Value, message: &str) {
112        if let Some(items) = payload.get("items").and_then(|i| i.as_array()) {
113            formatters::format_show_episodes(items, message);
114        }
115    }
116}
117
118pub struct SavedEpisodesFormatter;
119
120impl PayloadFormatter for SavedEpisodesFormatter {
121    fn name(&self) -> &'static str {
122        "saved_episodes"
123    }
124
125    fn supported_kinds(&self) -> &'static [PayloadKind] {
126        &[PayloadKind::SavedEpisodes]
127    }
128
129    fn matches(&self, payload: &Value) -> bool {
130        if let Some(items) = payload.get("items").and_then(|i| i.as_array()) {
131            !items.is_empty() && items[0].get("episode").is_some()
132        } else {
133            false
134        }
135    }
136
137    fn format(&self, payload: &Value, message: &str) {
138        if let Some(items) = payload.get("items").and_then(|i| i.as_array()) {
139            formatters::format_episodes(items, message);
140        }
141    }
142}
143
144// ============================================================================
145// Audiobook Formatters
146// ============================================================================
147
148pub struct AudiobookDetailFormatter;
149
150impl PayloadFormatter for AudiobookDetailFormatter {
151    fn name(&self) -> &'static str {
152        "audiobook_detail"
153    }
154
155    fn supported_kinds(&self) -> &'static [PayloadKind] {
156        &[PayloadKind::Audiobook]
157    }
158
159    fn matches(&self, payload: &Value) -> bool {
160        payload.get("authors").is_some() && payload.get("total_chapters").is_some()
161    }
162
163    fn format(&self, payload: &Value, _message: &str) {
164        formatters::format_audiobook_detail(payload);
165    }
166}
167
168pub struct SavedAudiobooksFormatter;
169
170impl PayloadFormatter for SavedAudiobooksFormatter {
171    fn name(&self) -> &'static str {
172        "saved_audiobooks"
173    }
174
175    fn supported_kinds(&self) -> &'static [PayloadKind] {
176        &[PayloadKind::SavedAudiobooks, PayloadKind::AudiobookList]
177    }
178
179    fn matches(&self, payload: &Value) -> bool {
180        if let Some(items) = payload.get("items").and_then(|i| i.as_array()) {
181            !items.is_empty()
182                && (items[0].get("audiobook").is_some()
183                    || (items[0].get("authors").is_some()
184                        && items[0].get("total_chapters").is_some()))
185        } else {
186            false
187        }
188    }
189
190    fn format(&self, payload: &Value, message: &str) {
191        if let Some(items) = payload.get("items").and_then(|i| i.as_array()) {
192            formatters::format_audiobooks(items, message);
193        }
194    }
195}
196
197// ============================================================================
198// Chapter Formatters
199// ============================================================================
200
201pub struct ChapterDetailFormatter;
202
203impl PayloadFormatter for ChapterDetailFormatter {
204    fn name(&self) -> &'static str {
205        "chapter_detail"
206    }
207
208    fn supported_kinds(&self) -> &'static [PayloadKind] {
209        &[PayloadKind::Chapter]
210    }
211
212    fn matches(&self, payload: &Value) -> bool {
213        payload.get("audiobook").is_some() && payload.get("chapter_number").is_some()
214    }
215
216    fn format(&self, payload: &Value, _message: &str) {
217        formatters::format_chapter_detail(payload);
218    }
219}
220
221pub struct AudiobookChaptersFormatter;
222
223impl PayloadFormatter for AudiobookChaptersFormatter {
224    fn name(&self) -> &'static str {
225        "audiobook_chapters"
226    }
227
228    fn supported_kinds(&self) -> &'static [PayloadKind] {
229        &[PayloadKind::ChapterList]
230    }
231
232    fn matches(&self, payload: &Value) -> bool {
233        if let Some(items) = payload.get("items").and_then(|i| i.as_array()) {
234            !items.is_empty()
235                && (items[0].get("chapter_number").is_some()
236                    || (items[0].get("audiobook").is_some()
237                        && items[0].get("duration_ms").is_some()))
238        } else {
239            false
240        }
241    }
242
243    fn format(&self, payload: &Value, message: &str) {
244        if let Some(items) = payload.get("items").and_then(|i| i.as_array()) {
245            formatters::format_audiobook_chapters(items, message);
246        }
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use serde_json::json;
254
255    // Show tests
256    #[test]
257    fn show_detail_formatter_matches() {
258        let formatter = ShowDetailFormatter;
259        let payload = json!({ "publisher": "Test", "total_episodes": 10 });
260        assert!(formatter.matches(&payload));
261        assert!(!formatter.matches(&json!({})));
262    }
263
264    #[test]
265    fn saved_shows_formatter_matches() {
266        let formatter = SavedShowsFormatter;
267        let payload = json!({ "items": [{ "show": {} }] });
268        assert!(formatter.matches(&payload));
269        let direct = json!({ "items": [{ "publisher": "Test", "total_episodes": 5 }] });
270        assert!(formatter.matches(&direct));
271    }
272
273    // Episode tests
274    #[test]
275    fn episode_detail_formatter_matches() {
276        let formatter = EpisodeDetailFormatter;
277        let payload = json!({ "show": {}, "release_date": "2024", "duration_ms": 1000 });
278        assert!(formatter.matches(&payload));
279        assert!(!formatter.matches(&json!({})));
280    }
281
282    #[test]
283    fn show_episodes_formatter_matches() {
284        let formatter = ShowEpisodesFormatter;
285        let payload = json!({ "items": [{ "release_date": "2024", "duration_ms": 1000 }] });
286        assert!(formatter.matches(&payload));
287        let with_album =
288            json!({ "items": [{ "release_date": "2024", "duration_ms": 1000, "album": {} }] });
289        assert!(!formatter.matches(&with_album));
290    }
291
292    #[test]
293    fn saved_episodes_formatter_matches() {
294        let formatter = SavedEpisodesFormatter;
295        let payload = json!({ "items": [{ "episode": {} }] });
296        assert!(formatter.matches(&payload));
297        let empty = json!({ "items": [] });
298        assert!(!formatter.matches(&empty));
299    }
300
301    // Audiobook tests
302    #[test]
303    fn audiobook_detail_formatter_matches() {
304        let formatter = AudiobookDetailFormatter;
305        let payload = json!({ "authors": [], "total_chapters": 10 });
306        assert!(formatter.matches(&payload));
307        assert!(!formatter.matches(&json!({})));
308    }
309
310    #[test]
311    fn saved_audiobooks_formatter_matches() {
312        let formatter = SavedAudiobooksFormatter;
313        let payload = json!({ "items": [{ "audiobook": {} }] });
314        assert!(formatter.matches(&payload));
315        let direct = json!({ "items": [{ "authors": [], "total_chapters": 5 }] });
316        assert!(formatter.matches(&direct));
317    }
318
319    // Chapter tests
320    #[test]
321    fn chapter_detail_formatter_matches() {
322        let formatter = ChapterDetailFormatter;
323        let payload = json!({ "audiobook": {}, "chapter_number": 1 });
324        assert!(formatter.matches(&payload));
325        assert!(!formatter.matches(&json!({})));
326    }
327
328    #[test]
329    fn audiobook_chapters_formatter_matches() {
330        let formatter = AudiobookChaptersFormatter;
331        let payload = json!({ "items": [{ "chapter_number": 1 }] });
332        assert!(formatter.matches(&payload));
333        let alt = json!({ "items": [{ "audiobook": {}, "duration_ms": 1000 }] });
334        assert!(formatter.matches(&alt));
335    }
336
337    #[test]
338    fn formatter_names() {
339        assert_eq!(ShowDetailFormatter.name(), "show_detail");
340        assert_eq!(SavedShowsFormatter.name(), "saved_shows");
341        assert_eq!(EpisodeDetailFormatter.name(), "episode_detail");
342        assert_eq!(ShowEpisodesFormatter.name(), "show_episodes");
343        assert_eq!(SavedEpisodesFormatter.name(), "saved_episodes");
344        assert_eq!(AudiobookDetailFormatter.name(), "audiobook_detail");
345        assert_eq!(SavedAudiobooksFormatter.name(), "saved_audiobooks");
346        assert_eq!(ChapterDetailFormatter.name(), "chapter_detail");
347        assert_eq!(AudiobookChaptersFormatter.name(), "audiobook_chapters");
348    }
349
350    // Format method tests
351    #[test]
352    fn show_detail_format_runs() {
353        let formatter = ShowDetailFormatter;
354        let payload = json!({
355            "name": "Test Show",
356            "publisher": "Publisher",
357            "total_episodes": 50,
358            "description": "A test show"
359        });
360        formatter.format(&payload, "Show");
361    }
362
363    #[test]
364    fn saved_shows_format_runs() {
365        let formatter = SavedShowsFormatter;
366        let payload = json!({
367            "items": [{
368                "show": {"name": "Show", "publisher": "Publisher"}
369            }]
370        });
371        formatter.format(&payload, "Shows");
372    }
373
374    #[test]
375    fn episode_detail_format_runs() {
376        let formatter = EpisodeDetailFormatter;
377        let payload = json!({
378            "name": "Episode 1",
379            "show": {"name": "Show"},
380            "release_date": "2024-01-01",
381            "duration_ms": 3600000,
382            "description": "First episode"
383        });
384        formatter.format(&payload, "Episode");
385    }
386
387    #[test]
388    fn show_episodes_format_runs() {
389        let formatter = ShowEpisodesFormatter;
390        let payload = json!({
391            "items": [{
392                "name": "Episode",
393                "release_date": "2024-01-01",
394                "duration_ms": 1800000
395            }]
396        });
397        formatter.format(&payload, "Episodes");
398    }
399
400    #[test]
401    fn saved_episodes_format_runs() {
402        let formatter = SavedEpisodesFormatter;
403        let payload = json!({
404            "items": [{
405                "episode": {"name": "Saved Episode", "duration_ms": 1800000}
406            }]
407        });
408        formatter.format(&payload, "Saved Episodes");
409    }
410
411    #[test]
412    fn audiobook_detail_format_runs() {
413        let formatter = AudiobookDetailFormatter;
414        let payload = json!({
415            "name": "Test Audiobook",
416            "authors": [{"name": "Author"}],
417            "total_chapters": 20,
418            "description": "An audiobook"
419        });
420        formatter.format(&payload, "Audiobook");
421    }
422
423    #[test]
424    fn saved_audiobooks_format_runs() {
425        let formatter = SavedAudiobooksFormatter;
426        let payload = json!({
427            "items": [{
428                "audiobook": {"name": "Audiobook", "authors": []}
429            }]
430        });
431        formatter.format(&payload, "Audiobooks");
432    }
433
434    #[test]
435    fn chapter_detail_format_runs() {
436        let formatter = ChapterDetailFormatter;
437        let payload = json!({
438            "name": "Chapter 1",
439            "audiobook": {"name": "Book"},
440            "chapter_number": 1,
441            "duration_ms": 900000
442        });
443        formatter.format(&payload, "Chapter");
444    }
445
446    #[test]
447    fn audiobook_chapters_format_runs() {
448        let formatter = AudiobookChaptersFormatter;
449        let payload = json!({
450            "items": [{
451                "name": "Chapter 1",
452                "chapter_number": 1,
453                "duration_ms": 900000
454            }]
455        });
456        formatter.format(&payload, "Chapters");
457    }
458
459    // Edge case tests
460    #[test]
461    fn saved_shows_does_not_match_empty() {
462        let formatter = SavedShowsFormatter;
463        let payload = json!({ "items": [] });
464        assert!(!formatter.matches(&payload));
465    }
466
467    #[test]
468    fn saved_audiobooks_does_not_match_empty() {
469        let formatter = SavedAudiobooksFormatter;
470        let payload = json!({ "items": [] });
471        assert!(!formatter.matches(&payload));
472    }
473
474    #[test]
475    fn audiobook_chapters_does_not_match_empty() {
476        let formatter = AudiobookChaptersFormatter;
477        let payload = json!({ "items": [] });
478        assert!(!formatter.matches(&payload));
479    }
480
481    #[test]
482    fn show_episodes_does_not_match_with_artists() {
483        let formatter = ShowEpisodesFormatter;
484        let payload =
485            json!({ "items": [{ "release_date": "2024", "duration_ms": 1000, "artists": [] }] });
486        assert!(!formatter.matches(&payload));
487    }
488}