spotify_cli/cli/commands/
playlist.rs

1use crate::endpoints::playlists::{
2    add_items_to_playlist, change_playlist_details, create_playlist, follow_playlist,
3    get_current_user_playlists, get_playlist, get_playlist_cover_image, get_playlist_items,
4    get_users_playlists, remove_items_from_playlist, unfollow_playlist, update_playlist_items,
5};
6use crate::endpoints::user::get_current_user;
7use crate::io::output::{ErrorKind, Response};
8use crate::storage::pins::{PinStore, ResourceType};
9use std::collections::HashSet;
10
11use super::{extract_id, now_playing, with_client};
12
13/// Resolve a playlist identifier to a Spotify ID
14/// Accepts: ID, URL, or pin alias
15fn resolve_playlist_id(input: &str) -> Result<String, Response> {
16    if let Ok(store) = PinStore::new()
17        && let Some(pin) = store.find_by_alias(input)
18        && pin.resource_type == ResourceType::Playlist
19    {
20        return Ok(pin.id.clone());
21    }
22    Ok(extract_id(input))
23}
24
25/// Resolve a playlist identifier to a Spotify ID (async version with name lookup)
26/// Accepts: ID, URL, pin alias, or playlist name
27async fn resolve_playlist_id_async(
28    client: &crate::http::api::SpotifyApi,
29    input: &str,
30) -> Result<String, Response> {
31    // First try pin alias
32    if let Ok(store) = PinStore::new()
33        && let Some(pin) = store.find_by_alias(input)
34        && pin.resource_type == ResourceType::Playlist
35    {
36        return Ok(pin.id.clone());
37    }
38
39    // Check if it looks like a valid ID or URL (no spaces, valid base62 chars)
40    let extracted = extract_id(input);
41    if !input.contains(' ') && extracted.chars().all(|c| c.is_alphanumeric()) {
42        return Ok(extracted);
43    }
44
45    // Otherwise, search user's playlists by name
46    let mut offset = 0u32;
47    let limit = 50u8;
48    let search_name = input.to_lowercase();
49
50    loop {
51        match get_current_user_playlists::get_current_user_playlists(
52            client,
53            Some(limit),
54            Some(offset),
55        )
56        .await
57        {
58            Ok(Some(page)) => {
59                if let Some(items) = page.get("items").and_then(|i| i.as_array()) {
60                    // Look for exact match first, then partial match
61                    for playlist in items {
62                        if let Some(name) = playlist.get("name").and_then(|n| n.as_str())
63                            && name.to_lowercase() == search_name
64                            && let Some(id) = playlist.get("id").and_then(|i| i.as_str())
65                        {
66                            return Ok(id.to_string());
67                        }
68                    }
69
70                    // Check if there are more pages
71                    let total = page.get("total").and_then(|t| t.as_u64()).unwrap_or(0);
72                    offset += limit as u32;
73                    if offset >= total as u32 || items.is_empty() {
74                        break;
75                    }
76                } else {
77                    break;
78                }
79            }
80            Ok(None) => break,
81            Err(e) => return Err(Response::from_http_error(&e, "Failed to search playlists")),
82        }
83    }
84
85    Err(Response::err(
86        404,
87        format!(
88            "Playlist '{}' not found. Use playlist ID, URL, or exact name.",
89            input
90        ),
91        ErrorKind::NotFound,
92    ))
93}
94
95pub async fn playlist_list(limit: u8, offset: u32) -> Response {
96    with_client(|client| async move {
97        match get_current_user_playlists::get_current_user_playlists(
98            &client,
99            Some(limit),
100            Some(offset),
101        )
102        .await
103        {
104            Ok(Some(payload)) => Response::success_with_payload(200, "Your playlists", payload),
105            Ok(None) => Response::success_with_payload(
106                200,
107                "No playlists",
108                serde_json::json!({ "items": [] }),
109            ),
110            Err(e) => Response::from_http_error(&e, "Failed to get playlists"),
111        }
112    })
113    .await
114}
115
116pub async fn playlist_get(playlist: &str) -> Response {
117    let playlist_id = match resolve_playlist_id(playlist) {
118        Ok(id) => id,
119        Err(e) => return e,
120    };
121
122    with_client(|client| async move {
123        match get_playlist::get_playlist(&client, &playlist_id).await {
124            Ok(Some(payload)) => Response::success_with_payload(200, "Playlist details", payload),
125            Ok(None) => Response::err(404, "Playlist not found", ErrorKind::NotFound),
126            Err(e) => Response::from_http_error(&e, "Failed to get playlist"),
127        }
128    })
129    .await
130}
131
132pub async fn playlist_create(name: &str, description: Option<&str>, public: bool) -> Response {
133    let name = name.to_string();
134    let description = description.map(|s| s.to_string());
135
136    with_client(|client| async move {
137        let user_id = match get_current_user::get_current_user(&client).await {
138            Ok(Some(user)) => match user.get("id").and_then(|v| v.as_str()) {
139                Some(id) => id.to_string(),
140                None => return Response::err(500, "Could not get user ID", ErrorKind::Api),
141            },
142            Ok(None) => return Response::err(500, "Could not get user info", ErrorKind::Api),
143            Err(e) => return Response::from_http_error(&e, "Failed to get user info"),
144        };
145
146        match create_playlist::create_playlist(
147            &client,
148            &user_id,
149            &name,
150            description.as_deref(),
151            public,
152        )
153        .await
154        {
155            Ok(Some(payload)) => Response::success_with_payload(201, "Playlist created", payload),
156            Ok(None) => Response::err(500, "Failed to create playlist", ErrorKind::Api),
157            Err(e) => Response::from_http_error(&e, "Failed to create playlist"),
158        }
159    })
160    .await
161}
162
163pub async fn playlist_add(
164    playlist: &str,
165    uris: &[String],
166    now_playing_flag: bool,
167    position: Option<u32>,
168    dry_run: bool,
169) -> Response {
170    if uris.is_empty() && !now_playing_flag {
171        return Response::err(
172            400,
173            "Provide track URIs or use --now-playing",
174            ErrorKind::Validation,
175        );
176    }
177
178    let playlist_id = match resolve_playlist_id(playlist) {
179        Ok(id) => id,
180        Err(e) => return e,
181    };
182    let explicit_uris = uris.to_vec();
183
184    with_client(|client| async move {
185        let mut all_uris = explicit_uris;
186
187        if now_playing_flag {
188            match now_playing::get_track_uri(&client).await {
189                Ok(uri) => all_uris.push(uri),
190                Err(e) => return e,
191            }
192        }
193
194        let uri_count = all_uris.len();
195
196        if dry_run {
197            return Response::success_with_payload(
198                200,
199                format!(
200                    "[DRY RUN] Would add {} track(s) to playlist {}",
201                    uri_count, playlist_id
202                ),
203                serde_json::json!({
204                    "dry_run": true,
205                    "action": "add",
206                    "playlist_id": playlist_id,
207                    "uris": all_uris,
208                    "position": position
209                }),
210            );
211        }
212
213        match add_items_to_playlist::add_items_to_playlist(
214            &client,
215            &playlist_id,
216            &all_uris,
217            position,
218        )
219        .await
220        {
221            Ok(Some(payload)) => Response::success_with_payload(
222                201,
223                format!("Added {} track(s)", uri_count),
224                payload,
225            ),
226            Ok(None) => Response::success(201, format!("Added {} track(s)", uri_count)),
227            Err(e) => Response::from_http_error(&e, "Failed to add tracks"),
228        }
229    })
230    .await
231}
232
233pub async fn playlist_remove(playlist: &str, uris: &[String], dry_run: bool) -> Response {
234    let playlist_id = match resolve_playlist_id(playlist) {
235        Ok(id) => id,
236        Err(e) => return e,
237    };
238    let uris = uris.to_vec();
239    let uri_count = uris.len();
240
241    if dry_run {
242        return Response::success_with_payload(
243            200,
244            format!(
245                "[DRY RUN] Would remove {} track(s) from playlist {}",
246                uri_count, playlist_id
247            ),
248            serde_json::json!({
249                "dry_run": true,
250                "action": "remove",
251                "playlist_id": playlist_id,
252                "uris": uris
253            }),
254        );
255    }
256
257    with_client(|client| async move {
258        match remove_items_from_playlist::remove_items_from_playlist(&client, &playlist_id, &uris)
259            .await
260        {
261            Ok(Some(payload)) => Response::success_with_payload(
262                200,
263                format!("Removed {} track(s)", uri_count),
264                payload,
265            ),
266            Ok(None) => Response::success(200, format!("Removed {} track(s)", uri_count)),
267            Err(e) => Response::from_http_error(&e, "Failed to remove tracks"),
268        }
269    })
270    .await
271}
272
273pub async fn playlist_edit(
274    playlist: &str,
275    name: Option<&str>,
276    description: Option<&str>,
277    public: Option<bool>,
278) -> Response {
279    if name.is_none() && description.is_none() && public.is_none() {
280        return Response::err(
281            400,
282            "No changes specified. Use --name, --description, --public, or --private",
283            ErrorKind::Validation,
284        );
285    }
286
287    let playlist_id = match resolve_playlist_id(playlist) {
288        Ok(id) => id,
289        Err(e) => return e,
290    };
291    let name = name.map(|s| s.to_string());
292    let description = description.map(|s| s.to_string());
293
294    with_client(|client| async move {
295        match change_playlist_details::change_playlist_details(
296            &client,
297            &playlist_id,
298            name.as_deref(),
299            description.as_deref(),
300            public,
301        )
302        .await
303        {
304            Ok(_) => Response::success(200, "Playlist updated"),
305            Err(e) => Response::from_http_error(&e, "Failed to update playlist"),
306        }
307    })
308    .await
309}
310
311pub async fn playlist_reorder(playlist: &str, from: u32, to: u32, count: u32) -> Response {
312    let playlist_id = match resolve_playlist_id(playlist) {
313        Ok(id) => id,
314        Err(e) => return e,
315    };
316
317    with_client(|client| async move {
318        match update_playlist_items::reorder_playlist_items(
319            &client,
320            &playlist_id,
321            from,
322            to,
323            Some(count),
324        )
325        .await
326        {
327            Ok(_) => Response::success(
328                200,
329                format!("Moved {} track(s) from position {} to {}", count, from, to),
330            ),
331            Err(e) => Response::from_http_error(&e, "Failed to reorder tracks"),
332        }
333    })
334    .await
335}
336
337pub async fn playlist_follow(playlist: &str, public: bool) -> Response {
338    let playlist_id = extract_id(playlist);
339
340    with_client(|client| async move {
341        match follow_playlist::follow_playlist(&client, &playlist_id, Some(public)).await {
342            Ok(_) => Response::success(200, "Following playlist"),
343            Err(e) => Response::from_http_error(&e, "Failed to follow playlist"),
344        }
345    })
346    .await
347}
348
349pub async fn playlist_unfollow(playlist: &str) -> Response {
350    let playlist_id = extract_id(playlist);
351
352    with_client(|client| async move {
353        match unfollow_playlist::unfollow_playlist(&client, &playlist_id).await {
354            Ok(_) => Response::success(200, "Unfollowed playlist"),
355            Err(e) => Response::from_http_error(&e, "Failed to unfollow playlist"),
356        }
357    })
358    .await
359}
360
361pub async fn playlist_duplicate(playlist: &str, new_name: Option<&str>) -> Response {
362    let playlist_id = match resolve_playlist_id(playlist) {
363        Ok(id) => id,
364        Err(e) => return e,
365    };
366    let new_name = new_name.map(|s| s.to_string());
367
368    with_client(|client| async move {
369        // Get the source playlist
370        let source = match get_playlist::get_playlist(&client, &playlist_id).await {
371            Ok(Some(p)) => p,
372            Ok(None) => return Response::err(404, "Playlist not found", ErrorKind::NotFound),
373            Err(e) => return Response::from_http_error(&e, "Failed to get playlist"),
374        };
375
376        // Get source details
377        let source_name = source
378            .get("name")
379            .and_then(|v| v.as_str())
380            .unwrap_or("Playlist");
381        let default_name = format!("{} (Copy)", source_name);
382        let name = new_name.as_deref().unwrap_or(&default_name);
383        let description = source.get("description").and_then(|v| v.as_str());
384
385        // Get current user ID
386        let user = match get_current_user::get_current_user(&client).await {
387            Ok(Some(u)) => u,
388            Ok(None) => return Response::err(500, "Failed to get user info", ErrorKind::Api),
389            Err(e) => return Response::from_http_error(&e, "Failed to get user info"),
390        };
391        let user_id = match user.get("id").and_then(|v| v.as_str()) {
392            Some(id) => id,
393            None => return Response::err(500, "Failed to get user ID", ErrorKind::Api),
394        };
395
396        // Create new playlist
397        let new_playlist = match create_playlist::create_playlist(
398            &client,
399            user_id,
400            name,
401            description,
402            false,
403        )
404        .await
405        {
406            Ok(Some(p)) => p,
407            Ok(None) => return Response::err(500, "Failed to create playlist", ErrorKind::Api),
408            Err(e) => return Response::from_http_error(&e, "Failed to create playlist"),
409        };
410
411        let new_playlist_id = match new_playlist.get("id").and_then(|v| v.as_str()) {
412            Some(id) => id,
413            None => return Response::err(500, "Failed to get new playlist ID", ErrorKind::Api),
414        };
415
416        // Get tracks from source and add to new playlist
417        if let Some(tracks) = source
418            .get("tracks")
419            .and_then(|t| t.get("items"))
420            .and_then(|i| i.as_array())
421        {
422            let uris: Vec<String> = tracks
423                .iter()
424                .filter_map(|item| {
425                    item.get("track")
426                        .and_then(|t| t.get("uri"))
427                        .and_then(|u| u.as_str())
428                        .map(|s| s.to_string())
429                })
430                .collect();
431
432            if !uris.is_empty()
433                && let Err(e) = add_items_to_playlist::add_items_to_playlist(
434                    &client,
435                    new_playlist_id,
436                    &uris,
437                    None,
438                )
439                .await
440            {
441                return Response::from_http_error(&e, "Created playlist but failed to copy tracks");
442            }
443        }
444
445        Response::success_with_payload(
446            200,
447            format!("Duplicated playlist as '{}'", name),
448            new_playlist,
449        )
450    })
451    .await
452}
453
454/// Get playlist cover image URL
455pub async fn playlist_cover(playlist: &str) -> Response {
456    let playlist_id = match resolve_playlist_id(playlist) {
457        Ok(id) => id,
458        Err(e) => return e,
459    };
460
461    with_client(|client| async move {
462        match get_playlist_cover_image::get_playlist_cover_image(&client, &playlist_id).await {
463            Ok(Some(payload)) => {
464                Response::success_with_payload(200, "Playlist cover image", payload)
465            }
466            Ok(None) => Response::err(404, "No cover image found", ErrorKind::NotFound),
467            Err(e) => Response::from_http_error(&e, "Failed to get playlist cover"),
468        }
469    })
470    .await
471}
472
473/// Get another user's playlists
474pub async fn playlist_user(user_id: &str) -> Response {
475    let user_id = user_id.to_string();
476
477    with_client(|client| async move {
478        match get_users_playlists::get_users_playlists(&client, &user_id).await {
479            Ok(Some(payload)) => Response::success_with_payload(
480                200,
481                format!("Playlists for user {}", user_id),
482                payload,
483            ),
484            Ok(None) => Response::success_with_payload(
485                200,
486                "No playlists found",
487                serde_json::json!({ "items": [] }),
488            ),
489            Err(e) => Response::from_http_error(&e, "Failed to get user's playlists"),
490        }
491    })
492    .await
493}
494
495/// Remove duplicate tracks from a playlist (keeps first occurrence)
496pub async fn playlist_deduplicate(playlist: &str, dry_run: bool) -> Response {
497    let playlist_input = playlist.to_string();
498
499    with_client(|client| async move {
500        // Resolve playlist (supports ID, URL, pin alias, or name)
501        let playlist_id = match resolve_playlist_id_async(&client, &playlist_input).await {
502            Ok(id) => id,
503            Err(e) => return e,
504        };
505
506        // Fetch all tracks with pagination
507        let mut all_tracks: Vec<serde_json::Value> = Vec::new();
508        let mut offset = 0u32;
509        let limit = 50u8;
510
511        loop {
512            match get_playlist_items::get_playlist_items(
513                &client,
514                &playlist_id,
515                Some(limit),
516                Some(offset),
517            )
518            .await
519            {
520                Ok(Some(page)) => {
521                    let items = page.get("items").and_then(|i| i.as_array());
522                    match items {
523                        Some(tracks) if !tracks.is_empty() => {
524                            all_tracks.extend(tracks.iter().cloned());
525                            let total = page.get("total").and_then(|t| t.as_u64()).unwrap_or(0);
526                            offset += limit as u32;
527                            if offset >= total as u32 {
528                                break;
529                            }
530                        }
531                        _ => break,
532                    }
533                }
534                Ok(None) => break,
535                Err(e) => return Response::from_http_error(&e, "Failed to fetch playlist tracks"),
536            }
537        }
538
539        if all_tracks.is_empty() {
540            return Response::success(200, "Playlist is empty, nothing to deduplicate");
541        }
542
543        // Find unique tracks (first occurrence) and duplicates
544        let mut seen_ids: HashSet<String> = HashSet::new();
545        let mut unique_uris: Vec<String> = Vec::new();
546        let mut duplicate_names: Vec<String> = Vec::new();
547
548        for item in all_tracks.iter() {
549            if let Some(track) = item.get("track") {
550                let id = track.get("id").and_then(|i| i.as_str()).unwrap_or("");
551                let uri = track.get("uri").and_then(|u| u.as_str()).unwrap_or("");
552                let name = track
553                    .get("name")
554                    .and_then(|n| n.as_str())
555                    .unwrap_or("Unknown");
556
557                if !id.is_empty() && !uri.is_empty() {
558                    if seen_ids.contains(id) {
559                        duplicate_names.push(name.to_string());
560                    } else {
561                        seen_ids.insert(id.to_string());
562                        unique_uris.push(uri.to_string());
563                    }
564                }
565            }
566        }
567
568        if duplicate_names.is_empty() {
569            return Response::success(200, "No duplicates found");
570        }
571
572        let duplicate_count = duplicate_names.len();
573
574        if dry_run {
575            return Response::success_with_payload(
576                200,
577                format!(
578                    "[DRY RUN] Would remove {} duplicate(s) from playlist",
579                    duplicate_count
580                ),
581                serde_json::json!({
582                    "dry_run": true,
583                    "action": "deduplicate",
584                    "playlist_id": playlist_id,
585                    "duplicate_count": duplicate_count,
586                    "duplicates": duplicate_names,
587                    "unique_count": unique_uris.len()
588                }),
589            );
590        }
591
592        // Strategy: Clear playlist and re-add only unique tracks
593        // This works around Spotify API's position-based removal issues
594
595        // Step 1: Get all current URIs to remove
596        let all_uris: Vec<String> = all_tracks
597            .iter()
598            .filter_map(|item| {
599                item.get("track")
600                    .and_then(|t| t.get("uri"))
601                    .and_then(|u| u.as_str())
602                    .map(String::from)
603            })
604            .collect();
605
606        // Step 2: Remove all tracks
607        if let Err(e) =
608            remove_items_from_playlist::remove_items_from_playlist(&client, &playlist_id, &all_uris)
609                .await
610        {
611            return Response::from_http_error(&e, "Failed to clear playlist");
612        }
613
614        // Step 3: Re-add only unique tracks
615        if !unique_uris.is_empty()
616            && let Err(e) = add_items_to_playlist::add_items_to_playlist(
617                &client,
618                &playlist_id,
619                &unique_uris,
620                None,
621            )
622            .await
623        {
624            return Response::from_http_error(&e, "Failed to restore unique tracks");
625        }
626
627        Response::success_with_payload(
628            200,
629            format!(
630                "Removed {} duplicate(s), {} unique track(s) remain",
631                duplicate_count,
632                unique_uris.len()
633            ),
634            serde_json::json!({
635                "removed_count": duplicate_count,
636                "removed": duplicate_names,
637                "remaining_count": unique_uris.len()
638            }),
639        )
640    })
641    .await
642}