第 39 章 下载管理、断点续传、缓存策略
本章目标
- 设计一个任务式下载队列(可暂停、恢复、取消)。
- 断点续传:用 HTTP Range 头。
- 进度推送:Channel。
- 下载目录、并发上限、失败重试。
一、数据模型
// core/download/mod.rs
use std::path::PathBuf;
#[derive(Clone, Debug)]
pub struct DownloadTask {
pub id: String,
pub url: String,
pub target: PathBuf,
pub total: u64,
pub downloaded: u64,
pub status: Status,
}
#[derive(Clone, Debug, Copy, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub enum Status { Pending, Downloading, Paused, Done, Failed }
二、下载器核心
use futures::StreamExt;
use reqwest::header::{RANGE, CONTENT_LENGTH};
use tokio::io::AsyncWriteExt;
use tokio::fs::OpenOptions;
pub async fn download_with_resume(client: &reqwest::Client, task: &mut DownloadTask, mut on_progress: impl FnMut(u64, u64)) -> anyhow::Result<()> {
// 目标文件已有多少字节
let already = if task.target.exists() {
tokio::fs::metadata(&task.target).await?.len()
} else { 0 };
task.downloaded = already;
let mut req = client.get(&task.url);
if already > 0 {
req = req.header(RANGE, format!("bytes={}-", already));
}
let resp = req.send().await?.error_for_status()?;
// total = 已有 + Content-Length
if let Some(len) = resp.headers().get(CONTENT_LENGTH).and_then(|v| v.to_str().ok()).and_then(|s| s.parse::<u64>().ok()) {
task.total = already + len;
}
let mut file = OpenOptions::new().create(true).append(true).open(&task.target).await?;
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
file.write_all(&chunk).await?;
task.downloaded += chunk.len() as u64;
on_progress(task.downloaded, task.total);
}
file.flush().await?;
task.status = Status::Done;
Ok(())
}
三、任务管理器
use tokio::sync::{Mutex, Semaphore};
use dashmap::DashMap;
use std::sync::Arc;
use tauri::ipc::Channel;
#[derive(Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase", tag = "event", content = "data")]
pub enum DownloadEvent {
Progress { id: String, done: u64, total: u64 },
Done { id: String, path: String },
Failed { id: String, error: String },
}
pub struct DownloadManager {
pub tasks: DashMap<String, Arc<Mutex<DownloadTask>>>,
pub client: reqwest::Client,
pub sem: Arc<Semaphore>,
}
impl DownloadManager {
pub fn new(parallel: usize) -> Self {
Self {
tasks: DashMap::new(),
client: reqwest::Client::new(),
sem: Arc::new(Semaphore::new(parallel)),
}
}
pub async fn start(self: Arc<Self>, task: DownloadTask, ch: Channel<DownloadEvent>) {
let id = task.id.clone();
let task = Arc::new(Mutex::new(task));
self.tasks.insert(id.clone(), task.clone());
let sem = self.sem.clone();
let client = self.client.clone();
tokio::spawn(async move {
let _permit = sem.acquire_owned().await.unwrap();
let mut t = task.lock().await;
t.status = Status::Downloading;
let id2 = id.clone();
let ch_clone = ch.clone();
let res = download_with_resume(&client, &mut t, |d, total| {
let _ = ch_clone.send(DownloadEvent::Progress { id: id2.clone(), done: d, total });
}).await;
match res {
Ok(()) => { let _ = ch.send(DownloadEvent::Done { id, path: t.target.to_string_lossy().into() }); }
Err(e) => { t.status = Status::Failed; let _ = ch.send(DownloadEvent::Failed { id, error: e.to_string() }); }
}
});
}
pub async fn pause(&self, _id: &str) { /* 简化:关闭 HTTP 请求; 状态标记 */ }
pub async fn cancel(&self, id: &str) {
if let Some((_, t)) = self.tasks.remove(id) {
let tt = t.lock().await;
let _ = tokio::fs::remove_file(&tt.target).await;
}
}
}
pause的严谨实现:持有一个CancellationToken(tokio-util::sync::CancellationToken),任务内部每次写 chunk 前检查;pause触发 cancel,下次resume时重新调度。
四、命令
#[tauri::command] #[specta::specta]
pub async fn download_start(state: tauri::State<'_, AppState>, id: String, url: String, target: String, ch: Channel<DownloadEvent>) -> Result<(), String> {
let task = DownloadTask {
id, url, target: target.into(), total: 0, downloaded: 0, status: Status::Pending,
};
state.downloader.clone().start(task, ch).await;
Ok(())
}
#[tauri::command] #[specta::specta]
pub async fn download_cancel(state: tauri::State<'_, AppState>, id: String) -> Result<(), String> {
state.downloader.cancel(&id).await;
Ok(())
}
五、前端 UI
export function DownloadPanel() {
const [tasks, setTasks] = useState<Map<string, Task>>(new Map());
useEffect(() => {
// 监听全局事件(每次 download_start 用不同 Channel 更好)
}, []);
async function addDownload(song: RemoteSong) {
const ch = new Channel<DownloadEvent>();
ch.onmessage = (e) => {
if (e.event === "progress") {
setTasks(prev => new Map(prev).set(e.data.id, { ...prev.get(e.data.id)!, done: e.data.done, total: e.data.total }));
}
if (e.event === "done") { toast.success("下载完成"); }
};
const url = await commands.providerStreamUrl(song.provider, song.id);
await commands.downloadStart(song.id, url, `~/Music/CloudTone/${song.title}.mp3`, ch);
}
return <div>...</div>;
}
六、目录与命名
- 默认目录:
$HOME/Music/CloudTone/(用户可在设置里改)。 - 文件名:
{artist} - {title}.{ext},非法字符替换为_。 - 冲突:如存在同名,加
(2)、(3)。
pub fn sanitize(s: &str) -> String {
s.chars().map(|c| match c { '/'|'\\'|':'|'*'|'?'|'"'|'<'|'>'|'|' => '_', _ => c }).collect()
}
七、缓存淘汰
$APPCACHE/stream/ 用 LRU:
pub fn evict_cache(dir: &Path, max_size_mb: u64) -> std::io::Result<()> {
let mut files: Vec<_> = std::fs::read_dir(dir)?
.filter_map(Result::ok)
.map(|e| (e.path(), e.metadata().unwrap().modified().unwrap(), e.metadata().unwrap().len()))
.collect();
files.sort_by_key(|(_, m, _)| *m); // 旧的优先删
let total: u64 = files.iter().map(|(_, _, s)| s).sum();
let mut over = total.saturating_sub(max_size_mb * 1024 * 1024);
for (p, _, s) in files {
if over == 0 { break; }
let _ = std::fs::remove_file(&p);
over = over.saturating_sub(s);
}
Ok(())
}
本章小结
- Range 请求 + append 写入 = 断点续传。
- 信号量控制并发,避免把带宽打满。
- 用 CancellationToken 做暂停 / 取消。
动手时刻
- 下载一首 demo mp3,中途断网看恢复。
- 同时下载 5 首,限制并发 2,观察排队。
下一章:均衡器(EQ)与音效处理。