lux-native 0.1.1

A terminal-native music CLI replacing lx-music-desktop, powered by Agentic intelligence.
Documentation
use async_trait::async_trait;
use lux_core::error::SourceError;
use lux_core::traits::{LyricInfo, MusicSource};
use lux_core::types::{MusicInfo, Quality, SearchResult, Source};

#[derive(Default)]
pub struct NetEaseSource;

#[async_trait]
impl MusicSource for NetEaseSource {
    fn platform(&self) -> Source {
        Source::NetEase
    }

    fn supported_qualities(&self) -> &[Quality] {
        &[Quality::Q128k, Quality::Q320k, Quality::Flac]
    }

    async fn search(
        &self,
        keyword: &str,
        page: usize,
        limit: usize,
    ) -> Result<SearchResult, SourceError> {
        let offset = (page - 1) * limit;
        let url = "http://music.163.com/api/search/get/web";

        let client = reqwest::Client::new();
        let resp = client
            .get(url)
            .query(&[
                ("s", keyword),
                ("type", "1"),
                ("offset", &offset.to_string()),
                ("limit", &limit.to_string()),
            ])
            .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
            .send()
            .await
            .map_err(|e| SourceError::HttpError(e.to_string()))?;

        let body: serde_json::Value = resp
            .json()
            .await
            .map_err(|e| SourceError::HttpError(e.to_string()))?;

        let mut list = Vec::new();
        let songs = body["result"]["songs"].as_array();
        let total = body["result"]["songCount"].as_u64().unwrap_or(0) as usize;

        if let Some(songs) = songs {
            for song in songs {
                let songmid = song["id"].as_i64().unwrap_or(0).to_string();
                let name = song["name"].as_str().unwrap_or("Unknown").to_string();
                let singer = if let Some(artists) = song["artists"].as_array() {
                    artists
                        .iter()
                        .map(|a| a["name"].as_str().unwrap_or(""))
                        .collect::<Vec<_>>()
                        .join("")
                } else {
                    "Unknown".to_string()
                };
                let album_name = song["album"]["name"].as_str().map(|s| s.to_string());
                let album_id = song["album"]["id"].as_i64().map(|id| id.to_string());
                let duration_ms = song["duration"].as_i64().unwrap_or(0);
                let duration_secs = duration_ms / 1000;
                let interval = format!("{:02}:{:02}", duration_secs / 60, duration_secs % 60);

                list.push(MusicInfo {
                    songmid,
                    name,
                    singer,
                    source: Source::NetEase,
                    album_name,
                    album_id,
                    interval: Some(interval),
                    pic_url: None,
                    hash: None,
                    extra: None,
                });
            }
        }

        Ok(SearchResult {
            page,
            limit,
            total,
            list,
        })
    }

    async fn get_url(&self, _song_id: &str, _quality: Quality) -> Result<String, SourceError> {
        Err(SourceError::PlatformError(
            "NetEase native URL resolution requires JS source bridge".to_string(),
        ))
    }

    async fn get_lyric(&self, song_id: &str) -> Result<Option<LyricInfo>, SourceError> {
        let url = format!("http://music.163.com/api/song/media?id={}", song_id);
        let client = reqwest::Client::new();
        let resp = client
            .get(&url)
            .send()
            .await
            .map_err(|e| SourceError::HttpError(e.to_string()))?;

        let body: serde_json::Value = resp
            .json()
            .await
            .map_err(|e| SourceError::HttpError(e.to_string()))?;

        if let Some(lyric) = body["lyric"].as_str() {
            Ok(Some(LyricInfo {
                lyric: lyric.to_string(),
                tlyric: None,
                rlyric: None,
                lxlyric: None,
            }))
        } else {
            Ok(None)
        }
    }

    async fn get_pic(&self, song_id: &str) -> Result<Option<String>, SourceError> {
        let url = format!(
            "http://music.163.com/api/song/detail/?id={}&ids=[{}]",
            song_id, song_id
        );
        let client = reqwest::Client::new();
        let resp = client
            .get(&url)
            .send()
            .await
            .map_err(|e| SourceError::HttpError(e.to_string()))?;

        let body: serde_json::Value = resp
            .json()
            .await
            .map_err(|e| SourceError::HttpError(e.to_string()))?;

        if let Some(pic_url) = body["songs"]
            .as_array()
            .and_then(|songs| songs.first())
            .and_then(|song| song["album"]["picUrl"].as_str())
        {
            return Ok(Some(pic_url.to_string()));
        }
        Ok(None)
    }
}