diff --git a/README.md b/README.md index 165d20a..69d60a5 100644 --- a/README.md +++ b/README.md @@ -24,3 +24,5 @@ opencodes/ 不参与构建的参考项目 Web 开发和构建方式见 [`web/README.md`](web/README.md) 开发环境可以直接运行 `scripts\run.bat` 在当前窗口同时启动 Rust 后端和 Web 前端 按一次 `Ctrl+C` 即可统一停止 + +任一服务异常退出时启动脚本会清理 Cargo Bun 及其子进程树 避免遗留 Vite 或后端进程 diff --git a/TODOS.md b/TODOS.md index 997d699..6879de1 100644 --- a/TODOS.md +++ b/TODOS.md @@ -168,12 +168,18 @@ - [x] 实现 `/contents/{content_key}` 内容变体接口 - [x] 使用 Bun Vue TypeScript Vite Tailwind CSS 和 shadcn-vue 建立 Web 基础环境 - [x] 实现简单现代并适配移动端的单页搜索界面 -- [x] 接入搜索过滤排序分页详情内容变体和磁力链接复制 +- [x] 接入搜索排序分页详情内容变体和磁力链接复制 +- [x] 实现名称别名和文件路径的有限状态自动机正则搜索 +- [x] 实现种子详情文件列表后端分页并限制浏览器单页节点数量 +- [x] 保留种子详情顶部结构并使用 reka-ui 数字分页重构文件条目 +- [x] 支持用户选择并持久化文件列表每页数量 +- [x] 统一搜索结果数字分页并支持用户选择每页数量 - [x] 接入采集索引持久化和验证运行状态展示 -- [x] 实现运行状态定时刷新和浏览器持久化明暗主题 +- [x] 实现运行状态每秒刷新点击外部关闭和浏览器持久化明暗主题 - [x] 为加载空结果接口错误和失败重试提供明确界面状态 - [x] 将生产静态资源交给 Axum 提供并支持单页回退 - [x] 提供 Windows 一键启动后端和 Web 开发服务的脚本 +- [x] 修复一键启动脚本只停止父进程导致 Vite 子进程残留的问题 - [x] 定义统一错误响应 - [x] 限制查询长度分页大小和最大 offset - [ ] 增加请求延迟错误率和并发指标 @@ -240,6 +246,8 @@ - [x] 为 Metadata TCP 建连增加独立每秒速率限制 - [x] 将桌面默认配置调整为保守网络预算 - [x] 根据本机首次验证将主动 UDP 从 `40/s` 下调至 `10/s` 并将 Metadata 建连从 `5/s` 下调至 `2/s` +- [x] 对照 Bitmagnet 默认并发将运行模板调整为受全局预算约束的高吞吐配置 +- [x] 修复 Windows 临时索引文件占用导致整个服务退出的问题 - [x] 验证极保守配置运行三分钟不影响同机代理网络并安全退出 - [x] 将 BEP-51 采样准入压力反向传递到采样查询调度 - [x] 实现样本来源节点单点 `get_peers` 优先和失败后有限递归降级 diff --git a/dht-search.example.toml b/dht-search.example.toml index a65222c..6b0ad85 100644 --- a/dht-search.example.toml +++ b/dht-search.example.toml @@ -1,27 +1,27 @@ # 定义 dht-search 的推荐起始配置并作为用户配置模板 data_dir = "data" -persistence_queue_capacity = 4096 +persistence_queue_capacity = 8192 stats_interval_secs = 10 -index_batch_size = 512 +index_batch_size = 1024 index_interval_millis = 5000 [dht] port = 12313 netmode = "ipv4-only" -hash_queue_capacity = 10000 -max_outbound_queries_per_second = 10 -outbound_query_burst = 2 -metadata_timeout_secs = 4 -metadata_queue_capacity = 10000 -metadata_workers = 8 -metadata_connects_per_second = 2 -sample_queries_per_second = 1 -peer_lookups_per_second = 1 -peer_lookup_max_active = 4 -find_node_queries_per_second = 6 -find_node_max_in_flight = 12 -new_destinations_per_minute = 60 +hash_queue_capacity = 20000 +max_outbound_queries_per_second = 50 +outbound_query_burst = 10 +metadata_timeout_secs = 6 +metadata_queue_capacity = 20000 +metadata_workers = 64 +metadata_connects_per_second = 16 +sample_queries_per_second = 10 +peer_lookups_per_second = 8 +peer_lookup_max_active = 32 +find_node_queries_per_second = 30 +find_node_max_in_flight = 64 +new_destinations_per_minute = 1200 [http] listen = "127.0.0.1:8080" @@ -30,7 +30,7 @@ web_dir = "web/dist" [verification] enabled = true queue_capacity = 10000 -max_active = 2 +max_active = 8 max_peer_attempts = 3 lease_secs = 60 poll_interval_millis = 250 diff --git a/dht-search/README.md b/dht-search/README.md index e33d62e..260c4aa 100644 --- a/dht-search/README.md +++ b/dht-search/README.md @@ -36,22 +36,22 @@ cargo run -p dht-search -- --data-dir D:\data\dht-search --run-duration-secs 360 生产 Web 页面需要先在 `web` 目录执行 `bun run build` Axum 会从 `http.web_dir` 提供构建结果 -### 网络保护配置 +### 当前运行模板网络配置 -主动 DHT 查询共享 `max_outbound_queries_per_second` 总预算,因此 `find_node` `get_peers` 和 `sample_infohashes` 的总发送速率不会各自叠加后失控 +`dht-search.example.toml` 使用低于 Bitmagnet 默认并发的高吞吐配置 主动 DHT 查询仍共享 `max_outbound_queries_per_second` 总预算 因此 `find_node` `get_peers` 和 `sample_infohashes` 的总发送速率不会各自叠加后失控 -| 配置项 | 保守默认值 | 作用 | +| 配置项 | 运行模板值 | 作用 | |---|---:|---| -| `max_outbound_queries_per_second` | `10` | 三类主动 DHT UDP 查询的合计每秒速率 | -| `outbound_query_burst` | `2` | 空闲后允许立即消费的 UDP 查询数 | -| `find_node_queries_per_second` | `6` | `find_node` 自身速率上限 | -| `find_node_max_in_flight` | `12` | 同时等待响应的 `find_node` 数量 | -| `new_destinations_per_minute` | `60` | 每分钟首次探测的新 UDP 目标数量 | -| `peer_lookups_per_second` | `1` | 每秒启动的 infohash Peer 查找数量 | -| `peer_lookup_max_active` | `4` | 同时运行的 Peer 查找数量 | -| `sample_queries_per_second` | `1` | BEP-51 采样查询速率 | -| `metadata_workers` | `8` | 同时处理的 Metadata 任务数量 | -| `metadata_connects_per_second` | `2` | 每秒真正开始的 Peer TCP 连接数量 | +| `max_outbound_queries_per_second` | `50` | 三类主动 DHT UDP 查询的合计每秒速率 | +| `outbound_query_burst` | `10` | 空闲后允许立即消费的 UDP 查询数 | +| `find_node_queries_per_second` | `30` | `find_node` 自身速率上限 | +| `find_node_max_in_flight` | `64` | 同时等待响应的 `find_node` 数量 | +| `new_destinations_per_minute` | `1200` | 每分钟首次探测的新 UDP 目标数量 | +| `peer_lookups_per_second` | `8` | 每秒启动的 infohash Peer 查找数量 | +| `peer_lookup_max_active` | `32` | 同时运行的 Peer 查找数量 | +| `sample_queries_per_second` | `10` | BEP-51 采样查询速率 | +| `metadata_workers` | `64` | 同时处理的 Metadata 任务数量 | +| `metadata_connects_per_second` | `16` | 每秒真正开始的 Peer TCP 连接数量 | Metadata 下载和可用性握手共用 `metadata_connects_per_second` 预算不会各自叠加 @@ -76,7 +76,9 @@ Metadata 下载和可用性握手共用 `metadata_connects_per_second` 预算不 热度是近期 DHT 发现强度最近出现时间和可连接 Peer 数的综合活跃度分数 不代表全球下载量 -桌面网络不要在不了解路由器 NAT 和代理容量时大幅提高这些值 +这组参数仍保留全局速率限制 如果同机代理或路由器再次出现不稳定应优先降低总 UDP 预算和 Metadata 建连速率 + +Windows 下索引每五秒批量提交 临时文件占用会自动指数退避重试且不会停止采集 HTTP 服务或丢失 RocksDB 待索引状态 ### 采样去重和 Peer 查找 @@ -119,14 +121,17 @@ GET /stats GET /search?q=ubuntu&offset=0&limit=20 GET /search?q=&min_size=1048576&max_size=10737418240&extension=mkv GET /search?q=流浪地球&min_files=1&availability=active&heat=hot&sort=heat +GET /search?q=%5ES%5Cd%7B2%7DE%5Cd%7B2%7D®ex=true GET /contents/{content_key}?offset=0&limit=20 -GET /torrents/{infohash} +GET /torrents/{infohash}?file_offset=0&file_limit=100 ``` `limit` 被限制在 1 到 100 之间且 `offset` 最大为 10000 搜索支持中文英文数字和文件名片段匹配 +设置 `regex=true` 后查询文本作为不区分大小写的正则表达式匹配名称 别名和文件路径 正则最长 256 字节并由 Tantivy 有限状态自动机执行 + 过滤参数还包括 `min_files` `max_files` `first_seen_after` `first_seen_before` `last_seen_after` `last_seen_before` `availability` 和 `heat` 排序支持 `relevance` `latest` `oldest` `heat` `size_desc` `size_asc` 和 `discoveries` @@ -137,6 +142,8 @@ GET /torrents/{infohash} 搜索响应包含 `heat` 和 `availability` 摘要 详情响应包含完整验证时间 Peer 数和连续失败次数 +详情文件列表默认返回 100 条且单次最多 200 条 使用 `file_offset` 翻页避免超大种子一次向浏览器返回全部文件 + ## 数据恢复 RocksDB 是权威数据源而 Tantivy 是可重建索引 diff --git a/dht-search/src/api/handlers.rs b/dht-search/src/api/handlers.rs index 15f3ed9..79062f8 100644 --- a/dht-search/src/api/handlers.rs +++ b/dht-search/src/api/handlers.rs @@ -16,7 +16,7 @@ use dht_search::{ use super::{ ApiState, - request::{ContentVariantsRequest, SearchRequest}, + request::{ContentVariantsRequest, SearchRequest, TorrentRequest}, response::{ ContentVariantsResponse, ErrorResponse, StatsResponse, StatusResponse, TorrentResponse, TorrentVariantResponse, @@ -83,6 +83,9 @@ pub(crate) async fn search( if request.q.len() > 512 { return Err(ApiError::bad_request("查询文本不能超过 512 字节")); } + if request.regex && request.q.len() > 256 { + return Err(ApiError::bad_request("正则表达式不能超过 256 字节")); + } validate_range(request.min_size, request.max_size, "min_size", "max_size")?; validate_range( request.min_files, @@ -103,7 +106,12 @@ pub(crate) async fn search( "last_seen_before", )?; let mut query = request.q; - let content_key = if let Ok(info_hash) = InfoHash::from_str(query.trim()) { + let content_key = if !request.regex { + InfoHash::from_str(query.trim()).ok() + } else { + None + }; + let content_key = if let Some(info_hash) = content_key { let repository = state.repository.clone(); let record = tokio::task::spawn_blocking(move || repository.get(info_hash)) .await @@ -126,6 +134,7 @@ pub(crate) async fn search( let page = tokio::task::spawn_blocking(move || { state.search.search_with(SearchOptions { query, + regex: request.regex, offset: request.offset, limit: request.limit, min_size: request.min_size, @@ -203,7 +212,14 @@ pub(crate) async fn content_variants( pub(crate) async fn torrent( State(state): State, Path(info_hash): Path, + Query(request): Query, ) -> Result, ApiError> { + if request.file_limit == 0 || request.file_limit > 200 { + return Err(ApiError::bad_request("file_limit 必须在 1 到 200 之间")); + } + if request.file_offset > 1_000_000 { + return Err(ApiError::bad_request("file_offset 不能超过 1000000")); + } let verification = state.verification.clone(); let info_hash = InfoHash::from_str(&info_hash).map_err(|error| ApiError::bad_request(error.to_string()))?; @@ -217,7 +233,11 @@ pub(crate) async fn torrent( .enqueue(vec![info_hash], VerificationPriority::High) .await; } - Ok(Json(record.into())) + Ok(Json(TorrentResponse::from_record( + record, + request.file_offset, + request.file_limit, + ))) } pub(crate) struct ApiError { diff --git a/dht-search/src/api/mod.rs b/dht-search/src/api/mod.rs index a1dda1f..49f0d8e 100644 --- a/dht-search/src/api/mod.rs +++ b/dht-search/src/api/mod.rs @@ -80,11 +80,17 @@ mod tests { info_hash: "0101010101010101010101010101010101010101".into(), magnet_link: String::new(), name: "Example Movie".into(), - total_size: 42, - files: vec![FileInfo { - path: "movie.mkv".into(), - size: 42, - }], + total_size: 205, + files: (0..205) + .map(|index| FileInfo { + path: if index == 0 { + "movie.mkv".into() + } else { + format!("extras/{index}.txt") + }, + size: 1, + }) + .collect(), piece_length: 16_384, peers: vec!["127.0.0.1:6881".into()], timestamp: 10, @@ -144,6 +150,34 @@ mod tests { assert_eq!(json["hits"][0]["variant_count"], 2); assert_eq!(repository.verification_queue_len().unwrap(), 1); + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/search?q=example.%2Amovie®ex=true") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let json: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(json["total"], 1); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/search?q=%5B®ex=true") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let response = app .clone() .oneshot( @@ -202,6 +236,7 @@ mod tests { assert_eq!(response.status(), StatusCode::BAD_REQUEST); let response = app + .clone() .oneshot( Request::builder() .uri(format!("/torrents/{}", InfoHash::from_bytes([1; 20]))) @@ -222,8 +257,47 @@ mod tests { .starts_with("magnet:?xt=") ); assert_eq!(json["files"][0]["path"], "movie.mkv"); + assert_eq!(json["files"].as_array().unwrap().len(), 100); + assert_eq!(json["file_count"], 205); + assert_eq!(json["file_offset"], 0); + assert_eq!(json["file_limit"], 100); assert_eq!(repository.verification_queue_len().unwrap(), 1); + let response = app + .clone() + .oneshot( + Request::builder() + .uri(format!( + "/torrents/{}?file_limit=0", + InfoHash::from_bytes([1; 20]) + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri(format!( + "/torrents/{}?file_offset=200&file_limit=100", + InfoHash::from_bytes([1; 20]) + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let json: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(json["files"].as_array().unwrap().len(), 5); + assert_eq!(json["file_offset"], 200); + persistence.close_and_join().await.unwrap(); } } diff --git a/dht-search/src/api/request.rs b/dht-search/src/api/request.rs index c12b0ef..85f53c0 100644 --- a/dht-search/src/api/request.rs +++ b/dht-search/src/api/request.rs @@ -15,6 +15,8 @@ pub(crate) struct SearchRequest { #[serde(default)] pub(crate) q: String, #[serde(default)] + pub(crate) regex: bool, + #[serde(default)] pub(crate) offset: usize, #[serde(default = "default_limit")] pub(crate) limit: usize, @@ -39,3 +41,15 @@ pub(crate) struct ContentVariantsRequest { #[serde(default = "default_limit")] pub(crate) limit: usize, } + +#[derive(Debug, Deserialize)] +pub(crate) struct TorrentRequest { + #[serde(default)] + pub(crate) file_offset: usize, + #[serde(default = "default_file_limit")] + pub(crate) file_limit: usize, +} + +fn default_file_limit() -> usize { + 100 +} diff --git a/dht-search/src/api/response.rs b/dht-search/src/api/response.rs index f0c0488..2fee041 100644 --- a/dht-search/src/api/response.rs +++ b/dht-search/src/api/response.rs @@ -52,6 +52,9 @@ pub(crate) struct TorrentResponse { pub(crate) magnet_link: String, pub(crate) name: String, pub(crate) total_size: u64, + pub(crate) file_count: usize, + pub(crate) file_offset: usize, + pub(crate) file_limit: usize, pub(crate) files: Vec, pub(crate) piece_length: u64, pub(crate) content_key: String, @@ -104,16 +107,31 @@ impl From for TorrentVariantResponse { } } -impl From for TorrentResponse { - fn from(record: TorrentRecord) -> Self { +impl TorrentResponse { + pub(crate) fn from_record( + record: TorrentRecord, + file_offset: usize, + file_limit: usize, + ) -> Self { let info_hash = record.info_hash.to_string(); let heat = record.heat(unix_timestamp()); + let file_count = record.files.len(); + let file_offset = file_offset.min(file_count); + let files = record + .files + .into_iter() + .skip(file_offset) + .take(file_limit) + .collect(); Self { magnet_link: format!("magnet:?xt=urn:btih:{info_hash}"), info_hash, name: record.name, total_size: record.total_size, - files: record.files, + file_count, + file_offset, + file_limit, + files, piece_length: record.piece_length, content_key: hex::encode(record.content_key), first_seen: record.first_seen, diff --git a/dht-search/src/app.rs b/dht-search/src/app.rs index 2d1be13..75ae61b 100644 --- a/dht-search/src/app.rs +++ b/dht-search/src/app.rs @@ -254,6 +254,7 @@ async fn run_indexer( ) -> Result<(), String> { let mut ticker = tokio::time::interval(interval); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut consecutive_retries = 0_u32; loop { tokio::select! { _ = cancel.cancelled() => break, @@ -261,11 +262,21 @@ async fn run_indexer( let indexed = index_one_batch(repository.clone(), search.clone(), batch_size).await; match indexed { Ok(count) => { + consecutive_retries = 0; if count > 0 { tracing::debug!(count, "搜索索引已提交"); } } - Err(error) => { + Err(IndexBatchError::Retryable(error)) => { + consecutive_retries = consecutive_retries.saturating_add(1); + let delay = index_retry_delay(consecutive_retries); + tracing::warn!(%error, retry = consecutive_retries, delay_ms = delay.as_millis(), "搜索索引遇到临时 I/O 错误"); + tokio::select! { + _ = cancel.cancelled() => break, + _ = tokio::time::sleep(delay) => {} + } + } + Err(IndexBatchError::Fatal(error)) => { let _ = fatal.send(error.clone()); return Err(error); } @@ -274,27 +285,53 @@ async fn run_indexer( } } + let mut shutdown_retries = 0_u32; loop { - let count = index_one_batch(repository.clone(), search.clone(), batch_size).await?; - if count == 0 { - break; + match index_one_batch(repository.clone(), search.clone(), batch_size).await { + Ok(0) => break, + Ok(_) => shutdown_retries = 0, + Err(IndexBatchError::Retryable(error)) if shutdown_retries < 3 => { + shutdown_retries += 1; + let delay = index_retry_delay(shutdown_retries); + tracing::warn!(%error, retry = shutdown_retries, delay_ms = delay.as_millis(), "关闭前提交搜索索引时遇到临时 I/O 错误"); + tokio::time::sleep(delay).await; + } + Err(IndexBatchError::Retryable(error)) => { + tracing::warn!(%error, "关闭前搜索索引仍被占用 待索引状态将在下次启动恢复"); + break; + } + Err(IndexBatchError::Fatal(error)) => return Err(error), } } Ok(()) } +enum IndexBatchError { + Retryable(String), + Fatal(String), +} + async fn index_one_batch( repository: Arc, search: SearchEngine, batch_size: usize, -) -> Result { +) -> Result { tokio::task::spawn_blocking(move || { - search - .index_pending(repository.as_ref(), batch_size, unix_timestamp()) - .map_err(|error| error.to_string()) + match search.index_pending(repository.as_ref(), batch_size, unix_timestamp()) { + Ok(count) => Ok(count), + Err(error) if error.is_retryable_io() => { + Err(IndexBatchError::Retryable(error.to_string())) + } + Err(error) => Err(IndexBatchError::Fatal(error.to_string())), + } }) .await - .map_err(|error| error.to_string())? + .map_err(|error| IndexBatchError::Fatal(error.to_string()))? +} + +fn index_retry_delay(attempt: u32) -> Duration { + let shift = attempt.saturating_sub(1).min(6); + Duration::from_millis((250_u64 << shift).min(10_000)) } async fn monitor( diff --git a/dht-search/src/search/indexer.rs b/dht-search/src/search/indexer.rs index 8542d60..2c45f4e 100644 --- a/dht-search/src/search/indexer.rs +++ b/dht-search/src/search/indexer.rs @@ -12,7 +12,7 @@ use tantivy::{ Term, collector::{Count, TopDocs}, directory::MmapDirectory, - query::{AllQuery, BooleanQuery, BoostQuery, Occur, Query, RangeQuery, TermQuery}, + query::{AllQuery, BooleanQuery, BoostQuery, Occur, Query, RangeQuery, RegexQuery, TermQuery}, schema::{IndexRecordOption, Value}, tokenizer::{LowerCaser, NgramTokenizer, TextAnalyzer}, }; @@ -164,6 +164,8 @@ impl SearchEngine { let query_text = options.query.trim(); if query_text.is_empty() || query_text == "*" { clauses.push(Box::new(AllQuery)); + } else if options.regex { + clauses.push(regex_query(query_text, fields)?); } else { clauses.push(text_query(query_text, fields)); } @@ -351,9 +353,12 @@ fn document(group: &ContentGroup, fields: SearchFields) -> TantivyDocument { let mut document = TantivyDocument::default(); document.add_text(fields.info_hash, record.info_hash.to_string()); document.add_text(fields.name, normalize_bounded(&record.name, 512)); + document.add_text(fields.regex_text, normalize_bounded(&record.name, 512)); document.add_text(fields.display_name, &record.name); for alias in &group.aliases { - document.add_text(fields.aliases, normalize_bounded(alias, 512)); + let alias = normalize_bounded(alias, 512); + document.add_text(fields.aliases, &alias); + document.add_text(fields.regex_text, alias); } let mut indexed_path_bytes = 0_usize; for file in record.files.iter().take(MAX_INDEXED_FILES) { @@ -362,7 +367,8 @@ fn document(group: &ContentGroup, fields: SearchFields) -> TantivyDocument { break; } indexed_path_bytes += path.len(); - document.add_text(fields.files_text, path); + document.add_text(fields.files_text, &path); + document.add_text(fields.regex_text, path); } for extension in extensions(record) { document.add_text(fields.extensions, extension); @@ -390,6 +396,31 @@ fn document(group: &ContentGroup, fields: SearchFields) -> TantivyDocument { document } +fn regex_query(pattern: &str, fields: SearchFields) -> Result, SearchError> { + let mut pattern = pattern.to_lowercase(); + let anchored_start = pattern.starts_with('^'); + if anchored_start { + pattern.remove(0); + } + let anchored_end = pattern.ends_with('$') + && pattern[..pattern.len() - 1] + .chars() + .rev() + .take_while(|character| *character == '\\') + .count() + .is_multiple_of(2); + if anchored_end { + pattern.pop(); + } + let prefix = if anchored_start { "" } else { ".*" }; + let suffix = if anchored_end { "" } else { ".*" }; + let contains_pattern = format!("{prefix}({pattern}){suffix}"); + Ok(Box::new(RegexQuery::from_pattern( + &contains_pattern, + fields.regex_text, + )?)) +} + fn text_query(query: &str, fields: SearchFields) -> Box { let terms = query_terms(query); if terms.is_empty() { @@ -661,6 +692,46 @@ mod tests { assert_eq!(engine.search("1080p", 0, 10).unwrap().total, 1); } + #[test] + fn regex_matches_names_and_file_paths_case_insensitively() { + let directory = TempDir::new().unwrap(); + let engine = SearchEngine::open(directory.path()).unwrap(); + index_records(&engine, &[record()]); + + let name = engine + .search_with(SearchOptions { + query: r"ubuntu\s+linux\s+24\.0[0-9]".into(), + regex: true, + limit: 10, + ..SearchOptions::default() + }) + .unwrap(); + assert_eq!(name.total, 1); + let path = engine + .search_with(SearchOptions { + query: r"ubuntu\.(iso|img)$".into(), + regex: true, + limit: 10, + ..SearchOptions::default() + }) + .unwrap(); + assert_eq!(path.total, 1); + } + + #[test] + fn invalid_regex_is_rejected() { + let directory = TempDir::new().unwrap(); + let engine = SearchEngine::open(directory.path()).unwrap(); + index_records(&engine, &[record()]); + let result = engine.search_with(SearchOptions { + query: "[".into(), + regex: true, + limit: 10, + ..SearchOptions::default() + }); + assert!(result.is_err()); + } + #[test] fn equal_content_is_collapsed_and_aliases_remain_searchable() { let directory = TempDir::new().unwrap(); diff --git a/dht-search/src/search/mod.rs b/dht-search/src/search/mod.rs index 56bcfdd..c158722 100644 --- a/dht-search/src/search/mod.rs +++ b/dht-search/src/search/mod.rs @@ -17,6 +17,22 @@ pub enum SearchError { MissingField(&'static str), } +impl SearchError { + pub fn is_retryable_io(&self) -> bool { + matches!( + self, + Self::Tantivy(tantivy::TantivyError::IoError(error)) + if matches!( + error.kind(), + std::io::ErrorKind::PermissionDenied + | std::io::ErrorKind::WouldBlock + | std::io::ErrorKind::Interrupted + | std::io::ErrorKind::TimedOut + ) + ) + } +} + #[derive(Debug, thiserror::Error)] pub enum IndexingError { #[error(transparent)] @@ -24,3 +40,32 @@ pub enum IndexingError { #[error(transparent)] Storage(#[from] crate::storage::StorageError), } + +impl IndexingError { + pub fn is_retryable_io(&self) -> bool { + matches!(self, Self::Search(error) if error.is_retryable_io()) + } +} + +#[cfg(test)] +mod tests { + use std::{io, sync::Arc}; + + use super::{IndexingError, SearchError}; + + #[test] + fn permission_denied_index_error_is_retryable() { + let error = IndexingError::Search(SearchError::Tantivy(tantivy::TantivyError::IoError( + Arc::new(io::Error::from(io::ErrorKind::PermissionDenied)), + ))); + assert!(error.is_retryable_io()); + } + + #[test] + fn invalid_query_is_not_retryable() { + let error = IndexingError::Search(SearchError::Tantivy( + tantivy::TantivyError::InvalidArgument("invalid".into()), + )); + assert!(!error.is_retryable_io()); + } +} diff --git a/dht-search/src/search/query.rs b/dht-search/src/search/query.rs index 7ba750a..3f95b76 100644 --- a/dht-search/src/search/query.rs +++ b/dht-search/src/search/query.rs @@ -20,6 +20,7 @@ pub enum SearchSort { #[derive(Debug, Clone, Default)] pub struct SearchOptions { pub query: String, + pub regex: bool, pub offset: usize, pub limit: usize, pub min_size: Option, diff --git a/dht-search/src/search/schema.rs b/dht-search/src/search/schema.rs index a935925..f3f340e 100644 --- a/dht-search/src/search/schema.rs +++ b/dht-search/src/search/schema.rs @@ -13,6 +13,7 @@ pub(crate) struct SearchFields { pub(crate) display_name: Field, pub(crate) aliases: Field, pub(crate) files_text: Field, + pub(crate) regex_text: Field, pub(crate) extensions: Field, pub(crate) total_size: Field, pub(crate) file_count: Field, @@ -39,6 +40,7 @@ pub(crate) fn build_schema() -> (Schema, SearchFields) { let display_name = builder.add_text_field("display_name", STORED); let aliases = builder.add_text_field("aliases", indexed_text.clone()); let files_text = builder.add_text_field("files_text", indexed_text); + let regex_text = builder.add_text_field("regex_text", STRING); let extensions = builder.add_text_field("extensions", STRING); let total_size = builder.add_u64_field("total_size", FAST | STORED); let file_count = builder.add_u64_field("file_count", FAST | STORED); @@ -60,6 +62,7 @@ pub(crate) fn build_schema() -> (Schema, SearchFields) { display_name, aliases, files_text, + regex_text, extensions, total_size, file_count, diff --git a/scripts/run.bat b/scripts/run.bat index e07ace0..ec19677 100644 --- a/scripts/run.bat +++ b/scripts/run.bat @@ -53,14 +53,23 @@ echo. powershell -NoProfile -ExecutionPolicy Bypass -Command ^ "$backend = $null; $web = $null;" ^ + "function Stop-Tree($process) {" ^ + " if ($null -ne $process) {" ^ + " $process.Refresh();" ^ + " if (-not $process.HasExited) { Start-Process -FilePath 'taskkill.exe' -ArgumentList @('/PID', [string]$process.Id, '/T', '/F') -NoNewWindow -Wait | Out-Null }" ^ + " }" ^ + "}" ^ "try {" ^ " $backend = Start-Process -FilePath 'cargo' -ArgumentList @('run','-p','dht-search','--','--config','dht-search.example.toml') -WorkingDirectory $env:PROJECT_ROOT -NoNewWindow -PassThru;" ^ - " $web = Start-Process -FilePath 'bun' -ArgumentList @('run','dev','--','--host','127.0.0.1') -WorkingDirectory $env:WEB_DIR -NoNewWindow -PassThru;" ^ + " $web = Start-Process -FilePath 'bun' -ArgumentList @('--bun','run','dev','--','--host','127.0.0.1') -WorkingDirectory $env:WEB_DIR -NoNewWindow -PassThru;" ^ " while (-not $backend.HasExited -and -not $web.HasExited) { Start-Sleep -Milliseconds 250 }" ^ "} finally {" ^ - " foreach ($process in @($backend, $web)) {" ^ - " if ($null -ne $process -and -not $process.HasExited) { Stop-Process -Id $process.Id }" ^ + " for ($i = 0; $i -lt 20; $i++) {" ^ + " if (($null -eq $backend -or $backend.HasExited) -and ($null -eq $web -or $web.HasExited)) { break }" ^ + " Start-Sleep -Milliseconds 250" ^ " }" ^ + " Stop-Tree $backend;" ^ + " Stop-Tree $web;" ^ "}" endlocal diff --git a/web/README.md b/web/README.md index c1f7d0c..7d9df6d 100644 --- a/web/README.md +++ b/web/README.md @@ -50,13 +50,16 @@ Axum 根据配置中的 `http.web_dir` 提供静态资源和单页回退 不需 ## 已实现功能 - 关键词 文件名片段和精确 infohash 搜索 -- 大小 文件数 扩展名 时间 热度和可用性筛选 +- 普通关键词和名称 文件路径正则搜索 - 相关度 时间 热度 大小和发现次数排序 - 有上限的结果分页和 URL 查询恢复 +- 基于 reka-ui 的搜索结果数字分页和浏览器持久化每页数量选择 - 文件列表 热度 可用性和收录时间详情 +- 基于 reka-ui 的数字分页文件列表和浏览器持久化每页数量选择 - 相同内容的不同 infohash 变体展示 - 磁力链接打开和复制 - DHT 采集 索引 持久化和验证运行状态 +- 每秒自动刷新的运行状态和点击外部关闭 - 加载 空结果 接口错误 重试和移动端适配 ## 添加组件 diff --git a/web/src/App.vue b/web/src/App.vue index 48e730d..0291f14 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,23 +1,24 @@ @@ -140,7 +208,7 @@ onBeforeUnmount(() => {

