sunox 0.0.2

Generate AI music from your terminal via direct Suno web workflows
use serde_json::Value;

use super::SunoClient;
use super::types::{
    CreatePlaylistRequest, PlaylistInfo, PlaylistListResponse, PlaylistReaction,
    PlaylistReorderRequest, PlaylistTracksRequest, SetPlaylistMetadataRequest,
    SetPlaylistReactionRequest, SetPlaylistVisibilityRequest, TrashPlaylistRequest,
};
use crate::core::CliError;

impl SunoClient {
    /// List the authenticated user's playlists.
    /// GET /api/playlist/me?page={page}
    pub async fn list_playlists(&self, page: u32) -> Result<PlaylistListResponse, CliError> {
        self.with_auth_retry(|| async {
            let resp = self
                .get("/api/playlist/me")
                .query(&[("page", page)])
                .send()
                .await?;
            let resp = self.check_response(resp).await?;
            Ok(resp.json().await?)
        })
        .await
    }

    /// Fetch playlist details.
    /// GET /api/playlist/v2/{playlist_id}
    pub async fn get_playlist(&self, playlist_id: &str) -> Result<PlaylistInfo, CliError> {
        self.with_auth_retry(|| async {
            let resp = self
                .get(&format!("/api/playlist/v2/{playlist_id}"))
                .send()
                .await?;
            let resp = self.check_response(resp).await?;
            decode_playlist(resp.json().await?)
        })
        .await
    }

    /// Create a playlist. Suno Web's create route only sends the name; when a
    /// description is supplied we follow with the metadata route.
    pub async fn create_playlist(
        &self,
        name: &str,
        description: Option<&str>,
        image_url: Option<&str>,
    ) -> Result<PlaylistInfo, CliError> {
        let mut playlist = self
            .with_auth_retry(|| async {
                let resp = self
                    .post("/api/playlist/create/")
                    .json(&CreatePlaylistRequest {
                        name: name.to_string(),
                    })
                    .send()
                    .await?;
                let resp = self.check_response(resp).await?;
                decode_playlist(resp.json().await?)
            })
            .await?;

        if description.is_some() || image_url.is_some() {
            playlist = self
                .set_playlist_metadata(&playlist.id, None, description, image_url)
                .await?;
        }

        Ok(playlist)
    }

    /// Update playlist metadata.
    /// POST /api/playlist/set_metadata
    pub async fn set_playlist_metadata(
        &self,
        playlist_id: &str,
        name: Option<&str>,
        description: Option<&str>,
        image_url: Option<&str>,
    ) -> Result<PlaylistInfo, CliError> {
        let req = SetPlaylistMetadataRequest {
            playlist_id: playlist_id.to_string(),
            name: name.map(str::to_string),
            description: description.map(str::to_string),
            image_url: image_url.map(str::to_string),
        };

        self.with_auth_retry(|| async {
            let resp = self
                .post("/api/playlist/set_metadata")
                .json(&req)
                .send()
                .await?;
            let resp = self.check_response(resp).await?;
            let text = resp.text().await.unwrap_or_default();
            if !text.trim().is_empty() {
                let body: Value = serde_json::from_str(&text)?;
                reject_playlist_moderation_error(&body)?;
            }
            Ok(())
        })
        .await?;

        self.get_playlist(playlist_id).await
    }

    /// Set or clear playlist like/dislike reaction.
    /// POST /api/playlist_reaction/{playlist_id}/update_reaction_type/
    pub async fn set_playlist_reaction(
        &self,
        playlist_id: &str,
        reaction: Option<PlaylistReaction>,
    ) -> Result<(), CliError> {
        self.with_auth_retry(|| async {
            let resp = self
                .post(&format!(
                    "/api/playlist_reaction/{playlist_id}/update_reaction_type/"
                ))
                .json(&SetPlaylistReactionRequest::new(reaction))
                .send()
                .await?;
            self.check_response(resp).await?;
            Ok(())
        })
        .await
    }

    /// Add clips to a playlist.
    /// POST /api/playlist/v2/{playlist_id}/tracks/add
    pub async fn add_clips_to_playlist(
        &self,
        playlist_id: &str,
        clip_ids: &[String],
    ) -> Result<(), CliError> {
        self.update_playlist_tracks(playlist_id, "add", clip_ids)
            .await
    }

    /// Remove clips from a playlist.
    /// POST /api/playlist/v2/{playlist_id}/tracks/remove
    pub async fn remove_clips_from_playlist(
        &self,
        playlist_id: &str,
        clip_ids: &[String],
    ) -> Result<(), CliError> {
        self.update_playlist_tracks(playlist_id, "remove", clip_ids)
            .await
    }

