uta 0.1.2

Command-line music search and downloader for QQ Music and NetEase Cloud Music, lossless first, shipped as a single static binary. For learning and research only; non-commercial use.
//! 配置文件:`~/.config/uta/config.toml` 或 `--config <path>`。
//!
//! 第三方接口地址与 apiKey 只从这里读取,源码中不内置。

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde::Deserialize;
use tracing::{debug, warn};

use crate::model::{Level, SourceKind};

#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
    pub vkeys: VkeysConfig,
    pub tang: TangConfig,
    pub netease: NeteaseConfig,
    pub defaults: Defaults,
    /// 已移除的 xcvts 接口(已下架):旧配置里的这一段只提示、不报错。
    #[serde(rename = "xcvts")]
    legacy_xcvts: Option<toml::Value>,
}

/// 命令行未指定时使用的默认值。
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Defaults {
    /// 默认音源:`"qq"`、`"163"`,或列表 `["qq", "163"]`(多个只用于 search);未指定为 qq
    #[serde(deserialize_with = "one_or_many")]
    pub source: Option<Vec<SourceKind>>,
    /// 输出目录,支持 `~/` 开头
    pub output: Option<String>,
    pub max_quality: Option<Level>,
    pub jobs: Option<u16>,
    pub download_jobs: Option<u16>,
}

fn one_or_many<'de, D>(d: D) -> std::result::Result<Option<Vec<SourceKind>>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    #[derive(Deserialize)]
    #[serde(untagged)]
    enum OneOrMany {
        One(SourceKind),
        Many(Vec<SourceKind>),
    }
    Ok(Some(match OneOrMany::deserialize(d)? {
        OneOrMany::One(k) => vec![k],
        OneOrMany::Many(v) => v,
    }))
}

/// 把 `~/` 开头的路径展开到家目录。
pub fn expand_home(p: &str) -> PathBuf {
    match p.strip_prefix("~/") {
        Some(rest) => dirs::home_dir().map_or_else(|| PathBuf::from(p), |h| h.join(rest)),
        None if p == "~" => dirs::home_dir().unwrap_or_else(|| PathBuf::from(p)),
        None => PathBuf::from(p),
    }
}

#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct VkeysConfig {
    /// 例:`https://api.vkeys.cn/music/tencent/song/link`;留空则不使用 vkeys。
    pub url: String,
}

/// tang(Python 版 L2 接口,作为 vkeys 失效时的无损备用源)。
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TangConfig {
    /// 例:`https://tang.api.s01s.cn/music_open_api.php`;留空则不使用。
    pub url: String,
}

/// 只有地址的第三方接口。
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct EndpointConfig {
    /// 留空则不使用
    pub url: String,
}

impl EndpointConfig {
    pub fn enabled(&self) -> bool {
        !self.url.trim().is_empty()
    }
}

/// 网易云音乐的第三方接口。
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct NeteaseConfig {
    /// 例:`https://music.tmetu.cn/api/`
    pub tmetu: EndpointConfig,
    /// 例:`https://api.chksz.com/api/163_music`
    pub chksz: EndpointConfig,
    /// 例:`https://dm.jfjt.cc/Song_V1`
    pub jfjt: EndpointConfig,
}

impl NeteaseConfig {
    pub fn any_enabled(&self) -> bool {
        [&self.tmetu, &self.chksz, &self.jfjt]
            .iter()
            .any(|e| e.enabled())
    }
}

impl TangConfig {
    pub fn enabled(&self) -> bool {
        !self.url.trim().is_empty()
    }
}

impl VkeysConfig {
    pub fn enabled(&self) -> bool {
        !self.url.trim().is_empty()
    }
}

pub fn default_path() -> Option<PathBuf> {
    dirs::config_dir().map(|d| d.join("uta").join("config.toml"))
}

impl Config {
    pub fn parse(text: &str) -> Result<Config> {
        let c: Config = toml::from_str(text)?;
        if c.legacy_xcvts.is_some() {
            warn!("配置中的 [xcvts] 已不再支持(接口已下架),可以删除这一段");
        }
        let d = &c.defaults;
        if d.jobs.is_some_and(|j| !(1..=64).contains(&j)) {
            anyhow::bail!("defaults.jobs 须在 1..=64 之间");
        }
        if d.download_jobs.is_some_and(|j| !(1..=16).contains(&j)) {
            anyhow::bail!("defaults.download_jobs 须在 1..=16 之间");
        }
        Ok(c)
    }