DHT Search

-
+

服务运行状态

@@ -157,23 +225,25 @@ onBeforeUnmount(() => {
- - - + + +
-
正在搜索

无法完成搜索

{{ error }}

-

没有找到匹配内容

尝试其他关键词或减少筛选条件

+

没有找到匹配内容

尝试其他关键词或正则表达式

- +
+ + +
- +
diff --git a/web/src/components/SearchFilters.vue b/web/src/components/SearchFilters.vue deleted file mode 100644 index d1f3fff..0000000 --- a/web/src/components/SearchFilters.vue +++ /dev/null @@ -1,64 +0,0 @@ - - - diff --git a/web/src/components/TorrentDetailDialog.vue b/web/src/components/TorrentDetailDialog.vue index f70e126..8b73dc0 100644 --- a/web/src/components/TorrentDetailDialog.vue +++ b/web/src/components/TorrentDetailDialog.vue @@ -1,8 +1,10 @@ + + diff --git a/web/src/components/ui/pagination/index.ts b/web/src/components/ui/pagination/index.ts new file mode 100644 index 0000000..42ed68d --- /dev/null +++ b/web/src/components/ui/pagination/index.ts @@ -0,0 +1 @@ +export { default as AppPagination } from './AppPagination.vue' diff --git a/web/src/components/ui/select/AppSelect.vue b/web/src/components/ui/select/AppSelect.vue new file mode 100644 index 0000000..3aa3ea9 --- /dev/null +++ b/web/src/components/ui/select/AppSelect.vue @@ -0,0 +1,41 @@ + + + diff --git a/web/src/components/ui/select/index.ts b/web/src/components/ui/select/index.ts new file mode 100644 index 0000000..ca28cd2 --- /dev/null +++ b/web/src/components/ui/select/index.ts @@ -0,0 +1 @@ +export { default as AppSelect } from './AppSelect.vue' diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 58890f7..1656ba4 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,6 +1,5 @@ import type { ContentVariants, - SearchFilters, SearchPage, SearchSort, ServiceStats, @@ -12,22 +11,7 @@ interface SearchInput { offset: number limit: number sort: SearchSort - filters: SearchFilters -} - -function optionalNumber(params: URLSearchParams, key: string, value: string, scale = 1) { - if (value.trim() === '') return - const parsed = Number(value) - if (Number.isFinite(parsed) && parsed >= 0) params.set(key, String(Math.round(parsed * scale))) -} - -function optionalDate(params: URLSearchParams, key: string, value: string) { - if (!value) return - const timestamp = new Date(value).getTime() - if (Number.isFinite(timestamp)) { - const endOfDay = key.endsWith('_before') ? 86_399 : 0 - params.set(key, String(Math.floor(timestamp / 1000) + endOfDay)) - } + regex: boolean } async function request(path: string, signal?: AbortSignal): Promise { @@ -52,23 +36,13 @@ export function search(input: SearchInput, signal?: AbortSignal): Promise(`/search?${params}`, signal) } -export function getTorrent(infoHash: string, signal?: AbortSignal): Promise { - return request(`/torrents/${encodeURIComponent(infoHash)}`, signal) +export function getTorrent(infoHash: string, fileOffset = 0, fileLimit = 100, signal?: AbortSignal): Promise { + const params = new URLSearchParams({ file_offset: String(fileOffset), file_limit: String(fileLimit) }) + return request(`/torrents/${encodeURIComponent(infoHash)}?${params}`, signal) } export function getVariants(contentKey: string, signal?: AbortSignal): Promise { diff --git a/web/src/style.css b/web/src/style.css index df4ffd9..55900eb 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -149,6 +149,9 @@ .status-cell b { @apply text-sm font-semibold; } + .pagination-button { + @apply flex size-8 items-center justify-center rounded-md text-xs font-medium transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-40; + } dl dt { @apply text-xs text-muted-foreground; } diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 3eb17e8..3d3edba 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -60,6 +60,9 @@ export interface TorrentDetail { magnet_link: string name: string total_size: number + file_count: number + file_offset: number + file_limit: number files: TorrentFile[] piece_length: number content_key: string @@ -104,17 +107,3 @@ export interface ServiceStats { verification_succeeded: number verification_failed: number } - -export interface SearchFilters { - minSizeMiB: string - maxSizeMiB: string - extension: string - minFiles: string - maxFiles: string - firstSeenAfter: string - firstSeenBefore: string - lastSeenAfter: string - lastSeenBefore: string - availability: '' | AvailabilityStatus - heat: '' | HeatLevel -}