    /// Set playlist visibility.
    /// PATCH /api/playlist/v2/{playlist_id}
    pub async fn set_playlist_visibility(
        &self,
        playlist_id: &str,
        is_public: bool,
    ) -> Result<(), CliError> {
        self.with_auth_retry(|| async {
            let resp = self
                .patch(&format!("/api/playlist/v2/{playlist_id}"))
                .json(&SetPlaylistVisibilityRequest::new(is_public))
                .send()
                .await?;
            self.check_response(resp).await?;
            Ok(())
        })
        .await
    }

    /// Save a playlist to the user's library.
    /// POST /api/playlist/v2/{playlist_id}/save
    pub async fn save_playlist(&self, playlist_id: &str) -> Result<(), CliError> {
        self.with_auth_retry(|| async {
            let resp = self
                .post(&format!("/api/playlist/v2/{playlist_id}/save"))
                .send()
                .await?;
            self.check_response(resp).await?;
            Ok(())
        })
        .await
    }

    /// Remove a saved playlist from the user's library.
    /// DELETE /api/playlist/v2/{playlist_id}/save
    pub async fn unsave_playlist(&self, playlist_id: &str) -> Result<(), CliError> {
        self.with_auth_retry(|| async {
            let resp = self
                .delete(&format!("/api/playlist/v2/{playlist_id}/save"))
                .send()
                .await?;
            self.check_response(resp).await?;
            Ok(())
        })
        .await
    }

    /// Move a playlist clip to a zero-based index.
    /// POST /api/playlist/v2/{playlist_id}/tracks/reorder-by-index
    pub async fn reorder_playlist_clip(
        &self,
        playlist_id: &str,
        clip_id: &str,
        index: u32,
    ) -> Result<(), CliError> {
        self.with_auth_retry(|| async {
            let resp = self
                .post(&format!(
                    "/api/playlist/v2/{playlist_id}/tracks/reorder-by-index"
                ))
                .json(&PlaylistReorderRequest::single(clip_id, index))
                .send()
                .await?;
            self.check_response(resp).await?;
            Ok(())
        })
        .await
    }

    async fn update_playlist_tracks(
        &self,
        playlist_id: &str,
        action: &str,
        clip_ids: &[String],
    ) -> Result<(), CliError> {
        self.with_auth_retry(|| async {
            let resp = self
                .post(&format!("/api/playlist/v2/{playlist_id}/tracks/{action}"))
                .json(&PlaylistTracksRequest {
                    clip_ids: clip_ids.to_vec(),
                })
                .send()
                .await?;
            self.check_response(resp).await?;
            Ok(())
        })
        .await
    }

    /// Trash a playlist. The route supports undo, but the CLI exposes delete.
    /// POST /api/playlist/v2/{playlist_id}/trash
    pub async fn trash_playlist(&self, playlist_id: &str) -> Result<(), CliError> {
        self.set_playlist_trash_state(playlist_id, false).await
    }

    /// Restore a trashed playlist.
    /// POST /api/playlist/v2/{playlist_id}/trash
    pub async fn restore_playlist(&self, playlist_id: &str) -> Result<(), CliError> {
        self.set_playlist_trash_state(playlist_id, true).await
    }

    async fn set_playlist_trash_state(
        &self,
        playlist_id: &str,
        undo: bool,
    ) -> Result<(), CliError> {
        self.with_auth_retry(|| async {
            let resp = self
                .post(&format!("/api/playlist/v2/{playlist_id}/trash"))
                .json(&TrashPlaylistRequest { undo })
                .send()
                .await?;
            self.check_response(resp).await?;
            Ok(())
        })
        .await
    }
}

fn reject_playlist_moderation_error(body: &Value) -> Result<(), CliError> {
    if let Some(message) = body
        .get("moderation_error_message")
        .and_then(serde_json::Value::as_str)
    {
        return Err(CliError::Api {
            code: "moderation_error",
            message: message.to_string(),
        });
    }
    Ok(())
}

fn decode_playlist(body: Value) -> Result<PlaylistInfo, CliError> {
    let candidates = [
        body.get("playlist").cloned(),
        body.get("data").cloned(),
        Some(body.clone()),
    ];

    for candidate in candidates.into_iter().flatten() {
        if let Ok(playlist) = serde_json::from_value::<PlaylistInfo>(candidate) {
            return Ok(playlist);
        }
    }

    Err(CliError::Api {
        code: "schema_drift",
        message: format!("playlist response did not match known Suno schema: {body}"),
    })
}