优化调整、BT下载(有bug)

This commit is contained in:
zhongluofeng
2026-08-27 18:25:45 +08:00
parent 4dd60f42a1
commit d21649c60e
34 changed files with 4991 additions and 198 deletions
+53 -2
View File
@@ -1,10 +1,11 @@
use std::collections::HashMap;
use tauri::{AppHandle, State};
use tauri::{AppHandle, Manager, State};
use tauri_plugin_opener::OpenerExt;
use super::engine::{CheckUrlResult, DownloadEngine};
use super::task::{DownloadTask, DownloaderSettings};
use super::torrent::TorrentInfo;
/// 获取所有任务
#[tauri::command]
@@ -13,6 +14,20 @@ pub fn downloader_get_tasks(engine: State<'_, DownloadEngine>) -> Vec<DownloadTa
engine.get_tasks()
}
/// 解析磁力链 / .torrent 文件,返回种子信息(名称 / infohash / 文件列表),供前端做文件勾选
#[tauri::command]
#[specta::specta]
pub async fn downloader_inspect(engine: State<'_, DownloadEngine>, input: String) -> Result<TorrentInfo, String> {
engine.inspect(&input).await
}
/// 磁力任务:元数据解析成功后,用户勾选文件并确认开始下载
#[tauri::command]
#[specta::specta]
pub async fn downloader_select_bt_files(engine: State<'_, DownloadEngine>, id: String, only_files: Vec<u32>) -> Result<(), String> {
engine.select_bt_files(&id, only_files).await
}
/// 检查 URL 重复性并探测文件信息(添加下载前调用)
#[tauri::command]
#[specta::specta]
@@ -57,8 +72,9 @@ pub async fn downloader_add_task(
dir: Option<String>,
headers: Option<HashMap<String, String>>,
auto_rename: Option<bool>,
only_files: Option<Vec<u32>>,
) -> Result<String, String> {
engine.add_task(url, filename, dir, headers.unwrap_or_default(), auto_rename.unwrap_or(false)).await
engine.add_task(url, filename, dir, headers.unwrap_or_default(), auto_rename.unwrap_or(false), only_files).await
}
/// 暂停任务
@@ -75,6 +91,20 @@ pub fn downloader_resume_task(engine: State<'_, DownloadEngine>, id: String) ->
engine.resume_task(&id)
}
/// 取消任务(置为已取消,清空进度并删除下载文件,但保留记录)
#[tauri::command]
#[specta::specta]
pub fn downloader_cancel_task(engine: State<'_, DownloadEngine>, id: String) -> Result<(), String> {
engine.cancel_task(&id)
}
/// 重新下载已取消/出错的任务
#[tauri::command]
#[specta::specta]
pub async fn downloader_redownload(engine: State<'_, DownloadEngine>, id: String) -> Result<(), String> {
engine.redownload(&id).await
}
/// 移除任务
#[tauri::command]
#[specta::specta]
@@ -141,3 +171,24 @@ pub fn downloader_open_url(app: AppHandle, url: String) -> Result<(), String> {
.open_url(url, None::<&str>)
.map_err(|e| format!("打开链接失败: {}", e))
}
/// 将指定 label 的下载窗口显示并强制置为前台。
/// Tauri 的 set_focus 在 Windows 上受前台锁定限制(尤其下载窗口由后台进程创建、
/// 或创建到非主显示器时更明显),改用原生 SetForegroundWindow + BringWindowToTop
/// (模拟 Alt 键重置前台锁定),保证开始/完成下载时窗口能正确定位到前台。
#[tauri::command]
#[specta::specta]
pub fn downloader_focus_window(app: AppHandle, label: String) -> Result<(), String> {
let Some(window) = app.get_webview_window(&label) else {
return Ok(()); // 窗口已关闭则忽略
};
window.show().map_err(|e| e.to_string())?;
window.unminimize().map_err(|e| e.to_string())?;
match window.hwnd() {
Ok(hwnd) => crate::win32_util::force_foreground(hwnd.0 as isize),
Err(_) => {
window.set_focus().ok();
}
}
Ok(())
}
File diff suppressed because it is too large Load Diff
+53 -4
View File
@@ -53,14 +53,34 @@ impl HttpDownloader {
}
/// 探测下载资源信息(大小、是否支持 Range、文件名)
/// 优先用 GET + Range: bytes=0-0(返回 206 + Content-Range),回退到 HEAD
/// 优先用 GET + Range: bytes=0-0(返回 206 + Content-Range),回退到 HEAD
/// 代理降级:use_proxy=true 时先走系统代理,失败则回退 no_proxy 直连重试一次
pub async fn probe(
&self,
url: &str,
headers: &HashMap<String, String>,
use_proxy: bool,
) -> Result<ProbeResult, String> {
let client = self.client(use_proxy);
let first = self.client(use_proxy);
match self.probe_with_client(first, url, headers).await {
Ok(r) => return Ok(r),
Err(e) if use_proxy => {
let direct = self.client(false);
self.probe_with_client(direct, url, headers)
.await
.map_err(|e2| format!("代理探测失败({}),直连重试也失败({}", e, e2))
}
Err(e) => Err(e),
}
}
/// 用指定 client 执行探测(GET Range → 回退 HEAD),供代理降级复用
async fn probe_with_client(
&self,
client: &Client,
url: &str,
headers: &HashMap<String, String>,
) -> Result<ProbeResult, String> {
// 先尝试 Range 请求(能同时判断 Accept-Ranges 和获取大小)
let mut req = client
.get(url)
@@ -165,7 +185,36 @@ impl HttpDownloader {
limiter: Arc<RateLimiter>,
use_proxy: bool,
) -> Result<(), String> {
let client = self.client(use_proxy);
// 代理降级:仅当 use_proxy=true 才有"走代理→失败回退直连"的意义。
// use_proxy=false 直接用直连客户端,无需回退。
// 注意:用户主动暂停/取消(返回"已取消")必须原样透传,不能触发代理回退,
// 否则会把"已取消"包装成"代理失败",导致引擎将其误判为错误而非暂停。
let first = self.client(use_proxy);
match self.download_with_client(first, url, headers, segments, file_path, cancel.clone(), progress, &limiter).await {
Ok(()) => return Ok(()),
Err(e) if use_proxy && e != "已取消" => {
// 回退直连重试(不继承 use_proxy,保证用 no_proxy 客户端)
let direct = self.client(false);
self.download_with_client(direct, url, headers, segments, file_path, cancel, progress, &limiter)
.await
.map_err(|e2| format!("代理下载失败({}),直连重试也失败({}", e, e2))
}
Err(e) => Err(e),
}
}
/// 用指定 client 执行下载(支持单线程与多线程分段),供代理降级复用
async fn download_with_client(
&self,
client: &Client,
url: &str,
headers: &HashMap<String, String>,
segments: &[Segment],
file_path: &Path,
cancel: Arc<AtomicBool>,
progress: &[Arc<AtomicU64>],
limiter: &Arc<RateLimiter>,
) -> Result<(), String> {
let total_size = segments.iter().map(|s| s.len()).sum();
// 预分配文件(若已知大小)
@@ -191,7 +240,7 @@ impl HttpDownloader {
// 单线程下载(不支持 Range 或文件太小)
let seg = &segments[0];
let prog = &progress[0];
self.download_segment(url, headers, seg, file_path, cancel.clone(), prog.clone(), limiter, client)
self.download_segment(url, headers, seg, file_path, cancel.clone(), prog.clone(), limiter.clone(), client)
.await?;
return Ok(());
}
+3 -2
View File
@@ -5,10 +5,11 @@ pub mod rate_limit;
pub mod server;
pub mod storage;
pub mod task;
pub mod torrent;
pub use commands::{
downloader_add_task, downloader_check_url, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks,
downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_remove_task,
downloader_add_task, downloader_cancel_task, downloader_check_url, downloader_focus_window, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks, downloader_inspect, downloader_select_bt_files,
downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_redownload, downloader_remove_task,
downloader_resume_task, downloader_save_settings, downloader_status,
};
pub use engine::DownloadEngine;
+7 -6
View File
@@ -122,13 +122,14 @@ async fn create_download(
return Ok(Json(CreateDownloadResponse { id: existing.id }));
}
match state.engine.add_task(req.url, req.filename, req.dir, req.headers, true).await {
match state.engine.add_task(req.url, req.filename, req.dir, req.headers, true, None).await {
Ok(id) => {
// 浏览器扩展发起下载:置前主窗口并通知前端跳到下载画面(替代原桌面通知)
crate::tray_menu::focus_main_window(&state.app_handle);
let _ = state
.app_handle
.emit(crate::constants::events::DOWNLOAD_EXTENSION_ADDED, ());
// 浏览器扩展发起下载:不再置前主窗口,改为带 task id 通知前端,
// 由前端为该任务创建一个专属的一次性下载窗口(不打断主界面)
let _ = state.app_handle.emit(
crate::constants::events::DOWNLOAD_EXTENSION_ADDED,
serde_json::json!({ "id": id }),
);
Ok(Json(CreateDownloadResponse { id }))
}
Err(e) => Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))),
+55 -2
View File
@@ -2,6 +2,17 @@ use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::HashMap;
/// 任务下载协议类型
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type, Default)]
#[serde(rename_all = "lowercase")]
pub enum TaskProtocol {
/// HTTP/HTTPS 直链
#[default]
Http,
/// BitTorrent(磁力链 / .torrent 文件)
BitTorrent,
}
/// 任务状态
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type)]
#[serde(rename_all = "lowercase")]
@@ -16,6 +27,8 @@ pub enum TaskStatus {
Complete,
/// 错误
Error,
/// 已取消(用户取消:进度与文件已清除,仅保留记录,只能再次下载)
Cancelled,
}
/// 下载分段(多线程 Range 下载 / 断点续传用)
@@ -52,18 +65,42 @@ impl Segment {
}
}
/// BT 种子内文件条目(多文件任务用;阶段1下载全部文件,但保留列表供 UI 展示)
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct BtFileInfo {
/// 文件在种子内的索引
pub index: u32,
/// 相对种子根目录的路径(如 "sub/file.mkv"
pub path: String,
/// 文件大小(字节)
pub size: u64,
}
/// 下载任务
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DownloadTask {
/// 任务 ID(自增 hex 字符串)
pub id: String,
/// 下载地址
/// 下载地址HTTP URL 或磁力链接)
pub url: String,
/// 文件名
/// 文件名(HTTP:目标文件名;BT:种子名称)
pub filename: String,
/// 保存目录(绝对路径)
pub dir: String,
/// 协议类型
#[serde(default)]
pub protocol: TaskProtocol,
/// BT 种子 infohash(协议=BitTorrent 时存在)
#[serde(default)]
pub info_hash: Option<String>,
/// BT 种子内文件列表(协议=BitTorrent 时存在)
#[serde(default)]
pub bt_files: Vec<BtFileInfo>,
/// BT 元数据是否已解析就绪(异步添加时:后台解析完成前为 false,调度器跳过)
#[serde(default)]
pub bt_metadata_ready: bool,
/// 状态
pub status: TaskStatus,
/// 文件总大小(字节),0=未知
@@ -141,6 +178,18 @@ pub struct DownloaderSettings {
/// 下载是否使用代理:true=尊重系统代理(mihomo 开启系统代理时经其转发),false=强制直连
#[serde(default = "default_true")]
pub use_proxy: bool,
/// BitTorrent 上传限速 KB/s0=不限)
#[serde(default)]
pub bt_upload_limit_kb: u64,
/// BitTorrent 下载完成后是否继续做种上传(false=下载完即停止上传)
#[serde(default)]
pub bt_seed_after_download: bool,
/// BitTorrent 监听端口(0=自动选择)
#[serde(default)]
pub bt_listen_port: u16,
/// BitTorrent 使用代理下载:开启后自动使用代理模块(mihomo)的 SOCKS5 端口;代理不可用时降级直连
#[serde(default)]
pub bt_use_proxy: bool,
}
fn default_max_concurrent() -> u32 {
@@ -180,6 +229,10 @@ impl Default for DownloaderSettings {
delete_files_on_remove: false,
check_duplicate: true,
use_proxy: true,
bt_upload_limit_kb: 0,
bt_seed_after_download: false,
bt_listen_port: 0,
bt_use_proxy: false,
}
}
}
+469
View File
@@ -0,0 +1,469 @@
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, RwLock};
// 会话字段专用 tokio 异步互斥(与 std 互斥区分开):
// 创建 Session 是 async 操作,必须持锁跨 await,否则"双重检查"在 await 期间失效,
// 并发首次添加 BT 任务可能各建一个全局会话,产生孤儿会话与跨会话无效句柄。
use tokio::sync::Mutex as AsyncMutex;
use librqbit::api::TorrentIdOrHash;
use librqbit::{AddTorrent, AddTorrentOptions, AddTorrentResponse, ManagedTorrent, Session};
use super::task::BtFileInfo;
/// 磁力元数据解析超时(秒):依赖 DHT/tracker 拉取,过短易误报,过长体验差
pub(crate) const INSPECT_TIMEOUT_SECS: u64 = 60;
/// 每日 tracker 同步源(XIU2/TrackersListCollection,官方新域名 cf.trackerslist.com)。
/// 注意:GitHub 仓库 raw 路径(raw.githubusercontent.com/...trackers_best.txt)已随项目
/// 迁移失效(404),不要再用 GitHub 代理镜像。以下为官方 Cloudflare 分发地址,
/// 国内直连通常可达,按顺序尝试、首个成功即用。
const TRACKER_SYNC_SOURCES: &[&str] = &[
"https://cf.trackerslist.com/best.txt",
"https://trackerslist.com/best.txt",
"https://cf.trackerslist.com/all.txt",
];
/// 动态 tracker 持久化文件与同步元数据
const TRACKERS_FILE: &str = "bt_trackers.txt";
const TRACKERS_META_FILE: &str = "bt_trackers_meta.json";
/// 常用公共 tracker:磁力链接本身可能只带很少 tracker,追加这些可提升解析成功率。
/// 混合 UDP/HTTP(S)/WebSocket,覆盖 UDP 被屏蔽但 HTTP 可用的网络环境。
pub const PUBLIC_TRACKERS: &[&str] = &[
// UDP
"udp://tracker.opentrackr.org:1337/announce",
"udp://open.tracker.cl:1337/announce",
"udp://tracker.openbittorrent.com:6969/announce",
"udp://tracker.torrent.eu.org:451/announce",
"udp://open.stealth.si:80/announce",
"udp://exodus.desync.com:6969/announce",
"udp://tracker.tiny-vps.com:6969/announce",
"udp://open.demonii.com:1337/announce",
"udp://tracker.moeking.me:6969/announce",
"udp://ipv4.tracker.harry.lu:80/announce",
"udp://explodie.org:6969/announce",
"udp://tracker.birkenfeld.ru:7496/announce",
"udp://tracker.pomf.se:80/announce",
"udp://tracker.tamersunion.org:6969/announce",
"udp://retracker.lanta-net.ru:2710/announce",
// HTTP(S)
"http://tracker.opentrackr.org:1337/announce",
"http://tracker.openbittorrent.com:80/announce",
"http://tracker1.itzmx.com:8080/announce",
"http://tracker4.itzmx.com:2710/announce",
"https://tracker.gbitt.info:443/announce",
"https://tracker.nanoha.org:443/announce",
"http://tracker.bt4g.com:2095/announce",
"http://tracker.gbitt.info:80/announce",
];
/// 种子信息(inspect 解析结果,供命令返回给前端做文件勾选)
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct TorrentInfo {
/// 种子名称
pub name: String,
/// infohashhex 小写字符串)
pub info_hash: String,
/// 种子内全部文件总大小(字节)
pub total_size: u64,
/// 种子内文件列表
pub files: Vec<BtFileInfo>,
}
/// BitTorrent 下载器:封装 librqbit 全局会话(多任务共享 DHT / 监听端口 / tracker 缓存)。
/// 会话懒创建:只有真正添加 BT 任务时才初始化,避免引擎启动即拉起 BT 内核。
#[derive(Clone)]
pub struct TorrentDownloader {
/// 全局会话(懒创建;tokio 异步互斥保证并发首次添加只创建一个会话)
session: Arc<AsyncMutex<Option<Arc<Session>>>>,
/// 会话默认输出目录(每个任务用 AddTorrentOptions.output_folder 覆盖)
base_dir: PathBuf,
/// 上传限速 bytes/s0=不限),per-torrent 应用
upload_limit_bps: Arc<std::sync::atomic::AtomicU64>,
/// 监听端口(0=自动)
listen_port: Arc<std::sync::atomic::AtomicU16>,
/// SOCKS5 代理地址(None=直连;来自代理模块,不可用时降级直连)
proxy_addr: Arc<Mutex<Option<String>>>,
/// 动态公共 tracker(每日从外部列表同步,叠加到内置列表)
dynamic_trackers: Arc<RwLock<Vec<String>>>,
}
impl TorrentDownloader {
pub fn new(base_dir: PathBuf) -> Self {
Self {
session: Arc::new(AsyncMutex::new(None)),
base_dir,
upload_limit_bps: Arc::new(std::sync::atomic::AtomicU64::new(0)),
listen_port: Arc::new(std::sync::atomic::AtomicU16::new(0)),
proxy_addr: Arc::new(Mutex::new(None)),
dynamic_trackers: Arc::new(RwLock::new(Vec::new())),
}
}
/// 更新 BT 专属设置(上传限速 KB/s、监听端口、SOCKS5 代理地址)— 会话创建时生效
pub fn set_settings(&self, upload_limit_kb: u64, listen_port: u16, proxy_addr: Option<String>) {
self.upload_limit_bps
.store(upload_limit_kb.saturating_mul(1024), std::sync::atomic::Ordering::SeqCst);
self.listen_port
.store(listen_port, std::sync::atomic::Ordering::SeqCst);
*self.proxy_addr.lock().unwrap_or_else(|e| e.into_inner()) = proxy_addr;
}
/// 设置动态 tracker(合并去重)
pub fn add_dynamic_trackers(&self, list: Vec<String>) {
if list.is_empty() {
return;
}
let mut g = self.dynamic_trackers.write().unwrap_or_else(|e| e.into_inner());
for t in list {
let t = t.trim().to_string();
if !t.is_empty() && !g.contains(&t) {
g.push(t);
}
}
}
/// 当前动态 tracker 数量
pub fn dynamic_count(&self) -> usize {
self.dynamic_trackers.read().unwrap_or_else(|e| e.into_inner()).len()
}
/// 合并内置 + 动态 tracker(去重),供 inspect / add_async 共用
fn build_trackers(&self) -> Vec<String> {
let dyn_trackers = self.dynamic_trackers.read().unwrap_or_else(|e| e.into_inner()).clone();
let mut trackers: Vec<String> = PUBLIC_TRACKERS.iter().map(|s| s.to_string()).collect();
for t in dyn_trackers {
if !trackers.contains(&t) {
trackers.push(t);
}
}
trackers
}
/// 每日同步动态 tracker:读取本地缓存 → 判断今天是否已同步 → 未同步则拉取更新。
/// 失败静默降级:保留上次成功的列表。
pub async fn sync_dynamic_trackers(&self, data_dir: &Path) {
fn parse_list(text: &str) -> Vec<String> {
text.lines()
.map(|l| l.trim())
.filter(|l| l.starts_with("http://") || l.starts_with("https://") || l.starts_with("udp://") || l.starts_with("ws://") || l.starts_with("wss://"))
.map(|l| l.to_string())
.collect()
}
let trackers_path = data_dir.join(TRACKERS_FILE);
let meta_path = data_dir.join(TRACKERS_META_FILE);
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
// 1. 先加载上次成功保存的列表到内存(覆盖"重启后内存被清空")
if let Ok(s) = std::fs::read_to_string(&trackers_path) {
self.add_dynamic_trackers(parse_list(&s));
}
// 2. 今天已同步过则不再下载
if let Ok(meta) = std::fs::read_to_string(&meta_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&meta) {
if v.get("date").and_then(|d| d.as_str()) == Some(&today) {
return;
}
}
}
// 3. 依次尝试各镜像源(记录每个源的失败原因,便于判断是 DNS 还是连接超时)
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.ok();
for url in TRACKER_SYNC_SOURCES {
let Some(c) = &client else { break };
match c.get(*url).send().await {
Err(e) => {
crate::logger::log_line("download", crate::logger::LogLevel::Debug, &format!("动态 tracker 源不可达 {}: {}", url, e));
continue;
}
Ok(resp) => {
let text = match resp.text().await {
Ok(t) => t,
Err(e) => {
crate::logger::log_line("download", crate::logger::LogLevel::Debug, &format!("动态 tracker 源读取失败 {}: {}", url, e));
continue;
}
};
let list = parse_list(&text);
if list.is_empty() {
crate::logger::log_line("download", crate::logger::LogLevel::Debug, &format!("动态 tracker 源返回空列表 {}", url));
continue;
}
// 保存列表与同步元数据
let _ = std::fs::write(&trackers_path, format!("{}\n", list.join("\n")));
let _ = std::fs::write(&meta_path, serde_json::json!({ "date": today }).to_string());
self.add_dynamic_trackers(list);
crate::logger::log_info("download", &format!("已同步动态 tracker(共 {} 条)", self.dynamic_count()));
return;
}
}
}
crate::logger::log_warn("download", "动态 tracker 全部同步源不可达,使用上次成功列表或内置列表(不影响磁力解析,公共 tracker 仍会生效)");
}
/// 会话级公共 trackerHashSet<Url>,供 SessionOptions.trackers 使用)。
/// 注意:librqbit 对磁力链接只采用 magnet URL 自带的 tr= 参数,完全忽略
/// AddTorrentOptions.trackers(见 librqbit session.rs 的 magnet 分支),导致
/// 纯磁力(无自带 tr)解析元数据时 "trackers list is empty"。而会话级
/// SessionOptions.trackers 会在 make_peer_rx 中合并进每个种子(含磁力),
/// 因此必须放到这里才能让磁力链接真正带上公共 tracker。
fn session_trackers(&self) -> std::collections::HashSet<url::Url> {
self.build_trackers()
.into_iter()
.filter_map(|t| url::Url::parse(&t).ok())
.collect()
}
/// 获取或创建全局会话。
/// 持 tokio 互斥锁覆盖整个构造过程:并发调用在此串行化,
/// 先进入者创建并写入,后续者锁内二次检查直接复用(避免多会话竞态)。
/// 开启代理时先尝试用代理初始化;失败则降级为直连(记录下来供 add 重试判断)。
async fn get_session(&self) -> Result<Arc<Session>, String> {
let mut guard = self.session.lock().await;
if let Some(s) = guard.as_ref() {
return Ok(s.clone());
}
let _ = std::fs::create_dir_all(&self.base_dir);
let port = self.listen_port.load(std::sync::atomic::Ordering::SeqCst);
let proxy = self.proxy_addr.lock().unwrap_or_else(|e| e.into_inner()).clone();
let connect_opt = proxy.as_ref().map(|p| librqbit::ConnectionOptions { proxy_url: Some(p.clone()), ..Default::default() });
let had_proxy = connect_opt.is_some();
// 构造带代理(若启用)+ 监听端口 + 会话级公共 tracker 的会话选项
let mut opts = lib_session_options();
opts.trackers = self.session_trackers();
if connect_opt.is_some() {
opts.connect = connect_opt;
}
if port > 0 {
if let Ok(addr) = format!("0.0.0.0:{}", port).parse::<std::net::SocketAddr>() {
opts.listen = Some(librqbit::ListenerOptions {
listen_addr: addr,
..Default::default()
});
} else {
crate::logger::log_error("download", &format!("BT 监听端口 {} 无效,使用自动端口", port));
}
}
// 尝试创建会话:先按配置(可能带代理),失败且有代理则降级直连重试
let s = match Session::new_with_opts(self.base_dir.clone(), opts).await {
Ok(s) => s,
Err(e) => {
if had_proxy {
crate::logger::log_error("download", &format!("BT 代理会话初始化失败({}),降级为直连", e));
let mut m = lib_session_options();
m.trackers = self.session_trackers();
if port > 0 {
if let Ok(addr) = format!("0.0.0.0:{}", port).parse::<std::net::SocketAddr>() {
m.listen = Some(librqbit::ListenerOptions { listen_addr: addr, ..Default::default() });
}
}
Session::new_with_opts(self.base_dir.clone(), m).await.map_err(|e2| format!("初始化 BitTorrent 会话失败: {}", e2))?
} else {
return Err(format!("初始化 BitTorrent 会话失败: {}", e));
}
}
};
*guard = Some(s.clone());
Ok(s)
}
/// 解析磁力链 / 本地 .torrent 文件,返回种子信息(不开始下载)。
/// list_only 模式:仅获取元数据,不加入会话,因此不影响后续真正添加。
/// 磁力元数据依赖 DHT/tracker 拉取,无超时会永久等待,故加超时。
pub async fn inspect(&self, input: &str) -> Result<TorrentInfo, String> {
let session = self.get_session().await?;
let trackers = self.build_trackers();
// 磁力:需从网络拉取元数据,可能较慢(取决于种子热度与网络连通性);
// 本地 .torrent / http(s) .torrent URL:元数据在文件内,from_cli_argument 会读取/下载并解析
let add = AddTorrent::from_cli_argument(input).map_err(|e| format!("无效的种子输入: {}", e))?;
let add_fut = session.add_torrent(
add,
Some(AddTorrentOptions {
list_only: true,
trackers: Some(trackers),
..Default::default()
}),
);
let resp = tokio::time::timeout(std::time::Duration::from_secs(INSPECT_TIMEOUT_SECS), add_fut)
.await
.map_err(|_| "解析磁力元数据超时:未能从 DHT/Tracker 获取种子信息,请确认种子有做种源或网络可直连 BT".to_string())?
.map_err(|e| format!("解析种子失败: {}", e))?;
let AddTorrentResponse::ListOnly(lo) = resp else {
return Err("该链接未返回有效的种子元数据".to_string());
};
let info = lo.info.info();
let name = info
.name
.as_ref()
.map(|b| String::from_utf8_lossy(b.as_ref()).into_owned())
.or_else(|| lo.info.name().map(|c| c.to_string()))
.unwrap_or_else(|| "未命名种子".to_string());
let mut files = Vec::new();
let mut total_size = 0u64;
if let Some(fs) = &info.files {
for (i, f) in fs.iter().enumerate() {
let path = f
.path
.iter()
.map(|p| String::from_utf8_lossy(p.as_ref()).into_owned())
.collect::<Vec<_>>()
.join("/");
files.push(BtFileInfo {
index: i as u32,
path,
size: f.length,
});
total_size += f.length;
}
} else if let Some(len) = info.length {
// 单文件种子
files.push(BtFileInfo {
index: 0,
path: name.clone(),
size: len,
});
total_size = len;
}
Ok(TorrentInfo {
name,
info_hash: hex_encode(&lo.info_hash.0),
total_size,
files,
})
}
/// 异步添加种子:立即返回句柄(不等待元数据),由调用方后台解析。
/// 附加公共 tracker 提升磁力元数据解析成功率;上传限速 per-torrent 应用。
pub async fn add_async(
&self,
input: &str,
output_dir: &str,
) -> Result<(usize, Arc<ManagedTorrent>), String> {
let session = self.get_session().await?;
std::fs::create_dir_all(output_dir).map_err(|e| format!("创建下载目录失败: {}", e))?;
let up = self.upload_limit_bps.load(std::sync::atomic::Ordering::SeqCst);
let upload_bps = std::num::NonZeroU32::new(up.min(u32::MAX as u64) as u32);
let trackers = self.build_trackers();
let opts = AddTorrentOptions {
paused: true,
output_folder: Some(output_dir.to_string()),
overwrite: true,
trackers: Some(trackers),
ratelimits: librqbit::limits::LimitsConfig {
upload_bps,
download_bps: None,
},
..Default::default()
};
let add = AddTorrent::from_cli_argument(input).map_err(|e| format!("无效的种子输入: {}", e))?;
let resp = session
.add_torrent(add, Some(opts))
.await
.map_err(|e| format!("添加种子失败: {}", e))?;
match resp {
AddTorrentResponse::Added(id, handle) => Ok((id, handle)),
AddTorrentResponse::AlreadyManaged(id, handle) => Ok((id, handle)),
_ => Err("种子已存在或元数据无效".to_string()),
}
}
/// 等待种子元数据初始化就绪(磁力需要从网络拉取,带超时)
pub async fn wait_initialized(&self, handle: &Arc<ManagedTorrent>) -> Result<(), String> {
tokio::time::timeout(
std::time::Duration::from_secs(INSPECT_TIMEOUT_SECS),
handle.wait_until_initialized(),
)
.await
.map_err(|_| "解析磁力元数据超时:未能从 DHT/Tracker 获取种子信息,请确认种子有做种源或网络可直连 BT".to_string())?
.map_err(|e| format!("获取种子元数据失败: {}", e))
}
/// 设置要下载的种子文件子集(用户文件勾选;传全部索引则全选)
pub async fn set_only_files(&self, handle: &Arc<ManagedTorrent>, files: &[u32]) -> Result<(), String> {
let session = self.get_session().await?;
let set: std::collections::HashSet<usize> = files.iter().map(|&i| i as usize).collect();
session
.update_only_files(handle, &set)
.await
.map_err(|e| format!("设置下载文件失败: {}", e))
}
/// 读取句柄当前进度:返回 (已下载字节, 总字节, 每文件已下载字节)
/// `file_progress` 与种子文件一一对应,供前端详情页展示单文件进度。
pub fn progress_full(handle: &Arc<ManagedTorrent>) -> (u64, u64, Vec<u64>) {
let s = handle.stats();
(s.progress_bytes, s.total_bytes, s.file_progress.clone())
}
/// 暂停种子(下载中的连接会停止,已下载片段保留,可继续)
pub async fn pause(&self, handle: &Arc<ManagedTorrent>) -> Result<(), String> {
let session = self.get_session().await?;
session
.pause(handle)
.await
.map_err(|e| format!("暂停种子失败: {}", e))
}
/// 继续种子
pub async fn unpause(&self, handle: &Arc<ManagedTorrent>) -> Result<(), String> {
let session = self.get_session().await?;
session
.unpause(handle)
.await
.map_err(|e| format!("继续种子失败: {}", e))
}
/// 删除种子(info_hash 为 hex 字符串;delete_files 是否同时删除已下载文件)
pub async fn delete(&self, info_hash: &str, delete_files: bool) -> Result<(), String> {
let session = self.get_session().await?;
let id = TorrentIdOrHash::parse(info_hash)
.map_err(|_| format!("无效的 infohash: {}", info_hash))?;
session
.delete(id, delete_files)
.await
.map_err(|e| format!("删除种子失败: {}", e))
}
/// 读取句柄当前下载进度 (已下载字节, 总字节)
pub fn progress(handle: &Arc<ManagedTorrent>) -> (u64, u64) {
let s = handle.stats();
(s.progress_bytes, s.total_bytes)
}
/// 停止全局会话(应用退出时调用)
pub async fn stop(&self) {
let session = {
let mut guard = self.session.lock().await;
guard.take()
};
if let Some(s) = session {
let _ = s.stop().await;
}
}
}
/// 字节数组转小写十六进制字符串(librqbit 的 Id 未实现 Display
pub(crate) fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
/// 默认会话选项(后续如需新增 DHT/缓存等可在此统一配置)
fn lib_session_options() -> librqbit::SessionOptions {
librqbit::SessionOptions::default()
}