mod config;
mod doctor;
mod download;
mod lrc;
mod merge;
mod model;
mod net;
mod netease;
mod probe;
mod qq;
mod source;
mod tags;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::Duration;
use anyhow::{Context, Result};
use clap::{Args, Parser, Subcommand};
use comfy_table::{ContentArrangement, Table, presets::UTF8_FULL_CONDENSED};
use futures::{StreamExt, stream};
use inquire::{InquireError, MultiSelect, Select};
use tracing_subscriber::EnvFilter;
use config::{Config, Defaults, expand_home};
use download::{Item, Outcome, download_all, part_path, plan_paths};
use model::{Level, ResolveOptions, Song, SourceKind};
use model::{AlbumHit, AlbumKind, AlbumOrder, ArtistAlbum, SingerHit};
use source::Sources;
const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36";
#[derive(Parser)]
#[command(
name = "uta",
version,
about = "音乐搜索 / 下载,支持 QQ 音乐、网易云音乐(仅供学习研究,禁止商业用途)"
)]
struct Cli {
#[arg(long, global = true, value_name = "PATH")]
config: Option<PathBuf>,
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
verbose: u8,
#[command(subcommand)]
command: Command,
}
#[derive(Args, Clone)]
struct CommonArgs {
#[arg(
short = 's',
long,
value_enum,
value_delimiter = ',',
value_name = "SOURCE"
)]
source: Vec<SourceKind>,
#[arg(long)]
lossless: bool,
#[arg(long)]
no_fallback: bool,
#[arg(short = 'q', long, value_enum)]
max_quality: Option<Level>,
#[arg(short = 'o', long, value_name = "DIR")]
output: Option<PathBuf>,
#[arg(short = 'j', long, value_parser = clap::value_parser!(u16).range(1..=64))]
jobs: Option<u16>,
#[arg(short = 'J', long, value_parser = clap::value_parser!(u16).range(1..=16))]
download_jobs: Option<u16>,
}
#[derive(Debug, Clone)]
struct Settings {
sources: Vec<SourceKind>,
fallback: bool,
opts: ResolveOptions,
output: PathBuf,
jobs: usize,
download_jobs: usize,
}
#[derive(Subcommand)]
enum Command {
Search {
keyword: String,
#[arg(short = 'n', long, default_value_t = 10, value_parser = clap::value_parser!(u16).range(1..=200))]
num: u16,
#[arg(long)]
json: bool,
#[arg(long)]
no_merge: bool,
#[command(flatten)]
common: CommonArgs,
},
Playlist {
playlist: String,
#[arg(short = 'n', long, value_parser = clap::value_parser!(u32).range(1..))]
num: Option<u32>,
#[arg(long)]
json: bool,
#[arg(short = 'y', long)]
yes: bool,
#[command(flatten)]
common: CommonArgs,
},
Album {
#[arg(required_unless_present = "mid", conflicts_with = "mid")]
query: Option<String>,
#[arg(long, value_name = "MID")]
mid: Option<String>,
#[arg(short = 'n', long, value_parser = clap::value_parser!(u32).range(1..))]
num: Option<u32>,
#[arg(long)]
json: bool,
#[arg(short = 'y', long)]
yes: bool,
#[command(flatten)]
common: CommonArgs,
},
Artist {
#[arg(required_unless_present = "mid", conflicts_with = "mid")]
query: Option<String>,
#[arg(long, value_name = "MID")]
mid: Option<String>,
#[arg(
short = 't',
long = "type",
value_enum,
value_delimiter = ',',
value_name = "TYPE"
)]
kinds: Vec<AlbumKind>,
#[arg(long, value_enum, default_value_t = AlbumOrder::New)]
order: AlbumOrder,
#[arg(short = 'n', long, value_parser = clap::value_parser!(u32).range(1..))]
num: Option<u32>,
#[arg(long)]
json: bool,
#[arg(short = 'y', long)]
yes: bool,
#[command(flatten)]
common: CommonArgs,
},
Doctor {
#[arg(
short = 's',
long,
value_enum,
value_delimiter = ',',
value_name = "SOURCE"
)]
source: Vec<SourceKind>,
},
Completions {
#[arg(value_enum)]
shell: clap_complete::Shell,
},
Config {
#[command(subcommand)]
action: ConfigAction,
},
Get {
#[arg(required = true, value_name = "SONG")]
mids: Vec<String>,
#[command(flatten)]
common: CommonArgs,
},
}
#[derive(Subcommand)]
enum ConfigAction {
Path,
Init {
#[arg(long)]
force: bool,
},
}
fn client_builder() -> reqwest::ClientBuilder {
reqwest::Client::builder()
.user_agent(USER_AGENT)
.connect_timeout(Duration::from_secs(5))
}
fn build_api_client() -> Result<reqwest::Client> {
client_builder()
.timeout(Duration::from_secs(15))
.build()
.context("初始化 HTTP 客户端失败")
}
fn build_download_client() -> Result<reqwest::Client> {
client_builder()
.read_timeout(Duration::from_secs(15))
.build()
.context("初始化下载客户端失败")
}
fn init_logging(verbose: u8) {
let default = match verbose {
0 => "warn,lofty=error",
1 => "uta=debug",
_ => "uta=trace,reqwest=debug",
};
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.with_target(false)
.init();
}
fn fmt_duration(secs: u64) -> String {
format!("{}:{:02}", secs / 60, secs % 60)
}
fn fmt_size(bytes: u64) -> String {
if bytes == 0 {
"未知".into()
} else {
format!("{:.2} MB", bytes as f64 / 1024.0 / 1024.0)
}
}
fn print_table(items: &[Item]) {
let mut table = Table::new();
table
.load_style(UTF8_FULL_CONDENSED)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header([
"#", "歌名", "歌手", "专辑", "时长", "格式", "大小", "品质", "来源",
]);
for (i, it) in items.iter().enumerate() {
table.add_row([
(i + 1).to_string(),
it.song.title.clone(),
it.song.singer_text(),
it.song.album.clone(),
fmt_duration(it.song.interval),
it.resolved.probe.ext.clone(),
fmt_size(it.resolved.probe.size),
it.resolved.quality.clone(),
source_text(it),
]);
}
println!("{table}");
}
impl CommonArgs {
fn settings(&self, d: &Defaults) -> Result<Settings> {
let max_level = self.max_quality.or(d.max_quality).unwrap_or(Level::Master);
if self.lossless && max_level < Level::Sq {
anyhow::bail!(
"--lossless 与最高音质 {max_level} 冲突:无损至少需要 sq(可用 -q sq 覆盖配置)"
);
}
let output = match (&self.output, &d.output) {
(Some(p), _) => p.clone(),
(None, Some(p)) => expand_home(p),
(None, None) => PathBuf::from("."),
};
Ok(Settings {
sources: {
let list = if self.source.is_empty() {
d.source.clone().unwrap_or_default()
} else {
self.source.clone()
};
let mut seen = std::collections::HashSet::new();
let list: Vec<SourceKind> = list.into_iter().filter(|k| seen.insert(*k)).collect();
if list.is_empty() {
vec![SourceKind::default()]
} else {
list
}
},
fallback: !self.no_fallback,
opts: ResolveOptions {
lossless_only: self.lossless,
max_level,
},
output,
jobs: self.jobs.or(d.jobs).unwrap_or(8) as usize,
download_jobs: self.download_jobs.or(d.download_jobs).unwrap_or(4) as usize,
})
}
}
impl Settings {
fn source(&self) -> SourceKind {
self.sources[0]
}
fn single(self, command: &str) -> Result<Settings> {
if self.sources.len() > 1 {
anyhow::bail!("{command} 只支持一个音源,-s 只能给一个值(多音源仅用于 search)");
}
Ok(self)
}
}
async fn resolve_all(
sources: &Sources,
songs: Vec<Song>,
opts: ResolveOptions,
jobs: usize,
fallback: bool,
) -> Vec<Item> {
let found = songs.len();
eprintln!("正在解析 {found} 首的直链(并发 {jobs})…");
let mut resolved: Vec<(usize, Item)> = stream::iter(songs.into_iter().enumerate())
.map(|(i, song)| async move {
let r = match sources.resolve(&song, opts).await {
None if fallback => sources.resolve_fallback(&song, opts).await,
r => r,
};
r.map(|r| (i, Item::new(song, r)))
})
.buffer_unordered(jobs)
.filter_map(|x| async move { x })
.collect()
.await;
resolved.sort_by_key(|(i, _)| *i);
let filled = resolved
.iter()
.filter(|(_, it)| it.resolved.fallback.is_some())
.count();
if filled > 0 {
eprintln!(
"{filled} 首在本音源拿不到,已从其他音源补上链接(来源列标「补」;--no-fallback 可关闭)"
);
}
let dropped = found - resolved.len();
if dropped > 0 {
let why = if opts.lossless_only {
"拿不到无损"
} else {
"无可用直链"
};
eprintln!("{dropped} 首{why},已忽略");
}
resolved.into_iter().map(|(_, it)| it).collect()
}
fn source_text(it: &Item) -> String {
match it.resolved.fallback {
Some(k) => format!("{}·{}(补)", k.label(), it.resolved.via),
None => format!("{}·{}", it.song.source.label(), it.resolved.via),
}
}
fn select_label(i: usize, it: &Item) -> String {
format!(
"{:>2}. {} - {} 《{}》 [{} {} {}]",
i + 1,
it.song.title,
it.song.singer_text(),
it.song.album,
it.resolved.probe.ext,
fmt_size(it.resolved.probe.size),
it.resolved.quality,
)
}
fn choose(items: &[Item], all_selected: bool) -> Result<Vec<usize>> {
let labels: Vec<String> = items
.iter()
.enumerate()
.map(|(i, it)| select_label(i, it))
.collect();
let mut prompt = MultiSelect::new("选择要下载的歌曲:", labels)
.with_page_size(15)
.with_help_message("↑↓ 移动,空格 选择,→ 全选,← 全不选,输入可过滤,回车 确认,Esc 取消");
if all_selected {
prompt = prompt.with_all_selected_by_default();
}
let picked = prompt.raw_prompt();
match picked {
Ok(opts) => Ok(opts.into_iter().map(|o| o.index).collect()),
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(vec![]),
Err(e) => Err(e).context("交互选择失败"),
}
}
async fn download_and_report(
sources: &Sources,
items: &[Item],
settings: &Settings,
) -> Result<bool> {
let client = build_download_client()?;
let download = download_all(
&client,
sources,
items,
&settings.output,
settings.download_jobs,
);
let outcomes = tokio::select! {
r = download => r?,
_ = tokio::signal::ctrl_c() => {
let kept = plan_paths(items, &settings.output)
.iter()
.filter(|p| part_path(p).exists())
.count();
if kept > 0 {
eprintln!("\n已取消。{kept} 个未完成的文件已保留(.part),重新运行同一命令会自动续传");
} else {
eprintln!("\n已取消");
}
std::process::exit(130);
}
};
let (mut ok, mut skipped, mut failed) = (0, 0, 0);
for o in &outcomes {
match o {
Outcome::Downloaded { .. } => ok += 1,
Outcome::Skipped => skipped += 1,
Outcome::Failed(_) => failed += 1,
}
}
eprintln!(
"完成:成功 {ok},跳过 {skipped},失败 {failed}。保存位置:{}",
display_dir(&settings.output)
);
Ok(failed == 0)
}
fn display_dir(p: &Path) -> String {
let full = std::fs::canonicalize(p)
.unwrap_or_else(|_| p.to_path_buf())
.display()
.to_string();
strip_verbatim(&full).to_string()
}
fn strip_verbatim(s: &str) -> &str {
s.strip_prefix(r"\\?\UNC\")
.map_or_else(|| s.strip_prefix(r"\\?\").unwrap_or(s), |_| s)
}
async fn cmd_search(
sources: &Sources,
keyword: &str,
num: usize,
json: bool,
no_merge: bool,
settings: &Settings,
) -> Result<bool> {
let opts = settings.opts;
let kinds = &settings.sources;
let results =
futures::future::join_all(kinds.iter().map(|&k| sources.search(k, keyword, num))).await;
let mut lists = Vec::new();
let mut counts = Vec::new();
let mut last_err = None;
for (&k, r) in kinds.iter().zip(results) {
match r {
Ok(v) => {
counts.push(format!("{} {}", k.label(), v.len()));
lists.push(v);
}
Err(e) if kinds.len() > 1 => {
eprintln!("✘ {}搜索失败:{e:#}", k.label());
last_err = Some(e);
}
Err(e) => return Err(e),
}
}
let songs = merge::interleave(lists);
if songs.is_empty() {
if let Some(e) = last_err {
return Err(e);
}
anyhow::bail!("没有搜索到“{keyword}”相关的歌曲");
}
if kinds.len() > 1 {
eprintln!("搜索到 {} 首({})", songs.len(), counts.join(","));
} else {
eprintln!("搜索到 {} 首", songs.len());
}
let fallback = settings.fallback && kinds.len() == 1;
let mut items = resolve_all(sources, songs, opts, settings.jobs, fallback).await;
if kinds.len() > 1 && !no_merge {
let (kept, merged) = merge::dedupe_across_sources(items);
items = kept;
if merged > 0 {
eprintln!("{merged} 首在多个音源都有,已保留音质更好的一份(--no-merge 可全部列出)");
}
}
if json {
println!("{}", serde_json::to_string_pretty(&items)?);
return Ok(true);
}
if items.is_empty() {
eprintln!(
"没有可用的结果{}",
if opts.lossless_only {
"(--lossless)"
} else {
""
}
);
return Ok(true);
}
print_table(&items);
if !interactive() {
eprintln!("非交互环境,无法多选。可用 --json 输出结果,或用 `uta get <mid>...` 下载。");
return Ok(true);
}
let picked = choose(&items, false)?;
if picked.is_empty() {
eprintln!("未选择任何歌曲");
return Ok(true);
}
let selected: Vec<Item> = picked.into_iter().map(|i| items[i].clone()).collect();
download_and_report(sources, &selected, settings).await
}
fn interactive() -> bool {
std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
}
async fn download_collection(
sources: &Sources,
mut songs: Vec<Song>,
subdir: &str,
num: Option<u32>,
json: bool,
yes: bool,
settings: &Settings,
) -> Result<bool> {
if let Some(n) = num {
songs.truncate(n as usize);
}
let items = resolve_all(
sources,
songs,
settings.opts,
settings.jobs,
settings.fallback,
)
.await;
if json {
println!("{}", serde_json::to_string_pretty(&items)?);
return Ok(true);
}
if items.is_empty() {
eprintln!("没有可用的结果");
return Ok(false);
}
print_table(&items);
let selected: Vec<Item> = if yes || !interactive() {
items
} else {
let picked = choose(&items, true)?;
if picked.is_empty() {
eprintln!("未选择任何歌曲");
return Ok(true);
}
picked.into_iter().map(|i| items[i].clone()).collect()
};
let settings = Settings {
output: settings.output.join(download::sanitize_filename(subdir)),
..settings.clone()
};
download_and_report(sources, &selected, &settings).await
}
async fn cmd_playlist(
sources: &Sources,
input: &str,
num: Option<u32>,
json: bool,
yes: bool,
settings: &Settings,
) -> Result<bool> {
let kind = source::detect(input).unwrap_or(settings.source());
let (id, name, songs) = sources.playlist(kind, input).await?;
let total = songs.len();
eprintln!("{}歌单《{name}》({id})共 {total} 首", kind.label());
if total == 0 {
anyhow::bail!("歌单为空");
}
download_collection(sources, songs, &name, num, json, yes, settings).await
}
fn print_album_table(hits: &[AlbumHit]) {
let mut table = Table::new();
table
.load_style(UTF8_FULL_CONDENSED)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(["#", "专辑", "歌手", "曲目数", "发行日期", "id"]);
for (i, h) in hits.iter().enumerate() {
table.add_row([
(i + 1).to_string(),
h.name.clone(),
h.singer.clone(),
h.song_num.to_string(),
h.publish_date.clone(),
h.id.clone(),
]);
}
println!("{table}");
}
fn choose_album(hits: &[AlbumHit]) -> Result<Option<usize>> {
let labels: Vec<String> = hits
.iter()
.enumerate()
.map(|(i, h)| {
format!(
"{:>2}. {} - {}({} 首,{})",
i + 1,
h.name,
h.singer,
h.song_num,
h.publish_date
)
})
.collect();
match Select::new("选择专辑:", labels)
.with_page_size(15)
.with_help_message("↑↓ 移动,输入可过滤,回车 确认,Esc 取消")
.raw_prompt()
{
Ok(o) => Ok(Some(o.index)),
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(None),
Err(e) => Err(e).context("交互选择失败"),
}
}
async fn cmd_album(
sources: &Sources,
query: Option<&str>,
mid: Option<&str>,
num: Option<u32>,
json: bool,
yes: bool,
settings: &Settings,
) -> Result<bool> {
let kind = query.and_then(source::detect).unwrap_or(settings.source());
let key = match (mid, query) {
(Some(m), _) => source::parse_album(kind, m).with_context(|| match kind {
SourceKind::Qq => format!("--mid 需要 14 位 albummid 或数字 albumID,收到:{m}"),
SourceKind::Netease => format!("--mid 需要网易云数字专辑 id,收到:{m}"),
})?,
(None, Some(q)) => match source::parse_album(kind, q).filter(|_| q.contains("://")) {
Some(k) => k,
None => {
let hits = sources.search_albums(kind, q, 10).await?;
if hits.is_empty() {
anyhow::bail!("没有搜索到“{q}”相关的专辑");
}
if json {
println!("{}", serde_json::to_string_pretty(&hits)?);
return Ok(true);
}
print_album_table(&hits);
let idx = if yes {
eprintln!("-y:选择第 1 个结果《{}》", hits[0].name);
0
} else if !interactive() {
eprintln!(
"非交互环境,无法选择专辑。可用 `uta album{} --mid <id>`、专辑链接,或加 -y 选第 1 个结果。",
source_flag(kind)
);
return Ok(true);
} else {
match choose_album(&hits)? {
Some(i) => i,
None => {
eprintln!("未选择专辑");
return Ok(true);
}
}
};
source::AlbumKey::from_id(kind, &hits[idx].id)
}
},
(None, None) => unreachable!("clap 保证至少有一个"),
};
let album = sources.album(&key).await?;
print_album_header(&album);
let subdir = album.dir_name();
download_collection(sources, album.songs, &subdir, num, json, yes, settings).await
}
fn source_flag(kind: SourceKind) -> String {
match kind {
SourceKind::Qq => String::new(),
k => format!(" -s {k}"),
}
}
fn print_album_header(album: &model::Album) {
let date = if album.publish_date.is_empty() {
String::new()
} else {
format!(",{}", album.publish_date)
};
eprintln!(
"专辑《{}》 - {}({}{date})共 {} 首",
album.name,
album.singers.join(", "),
album.id,
album.songs.len()
);
}
async fn pick_singer(
sources: &Sources,
kind: SourceKind,
keyword: &str,
auto: bool,
) -> Result<Option<SingerHit>> {
let hits = sources.search_singers(kind, keyword, 10).await?;
if hits.is_empty() {
anyhow::bail!("没有搜索到“{keyword}”相关的歌手");
}
if let Some(h) = model::exact_match(&hits, keyword) {
return Ok(Some(h.clone()));
}
if auto || !interactive() {
eprintln!("没有同名歌手,选择第 1 个结果:{}", hits[0].name);
return Ok(Some(hits[0].clone()));
}
let labels: Vec<String> = hits
.iter()
.map(|h| format!("{}(专辑 {},歌曲 {})", h.name, h.album_num, h.song_num))
.collect();
match Select::new("选择歌手:", labels)
.with_page_size(15)
.with_help_message("↑↓ 移动,输入可过滤,回车 确认,Esc 取消")
.raw_prompt()
{
Ok(o) => Ok(Some(hits[o.index].clone())),
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(None),
Err(e) => Err(e).context("交互选择失败"),
}
}
fn print_artist_albums(albums: &[ArtistAlbum]) {
let mut table = Table::new();
table
.load_style(UTF8_FULL_CONDENSED)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(["#", "发行日期", "类型", "专辑", "歌手", "曲目数", "id"]);
for (i, a) in albums.iter().enumerate() {
table.add_row([
(i + 1).to_string(),
a.publish_date.clone(),
a.album_type.clone(),
a.name.clone(),
a.singer.clone(),
a.song_num.to_string(),
a.id.clone(),
]);
}
println!("{table}");
}
fn choose_albums(albums: &[ArtistAlbum]) -> Result<Vec<usize>> {
let labels: Vec<String> = albums
.iter()
.enumerate()
.map(|(i, a)| {
format!(
"{:>3}. {} 《{}》 [{},{} 首]",
i + 1,
a.year(),
a.name,
a.album_type,
a.song_num
)
})
.collect();
match MultiSelect::new("选择要下载的专辑:", labels)
.with_page_size(15)
.with_help_message("↑↓ 移动,空格 选择,→ 全选,← 全不选,输入可过滤,回车 确认,Esc 取消")
.raw_prompt()
{
Ok(opts) => Ok(opts.into_iter().map(|o| o.index).collect()),
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(vec![]),
Err(e) => Err(e).context("交互选择失败"),
}
}
fn filter_albums(
mut albums: Vec<ArtistAlbum>,
kinds: &[AlbumKind],
num: Option<u32>,
) -> Vec<ArtistAlbum> {
if !kinds.is_empty() {
albums.retain(|a| kinds.contains(&a.kind));
}
if let Some(n) = num {
albums.truncate(n as usize);
}
albums
}
struct ArtistArgs<'a> {
query: Option<&'a str>,
mid: Option<&'a str>,
kinds: &'a [AlbumKind],
order: AlbumOrder,
num: Option<u32>,
json: bool,
yes: bool,
}
async fn cmd_artist(sources: &Sources, a: ArtistArgs<'_>, settings: &Settings) -> Result<bool> {
let kind = a
.query
.and_then(source::detect)
.unwrap_or(settings.source());
let (singer_mid, singer_name) = match (a.mid, a.query) {
(Some(m), _) => (
source::parse_singer(kind, m).with_context(|| match kind {
SourceKind::Qq => format!("--mid 需要 14 位 singermid,收到:{m}"),
SourceKind::Netease => format!("--mid 需要网易云数字歌手 id,收到:{m}"),
})?,
None,
),
(None, Some(q)) => match source::parse_singer(kind, q).filter(|_| q.contains("://")) {
Some(m) => (m, None),
None => match pick_singer(sources, kind, q, a.yes || a.json).await? {
Some(h) => {
eprintln!(
"歌手:{}({},专辑 {},歌曲 {})",
h.name, h.id, h.album_num, h.song_num
);
(h.id, Some(h.name))
}
None => {
eprintln!("未选择歌手");
return Ok(true);
}
},
},
(None, None) => unreachable!("clap 保证至少有一个"),
};
let all = sources.artist_albums(kind, &singer_mid, a.order).await?;
let total = all.len();
let singer_name = singer_name.or_else(|| model::main_singer(&all));
let albums = filter_albums(all, a.kinds, a.num);
if a.json {
println!("{}", serde_json::to_string_pretty(&albums)?);
return Ok(true);
}
let who = singer_name.unwrap_or(singer_mid);
if albums.is_empty() {
anyhow::bail!("{who} 共 {total} 张专辑,没有符合 --type 的");
}
eprintln!("{who} 共 {total} 张专辑,列出 {} 张", albums.len());
print_artist_albums(&albums);
let picked: Vec<usize> = if a.yes {
(0..albums.len()).collect()
} else if !interactive() {
eprintln!(
"非交互环境,无法选择专辑。可加 -y 下载全部(建议配合 --type / -n 缩小范围),或用 `uta album{} --mid <id>` 下载单张。",
source_flag(kind)
);
return Ok(true);
} else {
choose_albums(&albums)?
};
if picked.is_empty() {
eprintln!("未选择任何专辑");
return Ok(true);
}
let n = picked.len();
let mut failed = Vec::new();
for (k, i) in picked.into_iter().enumerate() {
let hit = &albums[i];
eprintln!("\n[{}/{n}] 《{}》", k + 1, hit.name);
let r = async {
let album = sources
.album(&source::AlbumKey::from_id(kind, &hit.id))
.await?;
print_album_header(&album);
let subdir = album.dir_name();
download_collection(sources, album.songs, &subdir, None, false, true, settings).await
}
.await;
match r {
Ok(true) => {}
Ok(false) => failed.push(hit.name.clone()),
Err(e) => {
eprintln!("✘ 《{}》:{e:#}", hit.name);
failed.push(hit.name.clone());
}
}
}
if failed.is_empty() {
eprintln!("\n全部 {n} 张专辑完成");
Ok(true)
} else {
eprintln!(
"\n{n} 张专辑中 {} 张有失败:{}",
failed.len(),
failed
.iter()
.map(|f| format!("《{f}》"))
.collect::<Vec<_>>()
.join("、")
);
Ok(false)
}
}
async fn cmd_get(sources: &Sources, mids: &[String], settings: &Settings) -> Result<bool> {
let opts = settings.opts;
let mut seen = std::collections::HashSet::new();
let mids: Vec<&String> = mids.iter().filter(|m| seen.insert(m.trim())).collect();
let details: Vec<(usize, Result<Song>)> = stream::iter(mids.iter().enumerate())
.map(|(i, input)| async move {
let kind = source::detect(input).unwrap_or(settings.source());
let r = sources.song(kind, input).await;
let numeric = input.trim().bytes().all(|b| b.is_ascii_digit());
let r = match r {
Err(e) if kind == SourceKind::Qq && numeric => {
Err(anyhow::anyhow!("{e:#}(网易云的歌曲 id 请加 -s netease)"))
}
r => r,
};
(i, r)
})
.buffer_unordered(settings.jobs)
.collect()
.await;
let mut details = details;
details.sort_by_key(|(i, _)| *i);
let mut all_ok = true;
let mut songs = Vec::new();
let mut seen = std::collections::HashSet::new();
for (_, d) in details {
match d {
Ok(s) if !seen.insert((s.source, s.id.clone())) => {}
Ok(s) => songs.push(s),
Err(e) => {
all_ok = false;
eprintln!("✘ {e:#}");
}
}
}
if songs.is_empty() {
anyhow::bail!("没有可下载的歌曲");
}
let wanted = songs.len();
let items = resolve_all(sources, songs, opts, settings.jobs, settings.fallback).await;
if items.len() < wanted {
all_ok = false;
}
if items.is_empty() {
return Ok(false);
}
print_table(&items);
Ok(download_and_report(sources, &items, settings).await? && all_ok)
}
fn cmd_config(action: &ConfigAction, explicit: Option<&Path>) -> Result<bool> {
let path = match explicit {
Some(p) => p.to_path_buf(),
None => config::default_path().context("无法确定配置目录")?,
};
match action {
ConfigAction::Path => {
let state = if path.exists() {
"已存在"
} else {
"不存在"
};
println!("{}", path.display());
eprintln!("({state})");
}
ConfigAction::Init { force } => {
if path.exists() && !force {
anyhow::bail!("{} 已存在,如需覆盖请加 --force", path.display());
}
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)
.with_context(|| format!("无法创建目录 {}", dir.display()))?;
}
std::fs::write(&path, include_str!("../config.example.toml"))
.with_context(|| format!("无法写入 {}", path.display()))?;
eprintln!("已生成 {},按需修改 [defaults] 等配置", path.display());
}
}
Ok(true)
}
fn error_hint(e: &anyhow::Error) -> Option<&'static str> {
e.chain().find_map(|c| {
let re = c.downcast_ref::<reqwest::Error>()?;
if re.is_timeout() {
Some("网络请求超时,请检查网络或代理设置(HTTPS_PROXY 等环境变量)")
} else if re.is_connect() {
Some("无法连接服务器,请检查网络或代理设置(HTTPS_PROXY 等环境变量)")
} else {
None
}
})
}
async fn run(cli: Cli) -> Result<bool> {
if let Command::Config { action } = &cli.command {
return cmd_config(action, cli.config.as_deref());
}
if let Command::Completions { shell } = &cli.command {
use clap::CommandFactory;
clap_complete::generate(*shell, &mut Cli::command(), "uta", &mut std::io::stdout());
return Ok(true);
}
let config = Config::load(cli.config.as_deref())?;
if !config.vkeys.enabled() && !config.tang.enabled() && !config.netease.any_enabled() {
eprintln!(
"提示:未配置第三方接口(见 config.example.toml),只能使用官方接口,通常拿不到无损。"
);
}
let defaults = config.defaults.clone();
let sources = Sources::new(build_api_client()?, config);
match &cli.command {
Command::Search {
keyword,
num,
json,
no_merge,
common,
} => {
let settings = common.settings(&defaults)?;
cmd_search(
&sources,
keyword,
*num as usize,
*json,
*no_merge,
&settings,
)
.await
}
Command::Get { mids, common } => {
let settings = common.settings(&defaults)?.single("get")?;
cmd_get(&sources, mids, &settings).await
}
Command::Playlist {
playlist,
num,
json,
yes,
common,
} => {
let settings = common.settings(&defaults)?.single("playlist")?;
cmd_playlist(&sources, playlist, *num, *json, *yes, &settings).await
}
Command::Album {
query,
mid,
num,
json,
yes,
common,
} => {
let settings = common.settings(&defaults)?.single("album")?;
cmd_album(
&sources,
query.as_deref(),
mid.as_deref(),
*num,
*json,
*yes,
&settings,
)
.await
}
Command::Artist {
query,
mid,
kinds,
order,
num,
json,
yes,
common,
} => {
let settings = common.settings(&defaults)?.single("artist")?;
let args = ArtistArgs {
query: query.as_deref(),
mid: mid.as_deref(),
kinds,
order: *order,
num: *num,
json: *json,
yes: *yes,
};
cmd_artist(&sources, args, &settings).await
}
Command::Doctor { source } => {
let list = if source.is_empty() {
defaults.source.clone().unwrap_or_default()
} else {
source.clone()
};
let mut seen = std::collections::HashSet::new();
let mut kinds: Vec<SourceKind> = list.into_iter().filter(|k| seen.insert(*k)).collect();
if kinds.is_empty() {
kinds.push(SourceKind::default());
}
Ok(doctor::run(&sources, &kinds).await)
}
Command::Config { .. } | Command::Completions { .. } => unreachable!("已在前面处理"),
}
}
#[tokio::main]
async fn main() -> ExitCode {
let cli = Cli::parse();
init_logging(cli.verbose);
match run(cli).await {
Ok(true) => ExitCode::SUCCESS,
Ok(false) => ExitCode::FAILURE,
Err(e) => {
eprintln!("错误:{e:#}");
if let Some(hint) = error_hint(&e) {
eprintln!("提示:{hint}");
}
ExitCode::FAILURE
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn durations_and_sizes() {
assert_eq!(fmt_duration(226), "3:46");
assert_eq!(fmt_duration(5), "0:05");
assert_eq!(fmt_size(0), "未知");
assert_eq!(fmt_size(26_691_277), "25.45 MB");
}
fn get_args(args: &[&str]) -> CommonArgs {
let mut v = vec!["uta", "get", "m"];
v.extend(args);
match Cli::parse_from(v).command {
Command::Get { common, .. } => common,
_ => unreachable!(),
}
}
#[test]
fn artist_cli() {
let cli = Cli::parse_from([
"uta",
"artist",
"周杰伦",
"-t",
"studio,ep",
"--order",
"hot",
"-n",
"3",
"-y",
]);
let Command::Artist {
query,
kinds,
order,
num,
yes,
json,
..
} = cli.command
else {
panic!("应为 artist");
};
assert_eq!(query.as_deref(), Some("周杰伦"));
assert_eq!(kinds, [AlbumKind::Studio, AlbumKind::Ep]);
assert_eq!(
(order, num, yes, json),
(AlbumOrder::Hot, Some(3), true, false)
);
let Command::Artist { kinds, order, .. } =
Cli::parse_from(["uta", "artist", "--mid", "0025NhlN2yWrP4"]).command
else {
panic!("应为 artist");
};
assert!(kinds.is_empty());
assert_eq!(order, AlbumOrder::New);
assert!(Cli::try_parse_from(["uta", "artist"]).is_err());
assert!(Cli::try_parse_from(["uta", "artist", "x", "--mid", "y"]).is_err());
assert!(Cli::try_parse_from(["uta", "artist", "x", "-t", "nope"]).is_err());
}
#[test]
fn album_filter() {
let a = |kind: AlbumKind| ArtistAlbum {
id: String::new(),
name: String::new(),
singer: String::new(),
album_type: String::new(),
kind,
publish_date: String::new(),
song_num: 0,
};
let list = vec![
a(AlbumKind::Single),
a(AlbumKind::Studio),
a(AlbumKind::Ep),
a(AlbumKind::Studio),
];
assert_eq!(filter_albums(list.clone(), &[], None).len(), 4);
let f = filter_albums(list.clone(), &[AlbumKind::Studio, AlbumKind::Ep], Some(2));
assert_eq!(
f.iter().map(|x| x.kind).collect::<Vec<_>>(),
[AlbumKind::Studio, AlbumKind::Ep]
);
assert!(filter_albums(list, &[AlbumKind::Live], None).is_empty());
}
#[test]
fn verbatim_prefix() {
assert_eq!(strip_verbatim(r"\\?\C:\Users\a\Music"), r"C:\Users\a\Music");
assert_eq!(strip_verbatim(r"\\?\UNC\srv\share"), r"\\?\UNC\srv\share");
assert_eq!(strip_verbatim("/home/a/Music"), "/home/a/Music");
}
#[test]
fn completions_for_every_shell() {
use clap::{CommandFactory, ValueEnum};
for &shell in clap_complete::Shell::value_variants() {
let mut out = Vec::new();
clap_complete::generate(shell, &mut Cli::command(), "uta", &mut out);
let text = String::from_utf8(out).unwrap();
assert!(
text.contains("playlist") && text.contains("netease"),
"{shell:?}"
);
}
assert!(Cli::try_parse_from(["uta", "completions", "zsh"]).is_ok());
assert!(Cli::try_parse_from(["uta", "completions", "tcsh"]).is_err());
}
#[test]
fn cli_parses() {
use clap::CommandFactory;
Cli::command().debug_assert();
let cli = Cli::parse_from(["uta", "search", "夜曲", "-n", "20", "--lossless", "--json"]);
let Command::Search {
num, json, common, ..
} = cli.command
else {
panic!("应为 search");
};
assert_eq!((num, json, common.lossless), (20, true, true));
assert_eq!(
(common.jobs, common.download_jobs, common.output),
(None, None, None)
);
let cli = Cli::parse_from(["uta", "get", "a", "b", "-o", "/tmp/x", "-J", "2"]);
let Command::Get { mids, common } = cli.command else {
panic!("应为 get");
};
assert_eq!(mids, ["a", "b"]);
assert_eq!(common.output, Some(PathBuf::from("/tmp/x")));
assert_eq!(common.download_jobs, Some(2));
assert!(Cli::try_parse_from(["uta", "get"]).is_err());
assert!(Cli::try_parse_from(["uta", "get", "m", "-q", "ultra"]).is_err());
let cli = Cli::parse_from([
"uta",
"playlist",
"https://y.qq.com/n/ryqq/playlist/1",
"-n",
"5",
"-y",
]);
let Command::Playlist {
playlist,
num,
yes,
json,
..
} = cli.command
else {
panic!("应为 playlist");
};
assert_eq!(
(playlist.as_str(), num, yes, json),
("https://y.qq.com/n/ryqq/playlist/1", Some(5), true, false)
);
assert!(Cli::try_parse_from(["uta", "playlist", "1", "-n", "0"]).is_err());
let cli = Cli::parse_from(["uta", "album", "十一月的萧邦", "-y", "-n", "3"]);
let Command::Album {
query,
mid,
num,
yes,
..
} = cli.command
else {
panic!("应为 album");
};
assert_eq!(
(query.as_deref(), mid, num, yes),
(Some("十一月的萧邦"), None, Some(3), true)
);
assert!(matches!(
Cli::parse_from(["uta", "album", "--mid", "0024bjiL2aocxT"]).command,
Command::Album {
query: None,
mid: Some(_),
..
}
));
assert!(Cli::try_parse_from(["uta", "album"]).is_err());
assert!(Cli::try_parse_from(["uta", "album", "x", "--mid", "y"]).is_err());
assert!(matches!(
Cli::parse_from(["uta", "config", "init", "--force"]).command,
Command::Config {
action: ConfigAction::Init { force: true }
}
));
}
#[test]
fn settings_builtin_defaults() {
let s = get_args(&[]).settings(&Defaults::default()).unwrap();
assert_eq!(s.sources, [SourceKind::Qq]);
assert_eq!(s.opts.max_level, Level::Master);
assert!(!s.opts.lossless_only);
assert_eq!(s.output, PathBuf::from("."));
assert_eq!((s.jobs, s.download_jobs), (8, 4));
}
#[test]
fn settings_priority_cli_over_config() {
let d = Defaults {
source: Some(vec![SourceKind::Netease]),
output: Some("~/Music".into()),
max_quality: Some(Level::Sq),
jobs: Some(4),
download_jobs: Some(2),
};
let s = get_args(&[]).settings(&d).unwrap();
assert_eq!(s.source(), SourceKind::Netease);
assert_eq!(
get_args(&["-s", "qq"]).settings(&d).unwrap().source(),
SourceKind::Qq
);
let m = get_args(&["-s", "163,qq", "-s", "netease"])
.settings(&d)
.unwrap();
assert_eq!(m.sources, [SourceKind::Netease, SourceKind::Qq]);
assert!(m.clone().single("get").is_err());
assert!(s.clone().single("get").is_ok());
assert_eq!(s.opts.max_level, Level::Sq);
assert_eq!(s.output, dirs::home_dir().unwrap().join("Music"));
assert_eq!((s.jobs, s.download_jobs), (4, 2));
let s = get_args(&["-q", "hires", "-o", "out", "-j", "16", "-J", "1"])
.settings(&d)
.unwrap();
assert_eq!(s.opts.max_level, Level::Hires);
assert_eq!(s.output, PathBuf::from("out"));
assert_eq!((s.jobs, s.download_jobs), (16, 1));
}
#[test]
fn lossless_conflict() {
let none = Defaults::default();
let s = get_args(&["-q", "sq", "--lossless"])
.settings(&none)
.unwrap();
assert!(s.opts.lossless_only);
let err = get_args(&["-q", "hq", "--lossless"])
.settings(&none)
.unwrap_err();
assert!(err.to_string().contains("最高音质 hq"));
let d = Defaults {
max_quality: Some(Level::Hq),
..Defaults::default()
};
assert!(get_args(&["--lossless"]).settings(&d).is_err());
assert!(get_args(&["--lossless", "-q", "sq"]).settings(&d).is_ok());
}
}