    /// 读取配置:显式指定的路径必须存在;默认路径不存在时返回空配置。
    pub fn load(explicit: Option<&Path>) -> Result<Config> {
        let (path, required) = match explicit {
            Some(p) => (p.to_path_buf(), true),
            None => match default_path() {
                Some(p) => (p, false),
                None => return Ok(Config::default()),
            },
        };
        match std::fs::read_to_string(&path) {
            Ok(text) => {
                debug!(path = %path.display(), "读取配置");
                Config::parse(&text)
                    .with_context(|| format!("配置文件格式错误:{}", path.display()))
            }
            Err(e) if !required && e.kind() == std::io::ErrorKind::NotFound => {
                debug!(path = %path.display(), "配置文件不存在,使用空配置");
                Ok(Config::default())
            }
            Err(e) => Err(e).with_context(|| format!("无法读取配置文件:{}", path.display())),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_full() {
        let c = Config::parse(
            r#"
            [vkeys]
            url = "https://example.com/link"
            [tang]
            url = "https://example.com/tang"
            "#,
        )
        .unwrap();
        assert!(c.vkeys.enabled());
        assert!(c.tang.enabled());
    }

    #[test]
    fn netease_and_source() {
        let c = Config::parse(
            "[netease.tmetu]\nurl = \"https://example.com/api/\"\n[defaults]\nsource = \"netease\"",
        )
        .unwrap();
        assert!(c.netease.tmetu.enabled());
        assert_eq!(c.defaults.source, Some(vec![SourceKind::Netease]));
        assert!(Config::parse("[defaults]\nsource = \"kugou\"").is_err());
        let c = Config::parse("[defaults]\nsource = \"163\"").unwrap();
        assert_eq!(c.defaults.source, Some(vec![SourceKind::Netease]));
        let c = Config::parse("[defaults]\nsource = [\"qq\", \"163\"]").unwrap();
        assert_eq!(
            c.defaults.source,
            Some(vec![SourceKind::Qq, SourceKind::Netease])
        );
        assert_eq!(Config::parse("").unwrap().defaults.source, None);
        assert!(Config::parse("[netease]\nfoo = 1").is_err());
    }

    #[test]
    fn legacy_xcvts_section_is_ignored() {
        let c = Config::parse("[xcvts]\nurl = \"https://x\"\napi_key = \"k\"").unwrap();
        assert!(c.legacy_xcvts.is_some());
        assert!(!c.vkeys.enabled() && !c.tang.enabled());
    }

    #[test]
    fn empty_and_partial() {
        let c = Config::parse("").unwrap();
        assert!(!c.vkeys.enabled());
        assert!(!c.tang.enabled());
        assert!(
            Config::parse("[tang]\nurl = \"https://x\"")
                .unwrap()
                .tang
                .enabled()
        );
    }

    #[test]
    fn parse_defaults() {
        let c = Config::parse(
            "[defaults]\noutput = \"~/Music\"\nmax_quality = \"sq\"\njobs = 4\ndownload_jobs = 2",
        )
        .unwrap();
        assert_eq!(c.defaults.max_quality, Some(Level::Sq));
        assert_eq!(
            (c.defaults.jobs, c.defaults.download_jobs),
            (Some(4), Some(2))
        );
        assert!(Config::parse("[defaults]\nmax_quality = \"ultra\"").is_err());
        assert!(Config::parse("[defaults]\njobs = 0").is_err());
        assert!(Config::parse("[defaults]\ndownload_jobs = 17").is_err());
    }

    #[test]
    fn home_expansion() {
        let home = dirs::home_dir().unwrap();
        assert_eq!(expand_home("~/Music"), home.join("Music"));
        assert_eq!(expand_home("~"), home);
        assert_eq!(expand_home("/abs/~x"), PathBuf::from("/abs/~x"));
        assert_eq!(expand_home("rel"), PathBuf::from("rel"));
    }

    #[test]
    fn rejects_unknown_keys() {
        assert!(Config::parse("[vkeys]\nurll = \"x\"").is_err());
    }

    #[test]
    fn example_file_parses() {
        let c = Config::parse(include_str!("../config.example.toml")).unwrap();
        assert!(c.vkeys.enabled() && c.tang.enabled());
        let n = &c.netease;
        assert!(n.tmetu.enabled() && n.chksz.enabled() && n.jfjt.enabled());
        assert!(c.legacy_xcvts.is_none());
    }

    #[test]
    fn missing_explicit_path_errors() {
        assert!(Config::load(Some(Path::new("/nonexistent/uta.toml"))).is_err());
    }
}