sunox 0.0.3

Generate AI music from your terminal via direct Suno web workflows
use serde::{Deserialize, Serialize};

use super::clip::Clip;

#[derive(Clone, Copy, Debug)]
pub enum PersonaListScope {
    Mine,
    Loved,
    Followed,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct PersonaListResponse {
    #[serde(default)]
    pub personas: Vec<PersonaInfo>,
    #[serde(default, alias = "totalResults")]
    pub total_results: u64,
    #[serde(default, alias = "currentPage")]
    pub current_page: u32,
    #[serde(default, alias = "continuationToken")]
    pub continuation_token: Option<String>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct PersonaClipsResponse {
    pub persona: PersonaInfo,
    #[serde(default)]
    pub total_results: u64,
    #[serde(default)]
    pub current_page: u32,
    #[serde(default)]
    pub is_following: bool,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct PersonaClipEntry {
    pub clip: Clip,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct TogglePersonaLoveResponse {
    pub loved: bool,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct TrashPersonasRequest {
    pub persona_ids: Vec<String>,
    pub undo: bool,
    pub hide: bool,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct TrashPersonasResponse {
    #[serde(default)]
    pub updated_persona_ids: Vec<String>,
    #[serde(default)]
    pub voice_persona_count: u64,
    #[serde(default)]
    pub max_voice_personas: u64,
}

#[derive(Debug, Serialize)]
pub struct CreatePersonaRequest {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub root_clip_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub image_s3_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_public: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_suno_persona: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub persona_type: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub vox_audio_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub vocal_start_s: Option<f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub vocal_end_s: Option<f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub user_input_styles: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub singer_skill_level: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub clips: Option<Vec<serde_json::Value>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_voice_recording: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub voice_recording_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub verification_id: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct EditPersonaRequest {
    pub persona_id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_public: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub persona_type: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub user_input_styles: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub vox_audio_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub vocal_start_s: Option<f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub vocal_end_s: Option<f64>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct ProcessedClipInfo {
    pub id: String,
    pub status: String,
    #[serde(default)]
    pub vocal_start_s: Option<f64>,
    #[serde(default)]
    pub vocal_end_s: Option<f64>,
    #[serde(default)]
    pub vocal_audio_url: Option<String>,
}

#[cfg(test)]
mod tests {
    use super::{
        CreatePersonaRequest, EditPersonaRequest, PersonaInfo, TogglePersonaLoveResponse,
        TrashPersonasRequest,
    };

    #[test]
    fn toggle_love_response_reads_loved_state() {
        let response: TogglePersonaLoveResponse =
            serde_json::from_value(serde_json::json!({ "loved": true }))
                .expect("deserialize response");

        assert!(response.loved);
    }

    #[test]
    fn persona_detail_reads_direct_current_web_shape() {
        let persona: PersonaInfo = serde_json::from_value(serde_json::json!({
            "id": "persona-1",
            "name": "Lead Voice",
            "is_loved": true,
            "is_public": true,
            "is_trashed": false,
            "is_hidden": false,
            "clip_count": 4,
            "follower_count": 2,
            "is_following": true,
            "source": "generated_clip",
            "user_input_styles": "warm soul",
            "vocal_start_s": 0.43,
            "vocal_end_s": 22.56,
            "vocal_clip_id": "processed-1"
        }))
        .expect("deserialize persona");

        assert_eq!(persona.id, "persona-1");
        assert_eq!(persona.name, "Lead Voice");
        assert!(persona.is_loved);
        assert_eq!(persona.is_public, Some(true));
        assert_eq!(persona.clip_count, Some(4));
        assert_eq!(persona.follower_count, Some(2));
        assert!(persona.is_following);
        assert_eq!(persona.source.as_deref(), Some("generated_clip"));
        assert_eq!(persona.user_input_styles.as_deref(), Some("warm soul"));
        assert_eq!(persona.vocal_start_s, Some(0.43));
        assert_eq!(persona.vocal_end_s, Some(22.56));
        assert_eq!(persona.vocal_clip_id.as_deref(), Some("processed-1"));
    }

    #[test]
    fn create_persona_request_omits_absent_fields() {
        let req = CreatePersonaRequest {
            root_clip_id: Some("clip-a".into()),
            name: Some("Lead Voice".into()),
            description: None,
            image_s3_id: None,
            is_public: Some(false),
            is_suno_persona: None,
            persona_type: None,
            vox_audio_id: None,
            vocal_start_s: None,
            vocal_end_s: None,
            user_input_styles: None,
            source: None,
            singer_skill_level: None,
            clips: None,
            is_voice_recording: None,
            voice_recording_id: None,
            verification_id: None,
        };

        let json = serde_json::to_value(req).expect("serialize request");

        assert_eq!(
            json,
            serde_json::json!({
                "root_clip_id": "clip-a",
                "name": "Lead Voice",
                "is_public": false
            })
        );
    }

    #[test]
    fn trash_personas_request_matches_current_web_shape() {
        let req = TrashPersonasRequest {
            persona_ids: vec!["persona-1".into()],
            undo: false,
            hide: false,
        };

        let json = serde_json::to_value(req).expect("serialize request");

        assert_eq!(
            json,
            serde_json::json!({
                "persona_ids": ["persona-1"],
                "undo": false,
                "hide": false
            })
        );
    }

    #[test]
    fn edit_persona_request_omits_absent_fields() {
        let req = EditPersonaRequest {
            persona_id: "persona-1".into(),
            name: Some("Lead Voice".into()),
            description: None,
            is_public: Some(false),
            persona_type: Some("vox".into()),
            user_input_styles: None,
            vox_audio_id: Some("processed-1".into()),
            vocal_start_s: Some(0.43),
            vocal_end_s: None,
        };

        let json = serde_json::to_value(req).expect("serialize request");

        assert_eq!(
            json,
            serde_json::json!({
                "persona_id": "persona-1",
                "name": "Lead Voice",
                "is_public": false,
                "persona_type": "vox",
                "vox_audio_id": "processed-1",
                "vocal_start_s": 0.43
            })
        );
    }
}

#[derive(Debug, Deserialize, Serialize)]
pub struct PersonaInfo {
    pub id: String,
    pub name: String,
    #[serde(default)]
    pub description: Option<String>,
    #[serde(default)]
    pub image_s3_id: Option<String>,
    #[serde(default)]
    pub user_display_name: Option<String>,
    #[serde(default)]
    pub user_handle: Option<String>,
    #[serde(default)]
    pub user_image_url: Option<String>,
    #[serde(default)]
    pub persona_type: Option<String>,
    #[serde(default)]
    pub root_clip_id: Option<String>,
    #[serde(default)]
    pub is_loved: bool,
    #[serde(default)]
    pub is_owned: bool,
    #[serde(default)]
    pub is_public: Option<bool>,
    #[serde(default)]
    pub is_trashed: bool,
    #[serde(default)]
    pub is_hidden: bool,
    #[serde(default)]
    pub clip_count: Option<u64>,
    #[serde(default)]
    pub follower_count: Option<u64>,
    #[serde(default)]
    pub is_following: bool,
    #[serde(default)]
    pub source: Option<String>,
    #[serde(default)]
    pub user_input_styles: Option<String>,
    #[serde(default)]
    pub vocal_start_s: Option<f64>,
    #[serde(default)]
    pub vocal_end_s: Option<f64>,
    #[serde(default)]
    pub vocal_clip_id: Option<String>,
    #[serde(default)]
    pub clip: Option<Clip>,
    #[serde(default)]
    pub persona_clips: Vec<PersonaClipEntry>,
}