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,
#[serde(rename = "xcvts")]
legacy_xcvts: Option<toml::Value>,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Defaults {
#[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 {
pub url: String,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TangConfig {
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 {
pub tmetu: EndpointConfig,
pub chksz: EndpointConfig,
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());
}
}