feat: 增强搜索详情与索引稳定性
This commit is contained in:
@@ -24,3 +24,5 @@ opencodes/ 不参与构建的参考项目
|
||||
Web 开发和构建方式见 [`web/README.md`](web/README.md)
|
||||
|
||||
开发环境可以直接运行 `scripts\run.bat` 在当前窗口同时启动 Rust 后端和 Web 前端 按一次 `Ctrl+C` 即可统一停止
|
||||
|
||||
任一服务异常退出时启动脚本会清理 Cargo Bun 及其子进程树 避免遗留 Vite 或后端进程
|
||||
|
||||
@@ -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` 优先和失败后有限递归降级
|
||||
|
||||
+16
-16
@@ -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
|
||||
|
||||
+22
-15
@@ -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 是可重建索引
|
||||
|
||||
@@ -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<ApiState>,
|
||||
Path(info_hash): Path<String>,
|
||||
Query(request): Query<TorrentRequest>,
|
||||
) -> Result<Json<TorrentResponse>, 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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<TorrentFile>,
|
||||
pub(crate) piece_length: u64,
|
||||
pub(crate) content_key: String,
|
||||
@@ -104,16 +107,31 @@ impl From<TorrentRecord> for TorrentVariantResponse {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TorrentRecord> 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,
|
||||
|
||||
+46
-9
@@ -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<RocksTorrentRepository>,
|
||||
search: SearchEngine,
|
||||
batch_size: usize,
|
||||
) -> Result<usize, String> {
|
||||
) -> Result<usize, IndexBatchError> {
|
||||
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(
|
||||
|
||||
@@ -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<Box<dyn Query>, 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<dyn Query> {
|
||||
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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u64>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+12
-3
@@ -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
|
||||
|
||||
+4
-1
@@ -50,13 +50,16 @@ Axum 根据配置中的 `http.web_dir` 提供静态资源和单页回退 不需
|
||||
## 已实现功能
|
||||
|
||||
- 关键词 文件名片段和精确 infohash 搜索
|
||||
- 大小 文件数 扩展名 时间 热度和可用性筛选
|
||||
- 普通关键词和名称 文件路径正则搜索
|
||||
- 相关度 时间 热度 大小和发现次数排序
|
||||
- 有上限的结果分页和 URL 查询恢复
|
||||
- 基于 reka-ui 的搜索结果数字分页和浏览器持久化每页数量选择
|
||||
- 文件列表 热度 可用性和收录时间详情
|
||||
- 基于 reka-ui 的数字分页文件列表和浏览器持久化每页数量选择
|
||||
- 相同内容的不同 infohash 变体展示
|
||||
- 磁力链接打开和复制
|
||||
- DHT 采集 索引 持久化和验证运行状态
|
||||
- 每秒自动刷新的运行状态和点击外部关闭
|
||||
- 加载 空结果 接口错误 重试和移动端适配
|
||||
|
||||
## 添加组件
|
||||
|
||||
+96
-26
@@ -1,23 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { Activity, ChevronLeft, ChevronRight, Database, Filter, LoaderCircle, Moon, Search, Server, Sun, X } from '@lucide/vue'
|
||||
import { Activity, Database, LoaderCircle, Moon, Search, Server, Sun, X } from '@lucide/vue'
|
||||
|
||||
import SearchFiltersPanel from '@/components/SearchFilters.vue'
|
||||
import SearchResultCard from '@/components/SearchResultCard.vue'
|
||||
import TorrentDetailDialog from '@/components/TorrentDetailDialog.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AppPagination } from '@/components/ui/pagination'
|
||||
import { AppSelect } from '@/components/ui/select'
|
||||
import { getStats, getTorrent, getVariants, search } from '@/lib/api'
|
||||
import type { SearchFilters, SearchHit, SearchPage, SearchSort, ServiceStats, TorrentDetail, ContentVariants } from '@/types/api'
|
||||
import type { SearchHit, SearchPage, SearchSort, ServiceStats, TorrentDetail, ContentVariants } from '@/types/api'
|
||||
|
||||
const emptyFilters = (): SearchFilters => ({ minSizeMiB: '', maxSizeMiB: '', extension: '', minFiles: '', maxFiles: '', firstSeenAfter: '', firstSeenBefore: '', lastSeenAfter: '', lastSeenBefore: '', availability: '', heat: '' })
|
||||
const query = ref('')
|
||||
const submittedQuery = ref('')
|
||||
const filters = ref(emptyFilters())
|
||||
const filtersOpen = ref(false)
|
||||
const regexMode = ref(false)
|
||||
const sort = ref<SearchSort>('latest')
|
||||
const page = ref<SearchPage | null>(null)
|
||||
const offset = ref(0)
|
||||
const limit = 20
|
||||
const supportedResultPageSizes = [10, 20, 50, 100]
|
||||
const savedResultPageSize = Number(localStorage.getItem('dht-search-result-page-size'))
|
||||
const limit = ref(supportedResultPageSizes.includes(savedResultPageSize) ? savedResultPageSize : 20)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const stats = ref<ServiceStats | null>(null)
|
||||
@@ -25,22 +26,44 @@ const statsOpen = ref(false)
|
||||
const darkMode = ref(false)
|
||||
const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailFilesLoading = ref(false)
|
||||
const detailFilesError = ref('')
|
||||
const detailError = ref('')
|
||||
const selectedHash = ref('')
|
||||
const detail = ref<TorrentDetail | null>(null)
|
||||
const variants = ref<ContentVariants | null>(null)
|
||||
const supportedFilePageSizes = [25, 50, 100, 200]
|
||||
const savedFilePageSize = Number(localStorage.getItem('dht-search-file-page-size'))
|
||||
const detailFilePageSize = ref(supportedFilePageSizes.includes(savedFilePageSize) ? savedFilePageSize : 100)
|
||||
let searchController: AbortController | null = null
|
||||
let detailController: AbortController | null = null
|
||||
let filesController: AbortController | null = null
|
||||
let statsTimer: number | null = null
|
||||
let statsRequestActive = false
|
||||
|
||||
const pageNumber = computed(() => Math.floor((page.value?.offset ?? 0) / limit) + 1)
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil((page.value?.total ?? 0) / limit)))
|
||||
const filterCount = computed(() => Object.values(filters.value).filter(Boolean).length)
|
||||
const pageNumber = computed(() => Math.floor((page.value?.offset ?? 0) / limit.value) + 1)
|
||||
const paginationTotal = computed(() => Math.min(page.value?.total ?? 0, 10_000 + limit.value))
|
||||
const resultPageSizeOptions = [
|
||||
{ value: '10', label: '10 条/页' },
|
||||
{ value: '20', label: '20 条/页' },
|
||||
{ value: '50', label: '50 条/页' },
|
||||
{ value: '100', label: '100 条/页' },
|
||||
]
|
||||
const sortOptions: ReadonlyArray<{ value: SearchSort; label: string }> = [
|
||||
{ value: 'relevance', label: '相关度' },
|
||||
{ value: 'latest', label: '最近收录' },
|
||||
{ value: 'oldest', label: '最早收录' },
|
||||
{ value: 'heat', label: '热度' },
|
||||
{ value: 'discoveries', label: '发现次数' },
|
||||
{ value: 'size_desc', label: '大小降序' },
|
||||
{ value: 'size_asc', label: '大小升序' },
|
||||
]
|
||||
|
||||
function updateBrowserUrl() {
|
||||
const params = new URLSearchParams()
|
||||
if (submittedQuery.value) params.set('q', submittedQuery.value)
|
||||
if (sort.value !== 'latest') params.set('sort', sort.value)
|
||||
if (regexMode.value) params.set('regex', 'true')
|
||||
if (offset.value) params.set('offset', String(offset.value))
|
||||
history.replaceState(null, '', params.size ? `?${params}` : location.pathname)
|
||||
}
|
||||
@@ -52,7 +75,7 @@ async function loadSearch() {
|
||||
error.value = ''
|
||||
updateBrowserUrl()
|
||||
try {
|
||||
page.value = await search({ query: submittedQuery.value, offset: offset.value, limit, sort: sort.value, filters: filters.value }, searchController.signal)
|
||||
page.value = await search({ query: submittedQuery.value, offset: offset.value, limit: limit.value, sort: sort.value, regex: regexMode.value }, searchController.signal)
|
||||
} catch (cause) {
|
||||
if ((cause as Error).name !== 'AbortError') error.value = cause instanceof Error ? cause.message : '搜索失败'
|
||||
} finally {
|
||||
@@ -67,9 +90,17 @@ function submitSearch() {
|
||||
offset.value = 0
|
||||
void loadSearch()
|
||||
}
|
||||
function changePage(nextOffset: number) { offset.value = Math.max(0, nextOffset); void loadSearch(); window.scrollTo({ top: 0, behavior: 'smooth' }) }
|
||||
function changeSort(event: Event) { sort.value = (event.target as HTMLSelectElement).value as SearchSort; offset.value = 0; void loadSearch() }
|
||||
function resetFilters() { filters.value = emptyFilters(); offset.value = 0; void loadSearch() }
|
||||
function changePage(pageNumber: number) { offset.value = Math.max(0, (pageNumber - 1) * limit.value); void loadSearch(); window.scrollTo({ top: 0, behavior: 'smooth' }) }
|
||||
function changeResultPageSize(value: string) {
|
||||
const pageSize = Number(value)
|
||||
if (!supportedResultPageSizes.includes(pageSize) || pageSize === limit.value) return
|
||||
limit.value = pageSize
|
||||
localStorage.setItem('dht-search-result-page-size', String(pageSize))
|
||||
offset.value = 0
|
||||
void loadSearch()
|
||||
}
|
||||
function changeSort(value: string) { sort.value = value as SearchSort; offset.value = 0; void loadSearch() }
|
||||
function toggleRegex() { regexMode.value = !regexMode.value; offset.value = 0 }
|
||||
|
||||
function toggleTheme() {
|
||||
darkMode.value = !darkMode.value
|
||||
@@ -77,17 +108,24 @@ function toggleTheme() {
|
||||
localStorage.setItem('dht-search-theme', darkMode.value ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
function closeStatsOnOutsideClick(event: PointerEvent) {
|
||||
if (statsOpen.value && event.target instanceof Element && !event.target.closest('[data-stats-panel]')) {
|
||||
statsOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(hit: SearchHit) {
|
||||
selectedHash.value = hit.info_hash
|
||||
detailOpen.value = true
|
||||
detailLoading.value = true
|
||||
detailError.value = ''
|
||||
detailFilesError.value = ''
|
||||
detail.value = null
|
||||
variants.value = null
|
||||
detailController?.abort()
|
||||
detailController = new AbortController()
|
||||
try {
|
||||
const [torrent, contentVariants] = await Promise.all([getTorrent(hit.info_hash, detailController.signal), getVariants(hit.content_key, detailController.signal)])
|
||||
const [torrent, contentVariants] = await Promise.all([getTorrent(hit.info_hash, 0, detailFilePageSize.value, detailController.signal), getVariants(hit.content_key, detailController.signal)])
|
||||
detail.value = torrent
|
||||
variants.value = contentVariants
|
||||
} catch (cause) {
|
||||
@@ -97,11 +135,37 @@ async function openDetail(hit: SearchHit) {
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetail() { detailController?.abort(); detailOpen.value = false }
|
||||
async function loadFilePage(fileOffset: number) {
|
||||
if (!detail.value || detailFilesLoading.value) return
|
||||
filesController?.abort()
|
||||
filesController = new AbortController()
|
||||
detailFilesLoading.value = true
|
||||
detailFilesError.value = ''
|
||||
const infoHash = detail.value.info_hash
|
||||
try {
|
||||
const next = await getTorrent(infoHash, fileOffset, detailFilePageSize.value, filesController.signal)
|
||||
if (selectedHash.value === infoHash) detail.value = next
|
||||
} catch (cause) {
|
||||
if ((cause as Error).name !== 'AbortError') detailFilesError.value = cause instanceof Error ? cause.message : '文件列表加载失败'
|
||||
} finally {
|
||||
if (!filesController.signal.aborted) detailFilesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function changeFilePageSize(fileLimit: number) {
|
||||
if (!supportedFilePageSizes.includes(fileLimit) || fileLimit === detailFilePageSize.value) return
|
||||
detailFilePageSize.value = fileLimit
|
||||
localStorage.setItem('dht-search-file-page-size', String(fileLimit))
|
||||
void loadFilePage(0)
|
||||
}
|
||||
|
||||
function closeDetail() { detailController?.abort(); filesController?.abort(); detailOpen.value = false }
|
||||
function retryDetail() { const hit = page.value?.hits.find((item) => item.info_hash === selectedHash.value); if (hit) void openDetail(hit) }
|
||||
|
||||
async function loadStats() {
|
||||
try { stats.value = await getStats() } catch { stats.value = null }
|
||||
if (statsRequestActive) return
|
||||
statsRequestActive = true
|
||||
try { stats.value = await getStats() } catch { stats.value = null } finally { statsRequestActive = false }
|
||||
}
|
||||
|
||||
watch(statsOpen, (open) => {
|
||||
@@ -111,15 +175,17 @@ watch(statsOpen, (open) => {
|
||||
}
|
||||
if (open) {
|
||||
void loadStats()
|
||||
statsTimer = window.setInterval(() => void loadStats(), 5_000)
|
||||
statsTimer = window.setInterval(() => void loadStats(), 1_000)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', closeStatsOnOutsideClick)
|
||||
darkMode.value = document.documentElement.classList.contains('dark')
|
||||
const params = new URLSearchParams(location.search)
|
||||
query.value = params.get('q') ?? ''
|
||||
submittedQuery.value = query.value
|
||||
regexMode.value = params.get('regex') === 'true'
|
||||
const initialSort = params.get('sort') as SearchSort | null
|
||||
if (initialSort && ['relevance', 'latest', 'oldest', 'heat', 'size_desc', 'size_asc', 'discoveries'].includes(initialSort)) sort.value = initialSort
|
||||
else if (query.value.trim()) sort.value = 'relevance'
|
||||
@@ -127,8 +193,10 @@ onMounted(() => {
|
||||
void loadSearch()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', closeStatsOnOutsideClick)
|
||||
searchController?.abort()
|
||||
detailController?.abort()
|
||||
filesController?.abort()
|
||||
if (statsTimer !== null) window.clearInterval(statsTimer)
|
||||
})
|
||||
</script>
|
||||
@@ -140,7 +208,7 @@ onBeforeUnmount(() => {
|
||||
<div class="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground"><Database class="size-4" aria-hidden="true" /></div>
|
||||
<p class="text-sm font-semibold tracking-tight">DHT Search</p>
|
||||
<Button class="ml-auto" size="icon" variant="ghost" :aria-label="darkMode ? '切换到浅色主题' : '切换到深色主题'" :title="darkMode ? '浅色主题' : '深色主题'" @click="toggleTheme"><Sun v-if="darkMode" /><Moon v-else /></Button>
|
||||
<div class="relative">
|
||||
<div class="relative" data-stats-panel>
|
||||
<Button size="icon" variant="ghost" aria-label="运行状态" title="运行状态" @click="statsOpen = !statsOpen"><span class="relative"><Activity class="size-4" /><i class="absolute -right-0.5 -top-0.5 size-1.5 rounded-full" :class="stats ? 'bg-emerald-500' : 'bg-muted-foreground'" /></span></Button>
|
||||
<div v-if="statsOpen" class="absolute right-0 top-11 w-72 rounded-xl border bg-popover p-4 text-popover-foreground shadow-xl">
|
||||
<div class="mb-3 flex items-center"><p class="text-sm font-semibold">服务运行状态</p><button class="ml-auto" aria-label="关闭状态面板" @click="statsOpen = false"><X class="size-4" /></button></div>
|
||||
@@ -157,23 +225,25 @@ onBeforeUnmount(() => {
|
||||
<div class="flex flex-wrap items-center gap-2 p-2">
|
||||
<Search class="ml-2.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<label class="sr-only" for="search-query">搜索关键词</label>
|
||||
<input id="search-query" v-model="query" class="h-9 min-w-48 flex-1 bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground" maxlength="512" placeholder="输入名称、文件名或 40 位 infohash" type="search" />
|
||||
<Button size="sm" type="button" :variant="filtersOpen ? 'secondary' : 'ghost'" @click="filtersOpen = !filtersOpen"><Filter />筛选<span v-if="filterCount" class="rounded-full bg-primary px-1.5 text-[10px] text-primary-foreground">{{ filterCount }}</span></Button>
|
||||
<select aria-label="结果排序" class="field-input h-9 w-auto py-0" :value="sort" @change="changeSort"><option value="relevance">相关度</option><option value="latest">最近收录</option><option value="oldest">最早收录</option><option value="heat">热度</option><option value="discoveries">发现次数</option><option value="size_desc">大小降序</option><option value="size_asc">大小升序</option></select>
|
||||
<input id="search-query" v-model="query" class="h-9 min-w-48 flex-1 bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground" :maxlength="regexMode ? 256 : 512" :placeholder="regexMode ? '输入正则表达式' : '输入名称、文件名或 40 位 infohash'" type="search" />
|
||||
<Button size="sm" type="button" :variant="regexMode ? 'secondary' : 'ghost'" :aria-pressed="regexMode" title="正则搜索" @click="toggleRegex"><span class="font-mono">.*</span><span class="hidden sm:inline">正则</span></Button>
|
||||
<AppSelect :model-value="sort" :options="sortOptions" label="结果排序" @update:model-value="changeSort" />
|
||||
<Button type="submit">搜索</Button>
|
||||
</div>
|
||||
<SearchFiltersPanel v-if="filtersOpen" v-model="filters" @reset="resetFilters" />
|
||||
</form>
|
||||
|
||||
<div v-if="loading" class="flex min-h-72 items-center justify-center text-sm text-muted-foreground"><LoaderCircle class="mr-2 size-5 animate-spin" />正在搜索</div>
|
||||
<div v-else-if="error" class="mt-4 flex min-h-72 flex-col items-center justify-center gap-4 rounded-xl border border-dashed text-center"><Server class="size-8 text-muted-foreground" /><div><p class="font-medium">无法完成搜索</p><p class="mt-1 text-sm text-muted-foreground">{{ error }}</p></div><Button variant="outline" @click="loadSearch">重试</Button></div>
|
||||
<div v-else-if="page?.hits.length" class="mt-4 space-y-3"><SearchResultCard v-for="hit in page.hits" :key="hit.content_key" :hit="hit" @select="openDetail" /></div>
|
||||
<div v-else class="mt-4 flex min-h-72 flex-col items-center justify-center rounded-xl border border-dashed text-center"><Search class="mb-3 size-8 text-muted-foreground" /><p class="font-medium">没有找到匹配内容</p><p class="mt-1 text-sm text-muted-foreground">尝试其他关键词或减少筛选条件</p></div>
|
||||
<div v-else class="mt-4 flex min-h-72 flex-col items-center justify-center rounded-xl border border-dashed text-center"><Search class="mb-3 size-8 text-muted-foreground" /><p class="font-medium">没有找到匹配内容</p><p class="mt-1 text-sm text-muted-foreground">尝试其他关键词或正则表达式</p></div>
|
||||
|
||||
<nav v-if="page && page.total > limit" aria-label="搜索结果分页" class="mt-6 flex items-center justify-center gap-3"><Button size="sm" variant="outline" :disabled="offset === 0" @click="changePage(offset - limit)"><ChevronLeft />上一页</Button><span class="min-w-24 text-center text-xs text-muted-foreground">第 {{ pageNumber }} / {{ pageCount }} 页</span><Button size="sm" variant="outline" :disabled="offset + limit >= page.total || offset + limit > 10_000" @click="changePage(offset + limit)">下一页<ChevronRight /></Button></nav>
|
||||
<div v-if="page && page.total > 0" class="mt-6 flex flex-wrap items-center justify-center gap-2">
|
||||
<AppSelect :model-value="String(limit)" :options="resultPageSizeOptions" label="每页搜索结果数量" @update:model-value="changeResultPageSize" />
|
||||
<AppPagination v-if="paginationTotal > limit" :page="pageNumber" :page-size="limit" :total="paginationTotal" @update:page="changePage" />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<TorrentDetailDialog :detail="detail" :error="detailError" :loading="detailLoading" :open="detailOpen" :variants="variants" @close="closeDetail" @retry="retryDetail" />
|
||||
<TorrentDetailDialog :detail="detail" :error="detailError" :files-error="detailFilesError" :files-loading="detailFilesLoading" :loading="detailLoading" :open="detailOpen" :variants="variants" @close="closeDetail" @file-page="loadFilePage" @file-page-size="changeFilePageSize" @retry="retryDetail" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { RotateCcw } from '@lucide/vue'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { SearchFilters } from '@/types/api'
|
||||
|
||||
const props = defineProps<{ modelValue: SearchFilters }>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: SearchFilters]
|
||||
reset: []
|
||||
}>()
|
||||
|
||||
function update<K extends keyof SearchFilters>(key: K, value: SearchFilters[K]) {
|
||||
emit('update:modelValue', { ...props.modelValue, [key]: value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-5 border-t px-5 py-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<label class="field-label">
|
||||
<span>大小范围 MiB</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<input class="field-input" min="0" placeholder="最小" type="number" :value="modelValue.minSizeMiB" @input="update('minSizeMiB', ($event.target as HTMLInputElement).value)" />
|
||||
<span class="text-muted-foreground">—</span>
|
||||
<input class="field-input" min="0" placeholder="最大" type="number" :value="modelValue.maxSizeMiB" @input="update('maxSizeMiB', ($event.target as HTMLInputElement).value)" />
|
||||
</span>
|
||||
</label>
|
||||
<label class="field-label">
|
||||
<span>文件数量</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<input class="field-input" min="0" placeholder="最少" type="number" :value="modelValue.minFiles" @input="update('minFiles', ($event.target as HTMLInputElement).value)" />
|
||||
<span class="text-muted-foreground">—</span>
|
||||
<input class="field-input" min="0" placeholder="最多" type="number" :value="modelValue.maxFiles" @input="update('maxFiles', ($event.target as HTMLInputElement).value)" />
|
||||
</span>
|
||||
</label>
|
||||
<label class="field-label">
|
||||
<span>文件扩展名</span>
|
||||
<input class="field-input" placeholder="例如 mkv" :value="modelValue.extension" @input="update('extension', ($event.target as HTMLInputElement).value)" />
|
||||
</label>
|
||||
<label class="field-label">
|
||||
<span>可用性</span>
|
||||
<select class="field-input" :value="modelValue.availability" @change="update('availability', ($event.target as HTMLSelectElement).value as SearchFilters['availability'])">
|
||||
<option value="">全部</option><option value="active">可用</option><option value="unknown">未验证</option><option value="possibly_stale">可能失效</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field-label">
|
||||
<span>热度</span>
|
||||
<select class="field-input" :value="modelValue.heat" @change="update('heat', ($event.target as HTMLSelectElement).value as SearchFilters['heat'])">
|
||||
<option value="">全部</option><option value="hot">热门</option><option value="active">活跃</option><option value="normal">一般</option><option value="cold">冷门</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field-label">
|
||||
<span>首次收录起止</span>
|
||||
<span class="flex gap-2"><input class="field-input min-w-0" type="date" :value="modelValue.firstSeenAfter" @input="update('firstSeenAfter', ($event.target as HTMLInputElement).value)" /><input class="field-input min-w-0" type="date" :value="modelValue.firstSeenBefore" @input="update('firstSeenBefore', ($event.target as HTMLInputElement).value)" /></span>
|
||||
</label>
|
||||
<label class="field-label">
|
||||
<span>最近发现起止</span>
|
||||
<span class="flex gap-2"><input class="field-input min-w-0" type="date" :value="modelValue.lastSeenAfter" @input="update('lastSeenAfter', ($event.target as HTMLInputElement).value)" /><input class="field-input min-w-0" type="date" :value="modelValue.lastSeenBefore" @input="update('lastSeenBefore', ($event.target as HTMLInputElement).value)" /></span>
|
||||
</label>
|
||||
<div class="flex items-end">
|
||||
<Button class="w-full" size="sm" type="button" variant="outline" @click="$emit('reset')"><RotateCcw />重置筛选</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { Check, Copy, Download, File, LoaderCircle, X } from '@lucide/vue'
|
||||
import { Check, Copy, Download, LoaderCircle, X } from '@lucide/vue'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AppPagination } from '@/components/ui/pagination'
|
||||
import { AppSelect } from '@/components/ui/select'
|
||||
import { availabilityLabel, formatBytes, formatDate, heatLabel } from '@/lib/format'
|
||||
import type { ContentVariants, TorrentDetail } from '@/types/api'
|
||||
|
||||
@@ -11,11 +13,30 @@ const props = defineProps<{
|
||||
detail: TorrentDetail | null
|
||||
variants: ContentVariants | null
|
||||
loading: boolean
|
||||
filesLoading: boolean
|
||||
filesError: string
|
||||
error: string
|
||||
}>()
|
||||
const emit = defineEmits<{ close: []; retry: [] }>()
|
||||
const emit = defineEmits<{ close: []; retry: []; 'file-page': [offset: number]; 'file-page-size': [size: number] }>()
|
||||
const copied = ref('')
|
||||
const totalFileSize = computed(() => props.detail?.files.reduce((sum, file) => sum + file.size, 0) ?? 0)
|
||||
const filePage = computed(() => props.detail ? Math.floor(props.detail.file_offset / props.detail.file_limit) + 1 : 1)
|
||||
const filePageSizeOptions = [
|
||||
{ value: '25', label: '25 条/页' },
|
||||
{ value: '50', label: '50 条/页' },
|
||||
{ value: '100', label: '100 条/页' },
|
||||
{ value: '200', label: '200 条/页' },
|
||||
]
|
||||
|
||||
function fileName(path: string) {
|
||||
const normalized = path.replaceAll('\\', '/')
|
||||
return normalized.slice(normalized.lastIndexOf('/') + 1) || normalized
|
||||
}
|
||||
|
||||
function fileDirectory(path: string) {
|
||||
const normalized = path.replaceAll('\\', '/')
|
||||
const separator = normalized.lastIndexOf('/')
|
||||
return separator > 0 ? normalized.slice(0, separator) : '根目录'
|
||||
}
|
||||
|
||||
async function copy(value: string, type: string) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
@@ -34,6 +55,14 @@ async function copy(value: string, type: string) {
|
||||
window.setTimeout(() => { if (copied.value === type) copied.value = '' }, 1600)
|
||||
}
|
||||
|
||||
function changeFilePage(page: number) {
|
||||
if (props.detail) emit('file-page', (page - 1) * props.detail.file_limit)
|
||||
}
|
||||
|
||||
function changeFilePageSize(value: string) {
|
||||
emit('file-page-size', Number(value))
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && props.open) emit('close')
|
||||
}
|
||||
@@ -61,14 +90,14 @@ onBeforeUnmount(() => {
|
||||
<div v-else-if="error" class="flex flex-1 flex-col items-center justify-center gap-4 px-6 text-center"><p class="text-sm text-destructive">{{ error }}</p><Button variant="outline" @click="$emit('retry')">重新加载</Button></div>
|
||||
<div v-else-if="detail" class="flex-1 overflow-y-auto">
|
||||
<div class="space-y-5 border-b p-5 sm:p-7">
|
||||
<div><h2 class="break-words text-xl font-semibold tracking-tight">{{ detail.name }}</h2><p class="mt-2 break-all font-mono text-[11px] leading-5 text-muted-foreground">{{ detail.info_hash }}</p></div>
|
||||
<div><h2 class="break-words text-xl font-semibold tracking-tight">{{ detail.name }}</h2><button class="mt-2 flex max-w-full items-center gap-1.5 text-left font-mono text-[11px] leading-5 text-muted-foreground hover:text-foreground" title="复制 infohash" @click="copy(detail.info_hash, 'hash')"><span class="truncate">{{ detail.info_hash }}</span><Check v-if="copied === 'hash'" class="size-3 shrink-0 text-emerald-600" /><Copy v-else class="size-3 shrink-0" /></button></div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button as-child><a :href="detail.magnet_link"><Download />打开磁力链接</a></Button>
|
||||
<Button variant="outline" @click="copy(detail.magnet_link, 'magnet')"><Check v-if="copied === 'magnet'" /><Copy v-else />{{ copied === 'magnet' ? '已复制' : '复制磁力链接' }}</Button>
|
||||
</div>
|
||||
<dl class="grid grid-cols-2 gap-x-5 gap-y-4 rounded-xl bg-muted/55 p-4 text-sm sm:grid-cols-3">
|
||||
<div><dt>总大小</dt><dd>{{ formatBytes(detail.total_size) }}</dd></div>
|
||||
<div><dt>文件数量</dt><dd>{{ detail.files.length.toLocaleString() }}</dd></div>
|
||||
<div><dt>文件数量</dt><dd>{{ detail.file_count.toLocaleString() }}</dd></div>
|
||||
<div><dt>分片大小</dt><dd>{{ formatBytes(detail.piece_length) }}</dd></div>
|
||||
<div><dt>热度</dt><dd>{{ heatLabel[detail.heat.level] }} · {{ detail.heat.score }}</dd></div>
|
||||
<div><dt>可用性</dt><dd>{{ availabilityLabel[detail.availability.status] }}<template v-if="detail.availability.reachable_peers"> · {{ detail.availability.reachable_peers }} Peer</template></dd></div>
|
||||
@@ -80,9 +109,24 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="border-b p-5 sm:p-7">
|
||||
<div class="mb-3 flex items-center"><h3 class="font-semibold">文件详情</h3><span class="ml-auto text-xs text-muted-foreground">{{ formatBytes(totalFileSize) }}</span></div>
|
||||
<div class="divide-y rounded-xl border">
|
||||
<div v-for="file in detail.files" :key="`${file.path}-${file.size}`" class="flex items-center gap-3 px-4 py-3 text-sm"><File class="size-4 shrink-0 text-muted-foreground" /><span class="min-w-0 flex-1 break-all">{{ file.path }}</span><span class="shrink-0 text-xs text-muted-foreground">{{ formatBytes(file.size) }}</span></div>
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<div><h3 class="font-semibold">文件详情</h3><p class="mt-1 text-xs text-muted-foreground">共 {{ detail.file_count.toLocaleString() }} 个文件</p></div>
|
||||
<div class="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||||
<AppSelect :model-value="String(detail.file_limit)" :options="filePageSizeOptions" label="每页文件数量" @update:model-value="changeFilePageSize" />
|
||||
<AppPagination v-if="detail.file_count > detail.file_limit" :disabled="filesLoading" :page="filePage" :page-size="detail.file_limit" :total="detail.file_count" @update:page="changeFilePage" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative overflow-hidden rounded-xl border bg-card">
|
||||
<div v-if="filesLoading" class="absolute inset-0 z-10 flex items-center justify-center bg-background/75 backdrop-blur-sm"><LoaderCircle class="size-5 animate-spin text-muted-foreground" /></div>
|
||||
<div v-if="filesError" class="border-b bg-destructive/10 px-4 py-2 text-xs text-destructive">{{ filesError }}</div>
|
||||
<div class="divide-y">
|
||||
<div v-for="(file, index) in detail.files" :key="`${detail.file_offset}-${file.path}-${file.size}`" class="group flex items-center gap-3 px-3 py-2 transition-colors hover:bg-muted/45 sm:px-4">
|
||||
<span class="w-7 shrink-0 text-left font-mono text-[10px] tabular-nums text-muted-foreground/70">{{ detail.file_offset + index + 1 }}</span>
|
||||
<div class="min-w-0 flex-1"><p class="truncate text-sm font-medium leading-4" :title="file.path">{{ fileName(file.path) }}</p><p class="truncate text-[10px] leading-4 text-muted-foreground" :title="fileDirectory(file.path)">{{ fileDirectory(file.path) }}</p></div>
|
||||
<span class="w-20 shrink-0 text-right text-xs tabular-nums text-muted-foreground">{{ formatBytes(file.size) }}</span>
|
||||
</div>
|
||||
<p v-if="!detail.files.length" class="px-4 py-8 text-center text-sm text-muted-foreground">没有文件信息</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from '@lucide/vue'
|
||||
import {
|
||||
PaginationEllipsis,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
PaginationNext,
|
||||
PaginationPrev,
|
||||
PaginationRoot,
|
||||
} from 'reka-ui'
|
||||
|
||||
defineProps<{
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:page': [page: number] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationRoot :page="page" :items-per-page="pageSize" :total="total" :disabled="disabled" :sibling-count="1" show-edges @update:page="emit('update:page', $event)">
|
||||
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
|
||||
<PaginationPrev class="pagination-button" title="上一页"><ChevronLeft class="size-4" /></PaginationPrev>
|
||||
<template v-for="(item, index) in items" :key="item.type === 'page' ? item.value : `ellipsis-${index}`">
|
||||
<PaginationListItem v-if="item.type === 'page'" class="pagination-button data-[selected]:bg-primary data-[selected]:text-primary-foreground" :value="item.value">{{ item.value }}</PaginationListItem>
|
||||
<PaginationEllipsis v-else class="flex size-8 items-center justify-center text-muted-foreground"><MoreHorizontal class="size-4" /></PaginationEllipsis>
|
||||
</template>
|
||||
<PaginationNext class="pagination-button" title="下一页"><ChevronRight class="size-4" /></PaginationNext>
|
||||
</PaginationList>
|
||||
</PaginationRoot>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AppPagination } from './AppPagination.vue'
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronDown } from '@lucide/vue'
|
||||
import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
SelectPortal,
|
||||
SelectRoot,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectViewport,
|
||||
} from 'reka-ui'
|
||||
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
options: ReadonlyArray<{ value: string; label: string }>
|
||||
label: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectRoot :model-value="modelValue" @update:model-value="(value) => typeof value === 'string' && emit('update:modelValue', value)">
|
||||
<SelectTrigger :aria-label="label" class="flex h-9 min-w-28 items-center justify-between gap-2 rounded-md border bg-background px-3 text-sm outline-none transition hover:bg-accent focus:ring-3 focus:ring-ring/20 data-[placeholder]:text-muted-foreground">
|
||||
<SelectValue />
|
||||
<ChevronDown class="size-4 text-muted-foreground" />
|
||||
</SelectTrigger>
|
||||
<SelectPortal>
|
||||
<SelectContent class="z-50 min-w-[var(--reka-select-trigger-width)] overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-lg" position="popper" :side-offset="5">
|
||||
<SelectViewport class="p-1">
|
||||
<SelectItem v-for="option in options" :key="option.value" :value="option.value" class="relative flex cursor-default select-none items-center rounded-md py-2 pl-8 pr-3 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50">
|
||||
<SelectItemIndicator class="absolute left-2 flex size-4 items-center justify-center"><Check class="size-4" /></SelectItemIndicator>
|
||||
<SelectItemText>{{ option.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectViewport>
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</SelectRoot>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AppSelect } from './AppSelect.vue'
|
||||
+5
-31
@@ -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<T>(path: string, signal?: AbortSignal): Promise<T> {
|
||||
@@ -52,23 +36,13 @@ export function search(input: SearchInput, signal?: AbortSignal): Promise<Search
|
||||
limit: String(input.limit),
|
||||
sort: input.sort,
|
||||
})
|
||||
const { filters } = input
|
||||
optionalNumber(params, 'min_size', filters.minSizeMiB, 1024 * 1024)
|
||||
optionalNumber(params, 'max_size', filters.maxSizeMiB, 1024 * 1024)
|
||||
optionalNumber(params, 'min_files', filters.minFiles)
|
||||
optionalNumber(params, 'max_files', filters.maxFiles)
|
||||
optionalDate(params, 'first_seen_after', filters.firstSeenAfter)
|
||||
optionalDate(params, 'first_seen_before', filters.firstSeenBefore)
|
||||
optionalDate(params, 'last_seen_after', filters.lastSeenAfter)
|
||||
optionalDate(params, 'last_seen_before', filters.lastSeenBefore)
|
||||
if (filters.extension.trim()) params.set('extension', filters.extension.trim().replace(/^\./, ''))
|
||||
if (filters.availability) params.set('availability', filters.availability)
|
||||
if (filters.heat) params.set('heat', filters.heat)
|
||||
if (input.regex) params.set('regex', 'true')
|
||||
return request<SearchPage>(`/search?${params}`, signal)
|
||||
}
|
||||
|
||||
export function getTorrent(infoHash: string, signal?: AbortSignal): Promise<TorrentDetail> {
|
||||
return request<TorrentDetail>(`/torrents/${encodeURIComponent(infoHash)}`, signal)
|
||||
export function getTorrent(infoHash: string, fileOffset = 0, fileLimit = 100, signal?: AbortSignal): Promise<TorrentDetail> {
|
||||
const params = new URLSearchParams({ file_offset: String(fileOffset), file_limit: String(fileLimit) })
|
||||
return request<TorrentDetail>(`/torrents/${encodeURIComponent(infoHash)}?${params}`, signal)
|
||||
}
|
||||
|
||||
export function getVariants(contentKey: string, signal?: AbortSignal): Promise<ContentVariants> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+3
-14
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user