feat: DSL 磁力聚合 — 搜索/解析/预览/投递 + cilisousuo 源 + 中英双语 + Docker 部署

This commit is contained in:
ice-a
2026-10-16 18:53:45 +08:00
commit d6999104bf
64 changed files with 16524 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules
.output
.nuxt
.data
.env
.git
.gitignore
*.log
npm-debug.log*
+20
View File
@@ -0,0 +1,20 @@
# MongoDB 连接(留空则回落到本地默认,配置/历史不持久化)
MONGODB_URI=mongodb://127.0.0.1:27017
MONGODB_DB=torrent_console
# 访问搜索源时使用的 HTTP 代理(国内网络连不上公开磁力站时配这里)
# 例如:http://127.0.0.1:7890
SEARCH_PROXY=
# 超时(毫秒)
SEARCH_TIMEOUT=12000
METADATA_TIMEOUT=30000
# 各搜索源基地址(一般无需修改)
APIBAY_BASE=https://apibay.org
NYAA_BASE=https://nyaa.si
CILISOUSUO_BASE=https://cilisousuo.cc
# 生产运行监听(仅直接运行 .output 时需要)
HOST=0.0.0.0
PORT=3000
+9
View File
@@ -0,0 +1,9 @@
node_modules
.nuxt
.output
.data
dist
.env
.env.local
*.log
.DS_Store
+31
View File
@@ -0,0 +1,31 @@
# 语法:docker/docker-buildx 多阶段构建
FROM node:20-alpine AS builder
WORKDIR /app
# 仅复制依赖清单,利用层缓存
COPY package*.json ./
RUN npm ci
# 复制源码并构建 Nitro 产物到 .output/
COPY . .
RUN npm run build
# ---------- 运行阶段 ----------
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=3000
# 只带运行所需文件,减小镜像体积
COPY --from=builder /app/.output ./.output
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
# 健康检查:命中 /api/health
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/api/health >/dev/null 2>&1 || exit 1
CMD ["node", ".output/server/index.mjs"]
+150
View File
@@ -0,0 +1,150 @@
# BT 种子预览台(DSL)
搜索 / 解析 / 预览 BT 种子内容,并一键投递到本机下载工具(qBittorrent / Transmission / aria2)的 Web 控制台。
## 功能特性
- **多源聚合搜索**:同时搜索多个公开磁力站并自动按 infoHash 去重、按做种数排序。
- The Pirate Bay(apibay)
- Nyaa
- SolidTorrents
- 磁力搜索(cilisousuo.cc)
- **种子内容预览**:通过 WebTorrent 的 DHT / Tracker 解析磁力链,取得文件清单、总大小、做种/下载人数(无需真正下载文件)。
- **一键投递**:把选中的磁力链直接发给本机 BT 客户端开始下载,并记录投递历史。
- **可配置**:搜索代理、超时、启用哪些搜索源、BT 客户端地址都可在「设置」页或 `.env` 中调整。
## 技术栈
- 前端/全栈:[Nuxt 4](https://nuxt.com/)(Vue 3 + Nitro 服务端)
- 数据库:[MongoDB](https://www.mongodb.com/)(搜索历史、投递记录、配置持久化;不连库时回落到 `.env` 默认值)
- 关键依赖:`webtorrent`(种子解析)、`mongodb`、`parse-torrent`、`undici`(带代理的 fetch)、`fast-xml-parser`
## 环境要求
- Node.js ≥ 18(推荐 20 LTS)
- MongoDB(可选;不安装也能跑,只是配置和历史记录不会持久化)
## 快速开始(本地开发)
```bash
# 1. 安装依赖
npm install
# 2. 配置环境变量(可选,见下文「配置项」)
cp .env.example .env
# 3. 启动开发服务器
npm run dev
```
打开 http://localhost:3000 即可。
构建并本地预览生产版本:
```bash
npm run build
npm run preview
```
## 配置项
所有配置都可写入 `.env`(项目根目录),未设置时使用括号内默认值。
| 变量 | 说明 | 默认 |
| --- | --- | --- |
| `MONGODB_URI` | MongoDB 连接串 | `mongodb://127.0.0.1:27017` |
| `MONGODB_DB` | 数据库名 | `torrent_console` |
| `SEARCH_PROXY` | 访问搜索源时使用的 HTTP 代理(如 `http://127.0.0.1:7890`),留空直连 | 空 |
| `SEARCH_TIMEOUT` | 单次搜索源请求超时(毫秒) | `12000` |
| `METADATA_TIMEOUT` | 磁力链元数据解析超时(毫秒) | `30000` |
| `APIBAY_BASE` | The Pirate Bay 接口地址 | `https://apibay.org` |
| `NYAA_BASE` | Nyaa 地址 | `https://nyaa.si` |
| `CILISOUSUO_BASE` | 磁力搜索(cilisousuo)地址 | `https://cilisousuo.cc` |
> 这些项也可在「设置」页通过 UI 修改(保存后写入 MongoDB 的 `settings` 集合,优先级高于 `.env`)。
## 搜索源说明
cilisousuo.cc 的列表页只暴露名称、大小和详情页短链,不含 infoHash,因此该源会**逐条抓取详情页**补全磁力链(单次搜索最多取前 24 条、5 路并发)。若服务端网络连不上该站,请在「设置」里配置 `SEARCH_PROXY`。
## 部署
### 方式一:Docker(推荐)
已提供 `Dockerfile` 与 `docker-compose.yml`(自带 MongoDB):
```bash
# 构建并启动(后台)
docker compose up -d --build
# 查看日志
docker compose logs -f app
```
访问 http://<服务器IP>:3000 。
仅构建镜像单独运行:
```bash
docker build -t bt-preview .
docker run -d --name bt-preview -p 3000:3000 \
-e MONGODB_URI=mongodb://mongo:27017 \
bt-preview
```
### 方式二:直接运行(Node 服务器)
```bash
# 生产构建,产物在 .output/
npm run build
# 用任意进程管理器跑起来(以 PM2 为例)
npm install -g pm2
pm2 start .output/server/index.mjs --name bt-preview
pm2 save
```
关键运行环境变量:
```bash
HOST=0.0.0.0 # 监听地址
PORT=3000 # 监听端口
MONGODB_URI=mongodb://127.0.0.1:27017
```
### 方式三:反向代理(Nginx 示例)
```nginx
server {
listen 80;
server_name your.domain;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
## 常见问题
- **搜索源"打不开" / 搜不到结果**:公开磁力站在部分网络下被墙。在「设置」里填一个可用的 HTTP 代理(`SEARCH_PROXY`),保存后重试。
- **MongoDB 未连接提示**:不影响基础搜索;只是配置和历史不会持久化。安装并启动 MongoDB,或设置 `MONGODB_URI` 指向可用的实例即可。
- **磁力解析不可用**:WebTorrent 依赖 Node 网络环境(DHT/UDP)。在受限网络下可能只能显示 infoHash,拿不到文件清单,属正常现象。
## 目录结构
```
app/ 前端页面与组件(Nuxt pages / components / composables)
server/
api/ Nitro 接口(search / torrent / clients / history / downloads ...)
utils/
search/ 各搜索源实现(apibay / nyaa / solidtorrents / cilisousuo)
mongo.ts MongoDB 连接与索引
appconfig.ts 配置读写与默认值
torrent.ts 磁力链 / 种子解析工具
```
+6
View File
@@ -0,0 +1,6 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<AppToasts />
</template>
+429
View File
@@ -0,0 +1,429 @@
/* ============================================================
设计令牌:深色工业控制台 / 网络信号台
近黑蓝底 + 发丝线 + 信号橙点缀,等宽字体承载数据
============================================================ */
:root {
--bg: #08090b;
--panel: #0d0f12;
--panel-2: #121519;
--panel-3: #171b20;
--line: #1d2228;
--line-2: #2a3138;
--fg: #d6dbdf;
--fg-dim: #8d979f;
--fg-mute: #5c656d;
--acc: #ff6a1f;
--acc-soft: rgba(255, 106, 31, 0.12);
--acc-line: rgba(255, 106, 31, 0.42);
--ok: #2fd39f;
--warn: #ffc046;
--err: #ff5757;
--ok-soft: rgba(47, 211, 159, 0.12);
--err-soft: rgba(255, 87, 87, 0.12);
--display: Bahnschrift, 'Segoe UI Variable Display', 'Segoe UI', 'Microsoft YaHei UI', sans-serif;
--body: 'Segoe UI', 'Microsoft YaHei UI', 'PingFang SC', 'Hiragino Sans GB', system-ui, sans-serif;
--mono: 'Cascadia Mono', 'Cascadia Code', Consolas, 'JetBrains Mono', 'DejaVu Sans Mono', monospace;
}
* { box-sizing: border-box; }
html, body, #__nuxt { height: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--body);
font-size: 14px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
/* 背景网格:轻微的技术图纸感 */
body::before {
content: '';
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background-image:
linear-gradient(to right, rgba(255, 255, 255, 0.016) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.016) 1px, transparent 1px);
background-size: 64px 64px;
}
body::after {
content: '';
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background: radial-gradient(120% 80% at 50% -20%, rgba(255, 106, 31, 0.055), transparent 60%);
}
a { color: inherit; text-decoration: none; }
::selection { background: var(--acc); color: #0b0b0b; }
/* 滚动条 */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--line-2); border: 3px solid var(--bg); }
::-webkit-scrollbar-thumb:hover { background: #3a434c; }
/* ================= 布局 ================= */
.shell { position: relative; z-index: 1; min-height: 100%; display: flex; flex-direction: column; }
.wrap { width: 100%; max-width: 1320px; margin: 0 auto; padding: 0 28px; }
.grow { flex: 1; }
/* ================= 顶栏 ================= */
.topbar {
position: sticky;
top: 0;
z-index: 40;
background: rgba(8, 9, 11, 0.86);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--line);
}
.topbar-in { display: flex; align-items: center; gap: 26px; height: 58px; }
.brand { display: flex; align-items: center; gap: 10px; font-family: var(--display); }
.brand-mark {
width: 26px; height: 26px;
display: grid; place-items: center;
border: 1px solid var(--acc-line);
background: var(--acc-soft);
color: var(--acc);
font-family: var(--mono);
font-size: 13px;
font-weight: 700;
letter-spacing: -0.06em;
}
.brand-name { font-size: 15px; letter-spacing: 0.16em; text-transform: uppercase; font-weight: 600; }
.brand-sub { font-family: var(--mono); font-size: 10px; color: var(--fg-mute); letter-spacing: 0.1em; }
.nav { display: flex; gap: 2px; margin-left: 6px; }
.nav a {
position: relative;
padding: 7px 13px;
font-size: 13px;
color: var(--fg-dim);
border: 1px solid transparent;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.nav a:hover { color: var(--fg); border-color: var(--line); background: var(--panel); }
.nav a.active { color: var(--acc); border-color: var(--acc-line); background: var(--acc-soft); }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.pill {
display: inline-flex; align-items: center; gap: 7px;
padding: 4px 10px;
border: 1px solid var(--line-2);
background: var(--panel);
font-family: var(--mono);
font-size: 11px;
color: var(--fg-dim);
white-space: nowrap;
}
.pill .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--fg-mute); }
.pill.on { border-color: rgba(47, 211, 159, 0.3); color: var(--ok); }
.pill.on .dot { background: var(--ok); box-shadow: 0 0 8px var(--ok); }
.pill.off { border-color: rgba(255, 87, 87, 0.3); color: var(--err); }
.pill.off .dot { background: var(--err); }
.pill.warn { border-color: rgba(255, 192, 70, 0.3); color: var(--warn); }
.pill.warn .dot { background: var(--warn); }
/* ================= 通用组件 ================= */
.page { padding: 34px 0 90px; }
.label {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--fg-mute);
}
h1 { font-family: var(--display); font-size: 34px; line-height: 1.15; margin: 10px 0 8px; font-weight: 600; letter-spacing: -0.005em; }
h2 { font-family: var(--display); font-size: 19px; margin: 0; font-weight: 600; letter-spacing: 0.01em; }
h3 { font-family: var(--display); font-size: 15px; margin: 0; font-weight: 600; }
.sub { color: var(--fg-dim); max-width: 62ch; font-size: 13.5px; }
.panel {
position: relative;
background: var(--panel);
border: 1px solid var(--line);
}
.panel::before {
content: '';
position: absolute; left: -1px; top: -1px; bottom: -1px;
width: 2px; background: var(--acc);
opacity: 0.85;
}
.panel-hd {
display: flex; align-items: center; gap: 12px;
padding: 12px 18px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, var(--panel-2), var(--panel));
}
.panel-hd .spacer { margin-left: auto; }
.panel-bd { padding: 18px; }
.panel-bd.tight { padding: 0; }
.btn {
display: inline-flex; align-items: center; gap: 7px;
padding: 8px 15px;
background: var(--panel-2);
color: var(--fg);
border: 1px solid var(--line-2);
font-family: var(--body);
font-size: 13px;
cursor: pointer;
transition: background 0.14s, border-color 0.14s, color 0.14s, transform 0.08s;
}
.btn:hover:not(:disabled) { background: var(--panel-3); border-color: #3a434c; }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:disabled { opacity: 0.42; cursor: not-allowed; }
.btn-primary { background: var(--acc); border-color: var(--acc); color: #12100e; font-weight: 600; }
.btn-primary:hover:not(:disabled) { background: #ff7f3d; border-color: #ff7f3d; }
.btn-ghost { background: transparent; border-color: var(--line); color: var(--fg-dim); }
.btn-ghost:hover:not(:disabled) { color: var(--fg); background: var(--panel-2); }
.btn-danger { border-color: rgba(255, 87, 87, 0.35); color: var(--err); background: transparent; }
.btn-danger:hover:not(:disabled) { background: var(--err-soft); }
.btn-sm { padding: 5px 10px; font-size: 12px; }
.input, .select, .textarea {
width: 100%;
padding: 9px 12px;
background: #0a0c0e;
border: 1px solid var(--line-2);
color: var(--fg);
font-family: var(--mono);
font-size: 13px;
transition: border-color 0.14s, box-shadow 0.14s;
}
.input:focus, .select:focus, .textarea:focus {
outline: none;
border-color: var(--acc-line);
box-shadow: 0 0 0 3px var(--acc-soft);
}
.input::placeholder, .textarea::placeholder { color: var(--fg-mute); }
.textarea { resize: vertical; min-height: 108px; line-height: 1.6; }
.select { font-family: var(--body); cursor: pointer; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field > .label { color: var(--fg-dim); }
.grid-2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.grid-3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.grid-4 { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
/* 标签页 */
.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--line); margin: 22px 0 0; }
.tabs button {
padding: 10px 18px;
background: transparent;
border: 1px solid transparent;
border-bottom: none;
color: var(--fg-mute);
font-family: var(--mono);
font-size: 12px;
letter-spacing: 0.06em;
cursor: pointer;
margin-bottom: -1px;
transition: color 0.14s, background 0.14s;
}
.tabs button:hover { color: var(--fg-dim); background: var(--panel); }
.tabs button.on { color: var(--acc); background: var(--panel); border-color: var(--line); border-bottom-color: var(--panel); }
/* 勾选 */
.check { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 13px; color: var(--fg-dim); user-select: none; }
.check input { appearance: none; width: 15px; height: 15px; border: 1px solid var(--line-2); background: #0a0c0e; cursor: pointer; position: relative; }
.check input:checked { background: var(--acc); border-color: var(--acc); }
.check input:checked::after { content: '✓'; position: absolute; inset: 0; display: grid; place-items: center; color: #12100e; font-size: 11px; font-weight: 700; }
.check:hover { color: var(--fg); }
/* 表格 */
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th {
text-align: left;
padding: 9px 12px;
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--fg-mute);
border-bottom: 1px solid var(--line-2);
background: var(--panel-2);
white-space: nowrap;
font-weight: 500;
}
.table td { padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
.table tbody tr { transition: background 0.1s; }
.table tbody tr:hover { background: rgba(255, 255, 255, 0.022); }
.table .num { font-family: var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; }
.table .right { text-align: right; }
.table .name { color: var(--fg); word-break: break-word; }
.mono { font-family: var(--mono); }
.dim { color: var(--fg-dim); }
.mute { color: var(--fg-mute); }
.ok { color: var(--ok); }
.warn { color: var(--warn); }
.err { color: var(--err); }
.acc { color: var(--acc); }
.small { font-size: 12px; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.break { word-break: break-all; }
.tag {
display: inline-block;
padding: 1px 7px;
border: 1px solid var(--line-2);
background: var(--panel-2);
font-family: var(--mono);
font-size: 10.5px;
color: var(--fg-dim);
white-space: nowrap;
}
.tag.acc { border-color: var(--acc-line); color: var(--acc); background: var(--acc-soft); }
.tag.ok { border-color: rgba(47, 211, 159, 0.32); color: var(--ok); background: var(--ok-soft); }
.tag.err { border-color: rgba(255, 87, 87, 0.32); color: var(--err); background: var(--err-soft); }
.stat { display: flex; flex-direction: column; gap: 3px; padding: 11px 14px; border: 1px solid var(--line); background: var(--panel); }
.stat .v { font-family: var(--mono); font-size: 17px; font-variant-numeric: tabular-nums; }
.empty { padding: 38px 18px; text-align: center; color: var(--fg-mute); font-family: var(--mono); font-size: 12px; }
.note { display: flex; gap: 10px; padding: 11px 14px; border: 1px solid var(--line-2); background: var(--panel-2); font-size: 12.5px; color: var(--fg-dim); }
.note.err { border-color: rgba(255, 87, 87, 0.3); background: var(--err-soft); color: #ffb3b3; }
.note.warn { border-color: rgba(255, 192, 70, 0.3); background: rgba(255, 192, 70, 0.08); color: #ffd98a; }
.note.ok { border-color: rgba(47, 211, 159, 0.28); background: var(--ok-soft); color: #a6f0d8; }
.row { display: flex; align-items: center; gap: 10px; }
.row.center { justify-content: center; }
.row.wrap { flex-wrap: wrap; }
.stack { display: flex; flex-direction: column; gap: 14px; }
.stack.sm { gap: 8px; }
.mt { margin-top: 14px; }
.mt-lg { margin-top: 26px; }
.py-md { padding-top: 14px; padding-bottom: 14px; }
.mx-lg { margin-left: 20px; margin-right: 20px; }
.spread { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.panel-ft { padding: 10px 14px; border-top: 1px solid var(--line-2); background: var(--panel-2); }
.kv { display: grid; grid-template-columns: 104px 1fr; gap: 6px 14px; font-size: 13px; }
.kv dt { color: var(--fg-mute); font-family: var(--mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; padding-top: 2px; }
.kv dd { margin: 0; }
/* 拖放区 */
.drop {
border: 1px dashed var(--line-2);
background: var(--panel-2);
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.drop:hover, .drop.over { border-color: var(--acc); background: var(--acc-soft); }
.drop .big { font-family: var(--display); font-size: 17px; margin-bottom: 6px; }
/* 文件树缩进 */
.file-indent { display: inline-block; color: var(--fg-mute); }
.file-path { font-family: var(--mono); font-size: 12.5px; }
.file-dir { color: var(--fg-mute); }
.file-name { color: var(--fg); }
.bar-track { height: 3px; background: var(--line); width: 100%; overflow: hidden; }
.bar-fill { height: 100%; background: var(--acc); }
/* toast */
.toasts { position: fixed; right: 22px; bottom: 22px; z-index: 90; display: flex; flex-direction: column; gap: 9px; width: min(420px, calc(100vw - 44px)); }
.toast {
display: flex; gap: 10px; align-items: flex-start;
padding: 12px 14px;
background: var(--panel-2);
border: 1px solid var(--line-2);
border-left: 2px solid var(--acc);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.55);
font-size: 13px;
animation: toast-in 0.22s cubic-bezier(0.2, 0.9, 0.3, 1);
}
.toast.ok { border-left-color: var(--ok); }
.toast.err { border-left-color: var(--err); }
.toast.warn { border-left-color: var(--warn); }
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
/* 页面进入的错落动画 */
.rise { animation: rise 0.42s cubic-bezier(0.16, 1, 0.3, 1) both; }
.rise-2 { animation-delay: 0.06s; }
.rise-3 { animation-delay: 0.12s; }
.rise-4 { animation-delay: 0.18s; }
@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
.spin { display: inline-block; width: 11px; height: 11px; border: 1.5px solid var(--line-2); border-top-color: var(--acc); border-radius: 50%; animation: spin 0.7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.footer { border-top: 1px solid var(--line); padding: 16px 0 26px; color: var(--fg-mute); font-family: var(--mono); font-size: 11px; }
/* 图片预览弹层 */
.overlay {
position: fixed;
inset: 0;
z-index: 80;
display: flex;
align-items: center;
justify-content: center;
padding: 3vh 3vw;
background: rgba(4, 5, 6, 0.87);
backdrop-filter: blur(3px);
animation: overlay-in 0.18s ease both;
}
@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } }
.lightbox {
display: flex;
flex-direction: column;
width: min(1120px, 100%);
max-height: 94vh;
background: var(--panel);
border: 1px solid var(--line-2);
box-shadow: 0 40px 100px rgba(0, 0, 0, 0.72);
animation: rise 0.26s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.lightbox-bd {
flex: 1;
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
overflow: auto;
/* 细网格底纹,透明 PNG 也能看清边界 */
background:
linear-gradient(rgba(255, 255, 255, 0.018) 1px, transparent 1px) 0 0 / 100% 28px,
linear-gradient(90deg, rgba(255, 255, 255, 0.018) 1px, transparent 1px) 0 0 / 28px 100%,
var(--bg);
}
.lightbox-img { display: block; max-width: 100%; max-height: 76vh; }
.lightbox-ft {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-top: 1px solid var(--line);
font-family: var(--mono);
}
.lightbox-ft > .mono { flex: 1; text-align: center; }
@media (max-width: 900px) {
.wrap { padding: 0 16px; }
.grid-4, .grid-3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-2 { grid-template-columns: 1fr; }
h1 { font-size: 26px; }
.nav a { padding: 6px 9px; font-size: 12px; }
.hide-sm { display: none; }
.overlay { padding: 0; }
.lightbox { max-height: 100vh; width: 100%; border: none; }
.lightbox-img { max-height: 66vh; }
}
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
const toasts = useToasts()
</script>
<template>
<div class="toasts">
<div v-for="t in toasts" :key="t.id" class="toast" :class="t.kind" @click="dismissToast(t.id)">
<span class="mono small" :class="t.kind === 'err' ? 'err' : t.kind === 'ok' ? 'ok' : 'acc'">
{{ t.kind === 'err' ? '✕' : t.kind === 'warn' ? '!' : t.kind === 'ok' ? '✓' : 'i' }}
</span>
<span>
<strong style="font-weight: 600">{{ t.title }}</strong>
<span v-if="t.detail" class="dim" style="display: block; margin-top: 2px; font-size: 12px">{{ t.detail }}</span>
</span>
</div>
</div>
</template>
+99
View File
@@ -0,0 +1,99 @@
<script setup lang="ts">
import type { TorrentFileEntry } from '~/types/api'
const props = defineProps<{
files: TorrentFileEntry[] | null
total: number
resolving?: boolean
resolveError?: string | null
}>()
const emit = defineEmits<{ resolve: []; preview: [file: TorrentFileEntry] }>()
const keyword = ref('')
const sort = ref<'size' | 'name'>('size')
/** 图片且体积没超上限,才值得从 swarm 拉下来预览 */
function previewable(file: TorrentFileEntry): boolean {
return file.length > 0 && file.length <= MAX_IMAGE_PREVIEW_BYTES && isImagePath(file.path)
}
const visible = computed(() => {
let list = props.files || []
if (keyword.value) {
const kw = keyword.value.toLowerCase()
list = list.filter((f) => f.path.toLowerCase().includes(kw))
}
const sorted = [...list]
if (sort.value === 'size') sorted.sort((a, b) => b.length - a.length)
else sorted.sort((a, b) => a.path.localeCompare(b.path))
return sorted.slice(0, 500)
})
const truncated = computed(() => (props.files?.length || 0) > 500)
</script>
<template>
<div>
<div v-if="resolving" class="empty">
<span class="spin" /> 正在通过 DHT / tracker 获取种子元数据,通常几秒到几十秒…
</div>
<div v-else-if="!files" class="stack sm" style="padding: 18px">
<div class="note warn">
这个种子只有 infoHash,还没有文件清单。可以点击下面的按钮在线拉取元数据(需要本机能连上 DHT / tracker)。
</div>
<div v-if="resolveError" class="note err">{{ resolveError }}</div>
<div>
<button class="btn btn-primary" @click="emit('resolve')">在线解析文件清单</button>
</div>
</div>
<template v-else>
<div class="panel-hd" style="border-top: none">
<span class="label">FILE LIST</span>
<span class="mono small dim">{{ formatNumber(files.length) }} 个文件 · {{ formatBytes(total) }}</span>
<span class="spacer" />
<input v-model="keyword" class="input" style="width: 200px" placeholder="过滤文件名…" />
<select v-model="sort" class="select" style="width: 120px">
<option value="size">按大小</option>
<option value="name">按名称</option>
</select>
</div>
<div style="max-height: 520px; overflow: auto">
<table class="table">
<thead>
<tr>
<th style="width: 58%">路径</th>
<th class="right" style="width: 14%">大小</th>
<th class="right" style="width: 10%">占比</th>
<th style="width: 76px" />
</tr>
</thead>
<tbody>
<tr v-for="(f, i) in visible" :key="i">
<td class="file-path">
<span class="file-dir">{{ splitPath(f.path).dir }}</span><span class="file-name">{{ splitPath(f.path).name }}</span>
</td>
<td class="num right">{{ formatBytes(f.length) }}</td>
<td class="num right mute">{{ filePercent(f.length, total) }}</td>
<td class="right">
<button
v-if="previewable(f)"
class="btn btn-sm btn-ghost"
:title="`从做种节点拉取这张图片用于预览:${f.path}`"
@click="emit('preview', f)"
>
预览
</button>
<span v-else-if="isImagePath(f.path)" class="dim small" title="图片体积超出在线预览上限">过大</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="truncated" class="empty">结果较多,表格只展示前 500 条,可用过滤框缩小范围</div>
</template>
</div>
</template>
+120
View File
@@ -0,0 +1,120 @@
<script setup lang="ts">
import type { TorrentFileEntry } from '~/types/api'
const props = defineProps<{
infoHash: string
magnet: string
/** 全部可预览的图片,按文件清单原始顺序 */
images: TorrentFileEntry[]
index: number
}>()
const emit = defineEmits<{ close: []; 'update:index': [value: number] }>()
const status = ref<'loading' | 'ok' | 'err'>('loading')
const errorText = ref('')
const src = ref('')
let objectUrl = ''
const current = computed<TorrentFileEntry | undefined>(() => props.images[props.index])
const requestUrl = computed(
() =>
`/api/torrent/${props.infoHash}/preview?path=${encodeURIComponent(
current.value?.path || ''
)}&magnet=${encodeURIComponent(props.magnet)}`
)
function revoke() {
if (objectUrl) {
URL.revokeObjectURL(objectUrl)
objectUrl = ''
}
}
async function load() {
revoke()
src.value = ''
errorText.value = ''
status.value = 'loading'
try {
const res = await fetch(requestUrl.value)
if (!res.ok) {
// 服务端用 statusMessage 带上可读原因:没有做种节点 / 图片过大 / 文件不存在
const text = await res.text()
let message = text
try {
const json = JSON.parse(text)
message = json.statusMessage || json.message || text
} catch {
/* 不是 JSON 就用原文 */
}
throw new Error(message || `请求失败(HTTP ${res.status})`)
}
objectUrl = URL.createObjectURL(await res.blob())
src.value = objectUrl
status.value = 'ok'
} catch (err) {
errorText.value = errorMessage(err)
status.value = 'err'
}
}
function step(delta: number) {
const next = props.index + delta
if (next < 0 || next >= props.images.length) return
emit('update:index', next)
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
else if (e.key === 'ArrowLeft') step(-1)
else if (e.key === 'ArrowRight') step(1)
}
watch(() => [props.index, props.infoHash, props.magnet], load, { immediate: true })
onMounted(() => window.addEventListener('keydown', onKey))
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKey)
revoke()
})
</script>
<template>
<div class="overlay" @click.self="emit('close')">
<div class="lightbox">
<div class="panel-hd">
<span class="label">IMAGE</span>
<span class="mono small truncate" style="max-width: 36ch" :title="current?.path">
{{ splitPath(current?.path || '').name }}
</span>
<span class="spacer" />
<span class="mono small dim">{{ index + 1 }} / {{ images.length }}</span>
<span class="mono small dim">{{ formatBytes(current?.length) }}</span>
<button class="btn btn-sm btn-ghost" @click="emit('close')">关闭</button>
</div>
<div class="lightbox-bd">
<div v-if="status === 'loading'" class="empty">
<span class="spin" /> 正在从做种节点拉取这张图片,取决于对方的上传速度…
</div>
<div v-else-if="status === 'err'" class="stack sm" style="padding: 22px">
<div class="note err">{{ errorText }}</div>
<div class="row">
<button class="btn btn-sm" @click="load">重试</button>
<a class="btn btn-sm btn-ghost" :href="requestUrl" target="_blank" rel="noreferrer noopener">
直接打开图片地址
</a>
</div>
</div>
<img v-else :src="src" :alt="current?.path || 'preview'" class="lightbox-img" />
</div>
<div class="lightbox-ft">
<button class="btn btn-sm" :disabled="index <= 0" @click="step(-1)">← 上一张</button>
<span class="mono small dim truncate" style="max-width: 44ch" :title="current?.path">{{ current?.path }}</span>
<button class="btn btn-sm" :disabled="index >= images.length - 1" @click="step(1)">下一张 →</button>
</div>
</div>
</div>
</template>
+122
View File
@@ -0,0 +1,122 @@
<script setup lang="ts">
import type { ClientsResponse, TorrentView } from '~/types/api'
const props = defineProps<{ torrent: TorrentView }>()
const clients = ref<ClientsResponse | null>(null)
const loading = ref(false)
const sending = ref(false)
const selectedId = ref('')
const dir = ref('')
const statuses = computed(() => clients.value?.statuses || [])
const onlineStatuses = computed(() => statuses.value.filter((s) => s.online))
const current = computed(() => statuses.value.find((s) => s.id === selectedId.value) || null)
async function load() {
loading.value = true
try {
clients.value = await $fetch<ClientsResponse>('/api/clients')
const firstOnline = clients.value.statuses.find((s) => s.online)
if (!selectedId.value) selectedId.value = (firstOnline || clients.value.statuses[0])?.id || ''
syncDir()
} catch (err) {
pushToast('err', '读取下载器配置失败', errorMessage(err))
} finally {
loading.value = false
}
}
function syncDir() {
const cfg = clients.value?.configs.find((c) => c.id === selectedId.value)
dir.value = cfg?.downloadDir || ''
}
watch(selectedId, syncDir)
async function send() {
if (!selectedId.value) return
sending.value = true
try {
const res = await $fetch<{ ok: boolean; message: string }>('/api/send', {
method: 'POST',
body: {
clientId: selectedId.value,
infoHash: props.torrent.infoHash,
magnet: props.torrent.magnet || undefined,
name: props.torrent.name,
downloadDir: dir.value || undefined
}
})
pushToast('ok', '投递成功', res.message)
refreshHealth()
} catch (err) {
pushToast('err', '投递失败', errorMessage(err))
} finally {
sending.value = false
}
}
onMounted(load)
</script>
<template>
<div class="panel">
<div class="panel-hd">
<span class="label">SEND TO LOCAL CLIENT</span>
<span class="mono small" :class="onlineStatuses.length ? 'ok' : 'err'">
{{ loading ? '探测中…' : `${onlineStatuses.length}/${statuses.length} 在线` }}
</span>
<span class="spacer" />
<button class="btn btn-sm btn-ghost" :disabled="loading" @click="load">重新探测</button>
<NuxtLink to="/settings" class="btn btn-sm btn-ghost">配置</NuxtLink>
</div>
<div class="panel-bd stack sm">
<div v-if="!statuses.length" class="note warn">
还没有配置任何下载器。去「设置」里填 qBittorrent / Transmission / aria2 的地址和账号密码。
</div>
<template v-else>
<div class="row wrap">
<div
v-for="s in statuses"
:key="s.id"
class="pill"
:class="s.online ? 'on' : 'off'"
:title="s.error || s.endpoint"
>
<i class="dot" />
{{ s.name }}
<span class="mute">{{ s.version ? `v${s.version}` : s.online ? '' : '离线' }}</span>
</div>
</div>
<div class="grid-2">
<label class="field">
<span class="label">目标下载器</span>
<select v-model="selectedId" class="select">
<option v-for="s in statuses" :key="s.id" :value="s.id">
{{ s.name }}({{ s.online ? '在线' : '离线' }})
</option>
</select>
</label>
<label class="field">
<span class="label">下载目录(留空用客户端默认)</span>
<input v-model="dir" class="input" placeholder="D:\\Downloads" />
</label>
</div>
<div v-if="current && !current.online" class="note err">{{ current.error || '该下载器当前不可用' }}</div>
<div class="row">
<button class="btn btn-primary" :disabled="sending || !selectedId" @click="send">
<span v-if="sending" class="spin" />
{{ sending ? '投递中…' : '投递到本机下载器' }}
</button>
<span class="mute small">种子交给本机客户端下载,服务器不中转文件内容</span>
</div>
</template>
</div>
</div>
</template>
+178
View File
@@ -0,0 +1,178 @@
<script setup lang="ts">
import type { TorrentFileEntry, TorrentView } from '~/types/api'
const props = defineProps<{
torrent: TorrentView
/** 是否允许在线解析磁力链文件清单 */
canResolve?: boolean
}>()
const emit = defineEmits<{ resolved: [payload: { files: TorrentFileEntry[]; size: number; name: string }] }>()
const local = ref<TorrentView>({ ...props.torrent })
const resolving = ref(false)
const showTrackers = ref(false)
watch(
() => props.torrent,
(next) => {
local.value = { ...next }
},
{ deep: true }
)
const files = computed(() => local.value.files)
const total = computed(() => files.value?.reduce((s, f) => s + f.length, 0) || local.value.size || 0)
/** 可在线预览的图片:按文件清单原始顺序,预览弹窗靠这个顺序翻页 */
const previewImages = computed<TorrentFileEntry[]>(() =>
(files.value || []).filter((f) => f.length > 0 && f.length <= MAX_IMAGE_PREVIEW_BYTES && isImagePath(f.path))
)
const previewIndex = ref<number | null>(null)
function openPreview(file: TorrentFileEntry) {
const index = previewImages.value.findIndex((f) => f.path === file.path)
if (index >= 0) previewIndex.value = index
}
// 文件清单变了(例如刚在线解析完),旧的索引可能已经失效
watch(files, () => {
previewIndex.value = null
})
const sourceIcon: Record<string, string> = {
apibay: 'The Pirate Bay',
nyaa: 'Nyaa',
cilisousuo: '磁力搜索',
upload: '本地上传',
magnet: '磁力链',
database: '数据库'
}
async function doResolve() {
resolving.value = true
try {
const res = await $fetch<{ files: TorrentFileEntry[]; size: number; name: string }>(
`/api/torrent/${local.value.infoHash}/resolve`,
{ method: 'POST', body: { magnet: local.value.magnet } }
)
local.value.files = res.files
local.value.size = res.size
local.value.fileCount = res.files.length
local.value.resolveError = null
if (res.name) local.value.name = res.name
emit('resolved', res)
pushToast('ok', '文件清单已解析', `共 ${res.files.length} 个文件 / ${formatBytes(res.size)}`)
} catch (err) {
const msg = errorMessage(err)
local.value.resolveError = msg
pushToast('err', '解析失败', msg)
} finally {
resolving.value = false
}
}
async function copyMagnet() {
const ok = await copyText(local.value.magnet)
pushToast(ok ? 'ok' : 'err', ok ? '磁力链已复制到剪贴板' : '复制失败,请手动选中复制')
}
onMounted(() => {
// 如果没有文件清单,且是磁力链接(有 magnet),则自动尝试解析
if (!local.value.files && local.value.magnet && props.canResolve !== false) {
doResolve()
}
})
</script>
<template>
<div class="stack">
<div class="panel">
<div class="panel-hd">
<span class="label">TORRENT</span>
<span class="mono small truncate" style="max-width: 46ch" :title="local.name">{{ local.name }}</span>
<span class="spacer" />
<span class="tag">{{ sourceIcon[local.source || ''] || local.source || '未知来源' }}</span>
<span v-if="local.category" class="tag acc">{{ local.category }}</span>
</div>
<div class="panel-bd stack">
<div class="grid-4">
<div class="stat">
<span class="label">总大小</span>
<span class="v">{{ formatBytes(total) }}</span>
</div>
<div class="stat">
<span class="label">文件数</span>
<span class="v">{{ local.files ? formatNumber(local.files.length) : local.fileCount ?? '未知' }}</span>
</div>
<div class="stat">
<span class="label">做种 / 下载</span>
<span class="v">
<span class="ok">{{ local.seeders ?? '—' }}</span>
<span class="mute"> / </span>
<span class="warn">{{ local.leechers ?? '—' }}</span>
</span>
</div>
<div class="stat">
<span class="label">发布时间</span>
<span class="v small">{{ local.creationDate ? formatDate(local.creationDate) : '—' }}</span>
</div>
</div>
<dl class="kv">
<dt>infoHash</dt>
<dd class="mono break small">{{ local.infoHash }}</dd>
<dt>Trackers</dt>
<dd>
<button class="btn btn-sm btn-ghost" @click="showTrackers = !showTrackers">
{{ local.trackers?.length || 0 }} 个{{ showTrackers ? ' · 收起' : ' · 展开' }}
</button>
<div v-if="showTrackers" class="stack sm mt" style="max-height: 180px; overflow: auto">
<span v-for="t in local.trackers" :key="t" class="mono small dim break">{{ t }}</span>
</div>
</dd>
<dt v-if="local.comment">备注</dt>
<dd v-if="local.comment" class="small dim">{{ local.comment }}</dd>
</dl>
<div class="row wrap">
<button class="btn btn-sm" @click="copyMagnet">复制磁力链</button>
<a v-if="local.hasFile" class="btn btn-sm" :href="`/api/torrent/${local.infoHash}/file`">下载 .torrent</a>
<a v-if="local.sourceUrl" class="btn btn-sm btn-ghost" :href="local.sourceUrl" target="_blank" rel="noreferrer noopener">
打开来源页面
</a>
</div>
</div>
</div>
<SendPanel :torrent="local" />
<div v-if="previewImages.length" class="note">
文件清单里有 {{ previewImages.length }} 张图片可以点「预览」直接看:只会按需拉取这几张图所在的分片,不会下载整个种子。
</div>
<div class="panel">
<div class="panel-bd tight">
<FileTable
:files="files"
:total="total"
:resolving="resolving"
:resolve-error="local.resolveError"
@resolve="doResolve"
@preview="openPreview"
/>
</div>
</div>
<ImagePreview
v-if="previewIndex !== null"
:info-hash="local.infoHash"
:magnet="local.magnet"
:images="previewImages"
:index="previewIndex"
@update:index="previewIndex = $event"
@close="previewIndex = null"
/>
</div>
</template>
+16
View File
@@ -0,0 +1,16 @@
import type { HealthResponse } from '~/types/api'
/** 全局健康状态:Mongo / webtorrent / 客户端在线数,用于顶栏状态灯与页面提示 */
export function useHealth() {
return useState<HealthResponse | null>('health', () => null)
}
export async function refreshHealth() {
const state = useHealth()
try {
state.value = await $fetch<HealthResponse>('/api/health')
} catch {
state.value = null
}
return state.value
}
+41
View File
@@ -0,0 +1,41 @@
export type ToastKind = 'info' | 'ok' | 'err' | 'warn'
export interface Toast {
id: number
kind: ToastKind
title: string
detail?: string
}
let seq = 0
export function useToasts() {
return useState<Toast[]>('toasts', () => [])
}
export function dismissToast(id: number) {
const toasts = useToasts()
toasts.value = toasts.value.filter((t) => t.id !== id)
}
export function pushToast(kind: ToastKind, title: string, detail?: string, ttl = 5200) {
const toasts = useToasts()
const id = ++seq
toasts.value = [...toasts.value, { id, kind, title, detail }]
if (import.meta.client && ttl > 0) {
setTimeout(() => dismissToast(id), ttl)
}
return id
}
/** 从 $fetch 抛出的错误里抠出可读信息 */
export function errorMessage(err: unknown): string {
const e = err as { statusMessage?: string; data?: { statusMessage?: string; message?: string }; message?: string }
return (
e?.data?.statusMessage ||
e?.statusMessage ||
e?.data?.message ||
e?.message ||
'请求失败'
)
}
+71
View File
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { locale, setLocale, type Locale } from '~/utils/i18n'
const route = useRoute()
const health = useHealth()
const lang = computed<Locale>({
get: () => locale.value,
set: (v) => setLocale(v)
})
onMounted(() => {
refreshHealth()
})
const mongoPill = computed(() => {
if (!health.value) return { cls: '', text: 'MONGO ?' }
return health.value.mongo.ok
? { cls: 'on', text: `MONGO · ${health.value.mongo.db}` }
: { cls: 'off', text: 'MONGO 未连接' }
})
</script>
<template>
<div class="shell">
<header class="topbar">
<div class="wrap topbar-in">
<NuxtLink to="/" class="brand">
<span class="brand-mark">BT</span>
<span>
<span class="brand-name">Torrent Console</span><br />
<span class="brand-sub">预览台 / 投递器</span>
</span>
</NuxtLink>
<nav class="nav">
<NuxtLink to="/" :class="{ active: route.path === '/' }">搜索</NuxtLink>
<NuxtLink to="/downloads" :class="{ active: route.path === '/downloads' }">任务</NuxtLink>
<NuxtLink to="/history" :class="{ active: route.path === '/history' }">历史</NuxtLink>
<NuxtLink to="/settings" :class="{ active: route.path === '/settings' }">设置</NuxtLink>
</nav>
<div class="topbar-right">
<span class="pill hide-sm" :class="health && !health.webTorrent ? 'warn' : ''">
<i class="dot" />
{{ health?.webTorrent ? '磁力解析就绪' : '磁力解析不可用' }}
</span>
<NuxtLink to="/settings" class="pill" :class="mongoPill.cls" :title="health?.mongo.error || ''">
<i class="dot" />
{{ mongoPill.text }}
</NuxtLink>
<select class="pill lang-select" v-model="lang" title="Language" aria-label="Language">
<option value="en">EN</option>
<option value="zh">中文</option>
</select>
</div>
</div>
</header>
<main class="grow wrap">
<slot />
</main>
<footer class="footer">
<div class="wrap spread">
<span>本地自托管 · 单进程 Nuxt 单体应用</span>
<span class="mute">种子只做解析与预览,文件内容由你自己的下载器拉取</span>
</div>
</footer>
</div>
</template>
+155
View File
@@ -0,0 +1,155 @@
<script setup lang="ts">
import type { BtTask, ClientTaskGroup } from '~/server/utils/clients'
const { data, refresh, pending } = await useFetch<{
groups: ClientTaskGroup[]
totalCount: number
onlineCount: number
}>('/api/clients/tasks')
const timer = ref<any>(null)
onMounted(() => {
timer.value = setInterval(() => {
if (!pending.value) refresh()
}, 5000)
})
onBeforeUnmount(() => {
if (timer.value) clearInterval(timer.value)
})
function statusCls(status: BtTask['status']) {
switch (status) {
case 'downloading': return 'ok'
case 'seeding': return 'acc'
case 'error': return 'err'
case 'paused': return 'mute'
case 'checking': return 'warn'
default: return ''
}
}
function statusText(status: BtTask['status']) {
switch (status) {
case 'downloading': return '下载中'
case 'seeding': return '做种中'
case 'paused': return '已暂停'
case 'error': return '错误'
case 'queued': return '排队中'
case 'checking': return '校验中'
case 'completed': return '已完成'
default: return '未知'
}
}
function formatEta(seconds?: number) {
if (seconds === undefined || seconds >= 8640000) return '—'
if (seconds < 60) return `${seconds}s`
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`
}
</script>
<template>
<div class="page">
<section class="rise">
<p class="label">ACTIVE · DOWNLOADS</p>
<h1>实时下载状态</h1>
<p class="sub">直接从你配置的 qBittorrent / Transmission / aria2 客户端拉取的实时数据。</p>
</section>
<div class="grid-4 mt rise rise-2">
<div class="stat">
<span class="label">活跃任务总数</span>
<span class="v">{{ data?.totalCount ?? 0 }}</span>
</div>
<div class="stat">
<span class="label">在线下载器</span>
<span class="v">{{ data?.onlineCount ?? 0 }}</span>
</div>
<div class="stat">
<span class="label">刷新频率</span>
<span class="v small">每 5 秒自动同步</span>
</div>
<div class="stat">
<button class="btn btn-sm btn-ghost mt-sm" :disabled="pending" @click="refresh()">
{{ pending ? '同步中…' : '手动刷新' }}
</button>
</div>
</div>
<div v-for="group in data?.groups" :key="group.clientId" class="stack mt rise rise-3">
<div class="panel">
<div class="panel-hd">
<span class="label">{{ group.clientName }}</span>
<span class="pill" :class="group.online ? 'on' : 'off'">
<i class="dot" />
{{ group.online ? '在线' : '离线' }}
</span>
<span v-if="group.error" class="small err ml">{{ group.error }}</span>
<span class="spacer" />
<span class="mono small dim">{{ group.tasks.length }} 个任务</span>
</div>
<div class="panel-bd tight">
<table v-if="group.tasks.length" class="table">
<thead>
<tr>
<th style="width: 40%">任务名称</th>
<th style="width: 15%">进度</th>
<th class="right">大小</th>
<th class="right hide-sm">下载速度</th>
<th class="right hide-sm">剩余时间</th>
<th class="right">状态</th>
</tr>
</thead>
<tbody>
<tr v-for="t in group.tasks" :key="t.infoHash">
<td class="name">
<NuxtLink class="mono small truncate" :to="`/torrent/${t.infoHash}`" style="max-width: 40ch">
{{ t.name }}
</NuxtLink>
</td>
<td>
<div class="row sm">
<div class="grow" style="height: 4px; background: var(--line); border-radius: 2px; overflow: hidden">
<div
style="height: 100%; background: var(--acc); transition: width 0.3s"
:style="{ width: (t.progress * 100) + '%' }"
/>
</div>
<span class="mono small" style="min-width: 4ch; text-align: right">
{{ (t.progress * 100).toFixed(1) }}%
</span>
</div>
</td>
<td class="num right">{{ formatBytes(t.size) }}</td>
<td class="num right hide-sm">
<span :class="t.dlspeed > 0 ? 'ok' : 'dim'">
{{ t.dlspeed > 0 ? `${formatBytes(t.dlspeed)}/s` : '—' }}
</span>
</td>
<td class="num right dim hide-sm">{{ formatEta(t.eta) }}</td>
<td class="right">
<span class="tag" :class="statusCls(t.status)">{{ statusText(t.status) }}</span>
</td>
</tr>
</tbody>
</table>
<div v-else class="empty">暂无任务</div>
</div>
</div>
</div>
<div v-if="!data?.groups.length && !pending" class="empty mt">
还没有配置任何下载器,或者所有下载器都已禁用。
<NuxtLink to="/settings" class="btn btn-sm btn-primary ml">去设置</NuxtLink>
</div>
</div>
</template>
<style scoped>
.ml { margin-left: 12px; }
.mt-sm { margin-top: 4px; }
</style>
+177
View File
@@ -0,0 +1,177 @@
<script setup lang="ts">
import type { TorrentView } from '~/types/api'
interface SearchItem {
_id: string
keyword: string
providers: string[]
resultCount: number
durationMs: number
ok: boolean
error: string | null
createdAt: string
}
const { data, refresh, pending } = await useFetch<{
items: SearchItem[]
hot: { keyword: string; count: number; last: string }[]
total: number
torrentTotal: number
offline: boolean
}>('/api/history')
const torrents = ref<TorrentView[]>([])
const torrentsPending = ref(false)
async function loadTorrents() {
torrentsPending.value = true
try {
const res = await $fetch<{ items: TorrentView[] }>('/api/torrents', { query: { limit: 30 } })
torrents.value = res.items
} catch {
torrents.value = []
} finally {
torrentsPending.value = false
}
}
async function removeOne(id: string) {
try {
await $fetch('/api/history', { method: 'DELETE', query: { id } })
pushToast('ok', '已删除该条记录')
await refresh()
} catch (err) {
pushToast('err', '删除失败', errorMessage(err))
}
}
async function clearAll() {
if (!confirm('确定清空全部搜索历史?')) return
try {
const res = await $fetch<{ deleted: number }>('/api/history', { method: 'DELETE' })
pushToast('ok', `已清空 ${res.deleted} 条记录`)
await refresh()
} catch (err) {
pushToast('err', '清空失败', errorMessage(err))
}
}
onMounted(loadTorrents)
</script>
<template>
<div class="page">
<section class="rise">
<p class="label">HISTORY · LIBRARY</p>
<h1>搜索历史与种子库</h1>
<p class="sub">每次搜索都会落到 MongoDB 的 searches 集合;搜到和解析过的种子按 infoHash 去重存在 torrents 集合。</p>
</section>
<div v-if="data?.offline" class="note warn mt rise rise-2">
MongoDB 未连接,历史记录不可用。启动本地 MongoDB 后刷新本页即可。
</div>
<div class="grid-4 mt rise rise-2">
<div class="stat"><span class="label">搜索总次数</span><span class="v">{{ formatNumber(data?.total) }}</span></div>
<div class="stat"><span class="label">库中种子数</span><span class="v">{{ formatNumber(data?.torrentTotal) }}</span></div>
<div class="stat"><span class="label">高频关键词</span><span class="v">{{ data?.hot?.length || 0 }}</span></div>
<div class="stat">
<span class="label">状态</span>
<span class="v small" :class="data?.offline ? 'err' : 'ok'">{{ data?.offline ? '未连接' : '正常' }}</span>
</div>
</div>
<div v-if="data?.hot?.length" class="panel mt rise rise-3">
<div class="panel-hd"><span class="label">TOP KEYWORDS</span><span class="spacer" /><span class="mute small">点击直接再搜一次</span></div>
<div class="panel-bd row wrap">
<NuxtLink
v-for="h in data.hot"
:key="h.keyword"
class="tag acc"
:to="{ path: '/', query: { q: h.keyword } }"
style="padding: 4px 10px; font-size: 12px"
>
{{ h.keyword }} ×{{ h.count }}
</NuxtLink>
</div>
</div>
<div class="panel mt rise rise-3">
<div class="panel-hd">
<span class="label">SEARCH LOG</span>
<span class="spacer" />
<button class="btn btn-sm btn-ghost" :disabled="pending" @click="refresh()">
{{ pending ? '刷新中…' : '刷新' }}
</button>
<button class="btn btn-sm btn-danger" :disabled="!data?.items?.length" @click="clearAll">清空</button>
</div>
<div class="panel-bd tight">
<table v-if="data?.items?.length" class="table">
<thead>
<tr>
<th style="width: 42%">关键词</th>
<th class="right">结果数</th>
<th class="right hide-sm">耗时</th>
<th class="hide-sm">源</th>
<th class="right">时间</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="it in data.items" :key="it._id">
<td>
<NuxtLink class="name" :to="{ path: '/', query: { q: it.keyword } }">{{ it.keyword }}</NuxtLink>
<span v-if="!it.ok && it.error" class="small err" style="display: block">{{ it.error }}</span>
</td>
<td class="num right" :class="it.ok ? '' : 'err'">{{ it.resultCount }}</td>
<td class="num right dim hide-sm">{{ it.durationMs }}ms</td>
<td class="hide-sm"><span class="tag">{{ it.providers?.join(' + ') || '—' }}</span></td>
<td class="num right dim">{{ formatRelative(it.createdAt) }}</td>
<td class="right">
<button class="btn btn-sm btn-ghost" @click="removeOne(it._id)">删除</button>
</td>
</tr>
</tbody>
</table>
<div v-else class="empty">还没有搜索记录</div>
</div>
</div>
<div class="panel mt rise rise-4">
<div class="panel-hd">
<span class="label">RECENT TORRENTS</span>
<span class="spacer" />
<button class="btn btn-sm btn-ghost" :disabled="torrentsPending" @click="loadTorrents">
{{ torrentsPending ? '加载中…' : '刷新' }}
</button>
</div>
<div class="panel-bd tight">
<table v-if="torrents.length" class="table">
<thead>
<tr>
<th style="width: 48%">名称</th>
<th>来源</th>
<th class="right">大小</th>
<th class="right hide-sm">文件</th>
<th class="right">更新</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="t in torrents" :key="t.infoHash">
<td class="name">{{ t.name }}</td>
<td><span class="tag">{{ t.source }}</span></td>
<td class="num right">{{ formatBytes(t.size) }}</td>
<td class="num right dim hide-sm">{{ t.files ? t.files.length : t.fileCount ?? '未解析' }}</td>
<td class="num right dim">{{ formatRelative(t.resolvedAt || t.creationDate) }}</td>
<td class="right">
<NuxtLink class="btn btn-sm" :to="{ path: `/torrent/${t.infoHash}`, query: { magnet: t.magnet } }">打开</NuxtLink>
</td>
</tr>
</tbody>
</table>
<div v-else class="empty">库里还没有种子</div>
</div>
</div>
</div>
</template>
+271
View File
@@ -0,0 +1,271 @@
<script setup lang="ts">
import type { SearchResponse, TorrentView } from '~/types/api'
type Tab = 'search' | 'file' | 'magnet'
const route = useRoute()
const health = useHealth()
const tab = ref<Tab>('search')
/* ---------------- 关键词搜索 ---------------- */
const keyword = ref('')
const activeProviders = ref<string[]>([])
const searching = ref(false)
const response = ref<SearchResponse | null>(null)
const page = ref(1)
const pageSize = ref(20)
const pagedResults = computed(() => {
if (!response.value) return []
const start = (page.value - 1) * pageSize.value
return response.value.results.slice(start, start + pageSize.value)
})
const totalPages = computed(() => {
if (!response.value) return 0
return Math.ceil(response.value.results.length / pageSize.value)
})
onMounted(async () => {
const data = health.value || (await refreshHealth())
if (data) activeProviders.value = data.enabledProviders
const q = String(route.query.q || '').trim()
if (q) {
keyword.value = q
await doSearch()
}
})
/* ---------------- 上传 / 磁力 ---------------- */
const dropping = ref(false)
const parsing = ref(false)
const parsed = ref<TorrentView | null>(null)
const magnetInput = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
async function doSearch(term?: string) {
const q = (term ?? keyword.value).trim()
if (!q) {
pushToast('warn', '请输入关键词')
return
}
// 如果输入的是 infoHash,直接跳转到详情页
if (/^[a-f0-9]{40}$/i.test(q)) {
navigateTo(`/torrent/${q.toLowerCase()}`)
return
}
keyword.value = q
searching.value = true
page.value = 1
try {
response.value = await $fetch<SearchResponse>('/api/search', {
query: { q, providers: activeProviders.value.join(',') }
})
if (!response.value.results.length) {
pushToast('warn', '没有搜到结果', '可以换个关键词,或检查代理设置')
}
} catch (err) {
pushToast('err', '搜索失败', errorMessage(err))
} finally {
searching.value = false
}
}
async function pickFile(file: File) {
parsing.value = true
try {
const form = new FormData()
form.append('file', file)
const res = await $fetch<{ torrent: TorrentView; stored: boolean }>('/api/parse', {
method: 'POST',
body: form
})
parsed.value = res.torrent
pushToast('ok', '种子解析完成', `${res.torrent.files?.length || 0} 个文件 / ${formatBytes(res.torrent.size)}`)
} catch (err) {
pushToast('err', '解析失败', errorMessage(err))
} finally {
parsing.value = false
}
}
function onFileChange(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (file) pickFile(file)
}
function onDrop(event: DragEvent) {
dropping.value = false
const file = event.dataTransfer?.files?.[0]
if (file) pickFile(file)
}
async function parseMagnet() {
const magnet = magnetInput.value.trim()
if (!magnet) {
pushToast('warn', '请粘贴磁力链')
return
}
parsing.value = true
try {
const res = await $fetch<{ torrent: TorrentView }>('/api/parse', { method: 'POST', body: { magnet } })
parsed.value = res.torrent
pushToast('ok', '磁力链解析完成', '文件清单需要点击「在线解析」拉取')
} catch (err) {
pushToast('err', '解析失败', errorMessage(err))
} finally {
parsing.value = false
}
}
function previewLink(item: TorrentView) {
return {
path: `/torrent/${item.infoHash}`,
query: { magnet: item.magnet, name: item.name }
}
}
</script>
<template>
<div class="page">
<section class="rise">
<p class="label">SEARCH · PARSE · HANDOFF</p>
<h1>搜种子、看清里面有什么,再丢给本机下载器</h1>
<p class="sub">
搜索走公开磁力源,预览只看文件清单(不下服务器),确认后把种子投递给本机的
qBittorrent / Transmission / aria2,由它们完成实际下载。
</p>
</section>
<div v-if="health && !health.mongo.ok" class="note warn mt rise rise-2">
MongoDB 未连接,搜索记录与种子缓存不会被保存(其余功能可用)。启动本地 MongoDB 后刷新即可:
<span class="mono">{{ health.mongo.uri }}</span>
</div>
<div class="tabs rise rise-2">
<button :class="{ on: tab === 'search' }" @click="tab = 'search'">关键词搜索</button>
<button :class="{ on: tab === 'file' }" @click="tab = 'file'">上传 .torrent</button>
<button :class="{ on: tab === 'magnet' }" @click="tab = 'magnet'">磁力链</button>
</div>
<!-- ============ 关键词搜索 ============ -->
<div v-show="tab === 'search'" class="stack mt rise rise-3">
<form class="panel" @submit.prevent="doSearch()">
<div class="panel-hd">
<span class="label">QUERY</span>
<span class="spacer" />
<span class="mono small mute">{{ health?.searchProxy === '已配置' ? '经代理访问' : '直连访问' }}</span>
</div>
<div class="panel-bd stack sm">
<div class="row">
<input v-model="keyword" class="input" placeholder="输入片名 / 剧名 / 关键词,回车搜索" autofocus />
<button class="btn btn-primary" type="submit" :disabled="searching">
<span v-if="searching" class="spin" />
{{ searching ? '搜索中' : '搜索' }}
</button>
</div>
<div class="row wrap">
<span class="label">搜索源</span>
<label v-for="p in health?.providers || []" :key="p.id" class="check">
<input v-model="activeProviders" type="checkbox" :value="p.id" />
{{ p.label }}
</label>
</div>
</div>
</form>
<div v-if="response" class="row wrap small">
<span class="mono dim">
{{ response.results.length }} 条结果 · {{ response.durationMs }}ms ·
{{ response.stored ? '已写入 MongoDB' : 'MongoDB 未连接,未记录' }}
</span>
<span class="spacer" />
<span v-for="r in response.reports" :key="r.id" class="tag" :class="r.ok ? 'ok' : 'err'" :title="r.error || ''">
{{ r.label }} {{ r.ok ? `${r.count} 条 / ${r.ms}ms` : r.error }}
</span>
</div>
<div v-if="response" class="panel rise">
<div class="panel-bd tight">
<table v-if="response.results.length" class="table">
<thead>
<tr>
<th style="width: 46%">名称</th>
<th class="hide-sm">来源</th>
<th class="right">大小</th>
<th class="right hide-sm">文件</th>
<th class="right">做种</th>
<th class="right hide-sm">下载</th>
<th class="right hide-sm">发布</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in pagedResults" :key="item.infoHash">
<td class="name">{{ item.name }}</td>
<td class="hide-sm"><span class="tag">{{ item.sourceLabel }}</span></td>
<td class="num right">{{ formatBytes(item.size) }}</td>
<td class="num right hide-sm dim">{{ item.fileCount ?? '—' }}</td>
<td class="num right ok">{{ item.seeders ?? '—' }}</td>
<td class="num right warn hide-sm">{{ item.leechers ?? '—' }}</td>
<td class="num right dim hide-sm">{{ formatRelative(item.addedAt) }}</td>
<td class="right">
<NuxtLink class="btn btn-sm" :to="previewLink(item)">预览</NuxtLink>
</td>
</tr>
</tbody>
</table>
<div v-else class="empty">没有结果。换个关键词,或在「设置」里配置代理后再试。</div>
</div>
<div v-if="response && response.results.length > pageSize" class="panel-ft row center py-md">
<button class="btn btn-sm btn-ghost" :disabled="page <= 1" @click="page--">上一页</button>
<span class="mono small dim mx-lg">第 {{ page }} / {{ totalPages }} 页</span>
<button class="btn btn-sm btn-ghost" :disabled="page >= totalPages" @click="page++">下一页</button>
</div>
</div>
<div v-else-if="!searching" class="panel">
<div class="empty">输入关键词开始搜索</div>
</div>
</div>
<!-- ============ 上传 .torrent ============ -->
<div v-show="tab === 'file'" class="stack mt rise rise-3">
<div
class="drop panel"
:class="{ over: dropping }"
@click="fileInput?.click()"
@dragover.prevent="dropping = true"
@dragleave.prevent="dropping = false"
@drop.prevent="onDrop"
>
<div class="big">{{ parsing ? '解析中…' : '把 .torrent 文件拖到这里,或点击选择' }}</div>
<p class="mute small">文件只在服务端解析出文件清单,不会上传到任何第三方</p>
<input ref="fileInput" type="file" accept=".torrent,application/x-bittorrent" hidden @change="onFileChange" />
</div>
<TorrentDetail v-if="parsed" :torrent="parsed" :can-resolve="true" />
</div>
<!-- ============ 磁力链 ============ -->
<div v-show="tab === 'magnet'" class="stack mt rise rise-3">
<div class="panel">
<div class="panel-hd"><span class="label">MAGNET URI</span></div>
<div class="panel-bd stack sm">
<textarea v-model="magnetInput" class="textarea" placeholder="magnet:?xt=urn:btih:…" />
<div class="row">
<button class="btn btn-primary" :disabled="parsing" @click="parseMagnet">
<span v-if="parsing" class="spin" />
解析磁力链
</button>
<span class="mute small">磁力链需要联网从 DHT / tracker 拉取元数据,才能看到文件清单</span>
</div>
</div>
</div>
<TorrentDetail v-if="parsed" :torrent="parsed" :can-resolve="true" />
</div>
</div>
</template>
+339
View File
@@ -0,0 +1,339 @@
<script setup lang="ts">
import type { BtClientConfig, ClientsResponse, ClientStatus } from '~/types/api'
interface DownloadItem {
_id: string
name: string
clientName: string
clientType: string
downloadDir: string | null
ok: boolean
message: string
createdAt: string
}
const health = useHealth()
const loading = ref(true)
const saving = ref(false)
const clients = ref<ClientsResponse | null>(null)
const configs = ref<BtClientConfig[]>([])
const statuses = ref<ClientStatus[]>([])
const downloads = ref<DownloadItem[]>([])
const testState = ref<Record<string, { testing?: boolean; ok?: boolean; error?: string; version?: string; endpoint?: string }>>({})
const enabledProviders = ref<string[]>([])
const searchProxy = ref('')
const searchTimeout = ref(12000)
const metadataTimeout = ref(30000)
async function load() {
loading.value = true
try {
const [c, , d] = await Promise.all([
$fetch<ClientsResponse>('/api/clients'),
refreshHealth(),
$fetch<{ items: DownloadItem[] }>('/api/downloads', { query: { limit: 25 } })
])
clients.value = c
configs.value = JSON.parse(JSON.stringify(c.configs))
statuses.value = c.statuses
downloads.value = d.items
enabledProviders.value = c.settings.enabledProviders
searchProxy.value = c.settings.searchProxy
searchTimeout.value = c.settings.searchTimeout
metadataTimeout.value = c.settings.metadataTimeout
} catch (err) {
pushToast('err', '读取配置失败', errorMessage(err))
} finally {
loading.value = false
}
}
const adapters = computed(() => clients.value?.adapters || [])
const statusOf = (id: string) => statuses.value.find((s) => s.id === id)
function statusPill(cfg: BtClientConfig): ClientStatus | undefined {
return statusOf(cfg.id)
}
function addClient() {
const seq = configs.value.length + 1
configs.value.push({
id: `client-${Date.now().toString(36)}`,
type: 'qbittorrent',
name: `新下载器 ${seq}`,
host: '127.0.0.1',
port: 8080,
username: 'admin',
password: '',
downloadDir: '',
enabled: true,
useHttps: false
})
}
function removeClient(index: number) {
configs.value.splice(index, 1)
}
function onTypeChange(cfg: BtClientConfig) {
const adapter = adapters.value.find((a) => a.type === cfg.type)
if (adapter) {
cfg.port = adapter.defaultPort
cfg.path = adapter.defaultPath || undefined
}
}
async function testClient(cfg: BtClientConfig) {
testState.value = { ...testState.value, [cfg.id]: { testing: true } }
try {
const res = await $fetch<{ ok: boolean; online: boolean; version: string | null; endpoint: string; error?: string }>(
'/api/clients/test',
{ method: 'POST', body: { client: cfg } }
)
testState.value = {
...testState.value,
[cfg.id]: { ok: res.ok, version: res.version || undefined, endpoint: res.endpoint, error: res.error }
}
pushToast(res.ok ? 'ok' : 'err', res.ok ? `${cfg.name} 连接正常` : `${cfg.name} 连接失败`, res.error || undefined)
} catch (err) {
const msg = errorMessage(err)
testState.value = { ...testState.value, [cfg.id]: { ok: false, error: msg } }
pushToast('err', '测试失败', msg)
}
}
async function save() {
saving.value = true
try {
await $fetch('/api/clients', {
method: 'PUT',
body: {
btClients: configs.value,
enabledProviders: enabledProviders.value,
searchProxy: searchProxy.value,
searchTimeout: searchTimeout.value,
metadataTimeout: metadataTimeout.value
}
})
pushToast('ok', '配置已保存')
await load()
} catch (err) {
pushToast('err', '保存失败', errorMessage(err))
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<template>
<div class="page">
<section class="rise">
<p class="label">SETTINGS · ADAPTERS</p>
<h1>搜索源与本机下载器</h1>
<p class="sub">
所有配置写在 MongoDB 的 settings 集合里(没连库时回落到 .env)。下载器地址默认指向本机,如果服务部署在别的机器上,改成本机可访问的地址即可。
</p>
</section>
<div class="row mt rise rise-2">
<button class="btn btn-primary" :disabled="saving || loading" @click="save">
<span v-if="saving" class="spin" />{{ saving ? '保存中…' : '保存全部配置' }}
</button>
<button class="btn btn-ghost" :disabled="loading" @click="load">重新加载</button>
<span v-if="health && !health.mongo.ok" class="small warn">MongoDB 未连接,配置只能在本次运行内生效</span>
</div>
<!-- ============ 搜索源 ============ -->
<div class="panel mt rise rise-2">
<div class="panel-hd"><span class="label">SEARCH SOURCES</span></div>
<div class="panel-bd stack">
<div class="row wrap">
<span class="label">启用的搜索源</span>
<label v-for="p in health?.providers || []" :key="p.id" class="check">
<input v-model="enabledProviders" type="checkbox" :value="p.id" />
{{ p.label }}
<span class="mute small">{{ p.homepage }}</span>
</label>
</div>
<div class="grid-3">
<label class="field">
<span class="label">HTTP 代理(搜索源用)</span>
<input v-model="searchProxy" class="input" placeholder="http://127.0.0.1:7890 / 留空直连" />
</label>
<label class="field">
<span class="label">搜索超时(毫秒)</span>
<input v-model.number="searchTimeout" class="input" type="number" min="1000" max="60000" step="500" />
</label>
<label class="field">
<span class="label">元数据解析超时(毫秒)</span>
<input v-model.number="metadataTimeout" class="input" type="number" min="3000" max="180000" step="1000" />
</label>
</div>
<div class="note">
公开磁力站在部分网络环境下不可直连,配好代理后「设置 → 保存」再回搜索页重试即可。代理只作用于搜索源请求,不影响下载器通信。
</div>
</div>
</div>
<!-- ============ 下载器 ============ -->
<div class="panel mt rise rise-3">
<div class="panel-hd">
<span class="label">LOCAL BT CLIENTS</span>
<span class="mono small" :class="statuses.some((s) => s.online) ? 'ok' : 'err'">
{{ statuses.filter((s) => s.online).length }}/{{ statuses.length }} 在线
</span>
<span class="spacer" />
<button class="btn btn-sm btn-ghost" @click="addClient">+ 新增下载器</button>
</div>
<div class="panel-bd stack">
<div v-for="(cfg, index) in configs" :key="cfg.id" class="panel" style="background: var(--panel-2)">
<div class="panel-hd" style="background: var(--panel-3)">
<label class="check">
<input v-model="cfg.enabled" type="checkbox" />
启用
</label>
<span class="spacer" />
<span v-if="statusPill(cfg)" class="pill" :class="statusPill(cfg)!.online ? 'on' : 'off'" :title="statusPill(cfg)!.error || ''">
<i class="dot" />
{{ statusPill(cfg)!.online ? `在线 ${statusPill(cfg)!.version || ''}` : '离线' }}
</span>
<button class="btn btn-sm" :disabled="testState[cfg.id]?.testing" @click="testClient(cfg)">
<span v-if="testState[cfg.id]?.testing" class="spin" />测试连接
</button>
<button class="btn btn-sm btn-danger" @click="removeClient(index)">删除</button>
</div>
<div class="panel-bd stack sm">
<div class="grid-4">
<label class="field">
<span class="label">名称</span>
<input v-model="cfg.name" class="input" />
</label>
<label class="field">
<span class="label">类型</span>
<select v-model="cfg.type" class="select" @change="onTypeChange(cfg)">
<option v-for="a in adapters" :key="a.type" :value="a.type">{{ a.label }}</option>
</select>
</label>
<label class="field">
<span class="label">主机</span>
<input v-model="cfg.host" class="input" />
</label>
<label class="field">
<span class="label">端口</span>
<input v-model.number="cfg.port" class="input" type="number" />
</label>
</div>
<div class="grid-4">
<label class="field">
<span class="label">RPC 路径(可空)</span>
<input v-model="cfg.path" class="input" :placeholder="adapters.find((a) => a.type === cfg.type)?.defaultPath || '默认'" />
</label>
<label class="field">
<span class="label">用户名{{ cfg.type === 'aria2' ? '(aria2 用不到)' : '' }}</span>
<input v-model="cfg.username" class="input" :disabled="cfg.type === 'aria2'" />
</label>
<label class="field">
<span class="label">{{ cfg.type === 'aria2' ? 'RPC 密钥 --rpc-secret' : '密码' }}</span>
<input
v-model="cfg.password"
class="input"
:type="cfg.type === 'aria2' ? 'text' : 'password'"
:placeholder="cfg.type === 'aria2' ? 'aria2 的 --rpc-secret 值' : ''"
/>
</label>
<label class="field">
<span class="label">默认下载目录</span>
<input v-model="cfg.downloadDir" class="input" placeholder="留空用客户端默认" />
</label>
</div>
<div class="row">
<label class="check">
<input v-model="cfg.useHttps" type="checkbox" />
使用 HTTPS
</label>
<span v-if="testState[cfg.id]?.error" class="small err">{{ testState[cfg.id]?.error }}</span>
<span v-else-if="testState[cfg.id]?.ok" class="small ok">
可达 {{ testState[cfg.id]?.endpoint }} {{ testState[cfg.id]?.version ? `· v${testState[cfg.id]?.version}` : '' }}
</span>
</div>
<div class="note">
<span v-if="cfg.type === 'qbittorrent'">
qBittorrent:开启「工具 → 选项 → Web UI」,默认端口 8080;首次访问会让客户端把本机 IP 加入白名单。
</span>
<span v-else-if="cfg.type === 'transmission'">
Transmission:开启远程控制,默认端口 9091,RPC 路径 <span class="mono">/transmission/rpc</span>;没设用户名密码就留空。
</span>
<span v-else>
aria2:启动时加 <span class="mono">--enable-rpc --rpc-listen-all=true --rpc-secret=你的密钥</span>,把密钥填到「RPC 密钥」框。
</span>
</div>
</div>
</div>
<div v-if="!configs.length" class="empty">还没有下载器,点右上角「新增下载器」</div>
</div>
</div>
<!-- ============ 下载记录 ============ -->
<div class="panel mt rise rise-4">
<div class="panel-hd"><span class="label">HANDOFF LOG</span><span class="spacer" /><span class="mute small">最近 25 条投递记录</span></div>
<div class="panel-bd tight">
<table v-if="downloads.length" class="table">
<thead>
<tr>
<th style="width: 44%">任务</th>
<th>下载器</th>
<th class="hide-sm">目录</th>
<th>结果</th>
<th class="right">时间</th>
</tr>
</thead>
<tbody>
<tr v-for="d in downloads" :key="d._id">
<td class="name">{{ d.name }}</td>
<td><span class="tag">{{ d.clientName || d.clientId }}</span></td>
<td class="small mute hide-sm">{{ d.downloadDir || '默认' }}</td>
<td>
<span class="tag" :class="d.ok ? 'ok' : 'err'">{{ d.ok ? '成功' : '失败' }}</span>
<span class="small mute" style="display: block">{{ d.message }}</span>
</td>
<td class="num right dim">{{ formatRelative(d.createdAt) }}</td>
</tr>
</tbody>
</table>
<div v-else class="empty">还没有投递记录</div>
</div>
</div>
<!-- ============ 环境信息 ============ -->
<div class="panel mt rise rise-4">
<div class="panel-hd"><span class="label">RUNTIME</span></div>
<div class="panel-bd">
<dl class="kv">
<dt>MongoDB</dt>
<dd>
<span :class="health?.mongo.ok ? 'ok' : 'err'">{{ health?.mongo.ok ? '已连接' : '未连接' }}</span>
<span class="mono small dim"> · {{ health?.mongo.uri }} / {{ health?.mongo.db }}</span>
<div v-if="health?.mongo.error" class="small err mono break">{{ health.mongo.error }}</div>
</dd>
<dt>磁力解析</dt>
<dd :class="health?.webTorrent ? 'ok' : 'err'">
{{ health?.webTorrent ? 'webtorrent 可用,可通过 DHT/tracker 取文件清单' : 'webtorrent 不可用,磁力链只能显示 infoHash' }}
</dd>
<dt>搜索源</dt>
<dd class="mono small">{{ health?.providers?.map((p) => p.label).join(' / ') }}</dd>
<dt>代理</dt>
<dd class="mono small">{{ health?.searchProxy }}</dd>
</dl>
</div>
</div>
</div>
</template>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import type { TorrentView } from '~/types/api'
const route = useRoute()
const infoHash = computed(() => String(route.params.infohash || '').toLowerCase())
const magnet = computed(() => String(route.query.magnet || ''))
const { data, error } = await useFetch<{ torrent: TorrentView; source: string }>(
() => `/api/torrent/${infoHash.value}`,
{ query: { magnet }, watch: [infoHash, magnet] }
)
useHead(() => ({ title: data.value?.torrent?.name ? `${data.value.torrent.name} · 种子预览` : '种子预览' }))
refreshHealth()
</script>
<template>
<div class="page">
<div class="row small rise">
<NuxtLink to="/" class="btn btn-sm btn-ghost">← 返回搜索</NuxtLink>
<span class="mono mute truncate">{{ infoHash }}</span>
<span v-if="data?.source" class="tag">{{ data.source === 'database' ? '来自 MongoDB 缓存' : '来自链接参数' }}</span>
</div>
<div v-if="error" class="panel mt rise rise-2">
<div class="panel-bd stack sm">
<span class="label">NOT FOUND</span>
<h2>拿不到这个种子的信息</h2>
<p class="sub">{{ errorMessage(error) }}</p>
<p class="mute small">
如果你是直接从外部链接进来的,请带上 magnet 参数,例如
<span class="mono">/torrent/&lt;infoHash&gt;?magnet=magnet:?xt=urn:btih:…</span>
</p>
<div><NuxtLink to="/" class="btn btn-primary">回到搜索页</NuxtLink></div>
</div>
</div>
<div v-else-if="data?.torrent" class="mt rise rise-2">
<TorrentDetail :torrent="data.torrent" :can-resolve="true" />
</div>
<div v-else class="empty mt">
<span class="spin" /> 加载中…
</div>
</div>
</template>
+48
View File
@@ -0,0 +1,48 @@
import { locale, translateDocument } from '~/utils/i18n'
export default defineNuxtPlugin(() => {
const applyLangAttr = () => {
if (import.meta.client) {
document.documentElement.lang = locale.value === 'zh' ? 'zh-CN' : 'en'
}
}
const run = () => {
translateDocument()
applyLangAttr()
}
// 初次翻译:等 DOM 就绪
if (import.meta.client) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run, { once: true })
} else {
run()
}
}
// 客户端渲染 / 数据刷新会改变 DOM,用 MutationObserver 兜底重新翻译
if (import.meta.client) {
let scheduled = false
const schedule = () => {
if (scheduled) return
scheduled = true
requestAnimationFrame(() => {
scheduled = false
translateDocument()
})
}
const observer = new MutationObserver(schedule)
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
// 路由切换
const router = useRouter()
router.afterEach(() => nextTick(schedule))
// 语言切换时整体重翻
watch(locale, () => {
applyLangAttr()
translateDocument()
})
}
})
+104
View File
@@ -0,0 +1,104 @@
export interface TorrentFileEntry {
path: string
length: number
}
export interface TorrentView {
infoHash: string
name: string
size: number
files: TorrentFileEntry[] | null
fileCount: number | null
trackers: string[]
magnet: string
source?: string
sourceLabel?: string
sourceUrl?: string | null
category?: string | null
seeders?: number | null
leechers?: number | null
comment?: string | null
creationDate?: string | null
resolvedAt?: string | null
resolveError?: string | null
/** 服务端是否保存了原始 .torrent 文件,可提供下载 */
hasFile?: boolean
}
export interface SearchResult extends TorrentView {
sourceLabel: string
}
export interface ProviderReport {
id: string
label: string
ok: boolean
count: number
ms: number
error: string | null
}
export interface SearchResponse {
query: string
durationMs: number
results: SearchResult[]
reports: ProviderReport[]
stored: boolean
}
export interface ClientStatus {
id: string
type: string
name: string
label: string
endpoint: string
online: boolean
version?: string
error?: string
}
export interface BtClientConfig {
id: string
type: 'qbittorrent' | 'transmission' | 'aria2'
name: string
host: string
port: number
path?: string
username?: string
password?: string
downloadDir?: string
useHttps?: boolean
enabled?: boolean
}
export interface ClientAdapterMeta {
type: string
label: string
defaultPort: number
defaultPath: string | null
}
export interface ClientsResponse {
adapters: ClientAdapterMeta[]
configs: BtClientConfig[]
statuses: ClientStatus[]
online: number
total: number
settings: {
searchProxy: string
searchTimeout: number
metadataTimeout: number
enabledProviders: string[]
}
}
export interface HealthResponse {
ok: boolean
time: string
mongo: { ok: boolean; uri: string; db: string; error: string | null }
webTorrent: boolean
providers: { id: string; label: string; homepage: string }[]
enabledProviders: string[]
searchProxy: string
clients: { id: string; type: string; name: string; endpoint: string; enabled: boolean }[]
}
+76
View File
@@ -0,0 +1,76 @@
export function formatBytes(bytes: number | null | undefined, digits = 2): string {
const value = Number(bytes)
if (!value || value < 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
let index = 0
let n = value
while (n >= 1024 && index < units.length - 1) {
n /= 1024
index++
}
return `${n.toFixed(index === 0 ? 0 : digits)} ${units[index]}`
}
export function formatDate(input: string | number | Date | null | undefined): string {
if (!input) return '—'
const date = input instanceof Date ? input : new Date(input)
if (Number.isNaN(date.getTime())) return '—'
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(
date.getMinutes()
)}`
}
export function formatRelative(input: string | number | Date | null | undefined): string {
if (!input) return '—'
const date = input instanceof Date ? input : new Date(input)
const diff = Date.now() - date.getTime()
if (Number.isNaN(diff)) return '—'
const min = Math.floor(diff / 60000)
if (min < 1) return '刚刚'
if (min < 60) return `${min} 分钟前`
const hour = Math.floor(min / 60)
if (hour < 24) return `${hour} 小时前`
const day = Math.floor(hour / 24)
if (day < 30) return `${day} 天前`
return formatDate(date)
}
export function formatNumber(value: number | null | undefined): string {
if (value === null || value === undefined) return '—'
return value.toLocaleString('en-US')
}
export function filePercent(length: number, total: number): string {
if (!total) return '0%'
const pct = (length / total) * 100
return pct >= 10 ? `${pct.toFixed(0)}%` : `${pct.toFixed(1)}%`
}
/** 把扁平的文件路径拆成 目录 + 文件名,便于展示层级 */
export function splitPath(path: string): { dir: string; name: string } {
const idx = path.lastIndexOf('/')
if (idx < 0) return { dir: '', name: path }
return { dir: path.slice(0, idx + 1), name: path.slice(idx + 1) }
}
/** 可以尝试在线预览的图片扩展名 */
export const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif']
/** 与服务端 preview 接口保持一致:超过这个大小就不做在线预览 */
export const MAX_IMAGE_PREVIEW_BYTES = 12 * 1024 * 1024
export function isImagePath(path: string): boolean {
const idx = path.lastIndexOf('.')
if (idx < 0) return false
return IMAGE_EXTENSIONS.includes(path.slice(idx + 1).toLowerCase())
}
export async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
return false
}
}
+232
View File
@@ -0,0 +1,232 @@
// 中文(源文) → 英文 映射。
// 站点模板以中文书写,默认显示英文;切换到中文时回退到源文(即中文)。
// 键已做空格归一化(连续空白合并为单个空格),便于整句匹配。
export const zh2en: Record<string, string> = {
// ===== 顶栏 / 布局 =====
'预览台 / 投递器': 'Preview / Handoff',
'搜索': 'Search',
'任务': 'Tasks',
'历史': 'History',
'设置': 'Settings',
'MONGO 未连接': 'MONGO disconnected',
'磁力解析就绪': 'Magnet parsing ready',
'磁力解析不可用': 'Magnet parsing unavailable',
'本地自托管 · 单进程 Nuxt 单体应用': 'Self-hosted · single-process Nuxt app',
'种子只做解析与预览,文件内容由你自己的下载器拉取': 'Torrents are only parsed and previewed; file contents are fetched by your own download client',
// ===== 种子详情页 [infohash] =====
'← 返回搜索': '← Back to search',
'来自 MongoDB 缓存': 'From MongoDB cache',
'来自链接参数': 'From link parameter',
'拿不到这个种子的信息': "Can't fetch info for this torrent",
'如果你是直接从外部链接进来的,请带上 magnet 参数,例如': 'If you came from an external link, include a magnet parameter, e.g.',
'回到搜索页': 'Back to search',
'加载中…': 'Loading…',
// ===== 首页 index =====
'SEARCH · PARSE · HANDOFF': 'SEARCH · PARSE · HANDOFF',
'搜种子、看清里面有什么,再丢给本机下载器': 'Search torrents, see what is inside, then hand off to your local downloader',
'qBittorrent / Transmission / aria2,由它们完成实际下载。': 'qBittorrent / Transmission / aria2, which do the actual downloading.',
'经代理访问': 'Via proxy',
'直连访问': 'Direct connection',
'请输入关键词': 'Please enter a keyword',
'没有搜到结果': 'No results found',
'可以换个关键词,或检查代理设置': 'Try another keyword or check proxy settings',
'没有结果。换个关键词,或在「设置」里配置代理后再试。': 'No results. Try another keyword, or configure a proxy in Settings.',
'上一页': 'Previous',
'下一页': 'Next',
'预览': 'Preview',
'输入关键词开始搜索': 'Enter a keyword to search',
'关键词搜索': 'Keyword search',
'上传 .torrent': 'Upload .torrent',
'磁力链': 'Magnet link',
'把 .torrent 文件拖到这里,或点击选择': 'Drag a .torrent file here, or click to choose',
'文件只在服务端解析出文件清单,不会上传到任何第三方': 'Files are parsed into a file list on the server only; nothing is uploaded to any third party',
'解析中…': 'Resolving…',
'磁力链需要联网从 DHT / tracker 拉取元数据,才能看到文件清单': 'A magnet link needs to fetch metadata from DHT / trackers to show the file list',
'解析磁力链': 'Resolve magnet',
'名称': 'Name',
'来源': 'Source',
'大小': 'Size',
'文件': 'Files',
'做种': 'Seeders',
'下载': 'Leechers',
'发布': 'Added',
'操作': 'Actions',
'搜索源': 'Search sources',
// ===== 下载任务 downloads =====
'实时下载状态': 'Live download status',
'直接从你配置的 qBittorrent / Transmission / aria2 客户端拉取的实时数据。': 'Real-time data pulled directly from your configured qBittorrent / Transmission / aria2 clients.',
'活跃任务总数': 'Total active tasks',
'在线下载器': 'Online clients',
'刷新频率': 'Refresh rate',
'每 5 秒自动同步': 'Auto-sync every 5s',
'手动刷新': 'Manual refresh',
'同步中…': 'Syncing…',
'任务名称': 'Task name',
'进度': 'Progress',
'下载速度': 'DL speed',
'剩余时间': 'ETA',
'状态': 'Status',
'暂无任务': 'No tasks',
'还没有配置任何下载器,或者所有下载器都已禁用。': 'No download clients configured, or all are disabled.',
'去设置': 'Go to settings',
'下载中': 'Downloading',
'做种中': 'Seeding',
'已暂停': 'Paused',
'错误': 'Error',
'排队中': 'Queued',
'校验中': 'Checking',
'已完成': 'Completed',
'未知': 'Unknown',
'在线': 'online',
'离线': 'offline',
// ===== 历史 history =====
'搜索历史与种子库': 'Search history & torrent library',
'每次搜索都会落到 MongoDB 的 searches 集合;搜到和解析过的种子按 infoHash 去重存在 torrents 集合。': "Every search is stored in MongoDB's searches collection; found and parsed torrents are deduplicated by infoHash in the torrents collection.",
'MongoDB 未连接,历史记录不可用。启动本地 MongoDB 后刷新本页即可。': 'MongoDB not connected, history unavailable. Start local MongoDB and refresh this page.',
'搜索总次数': 'Total searches',
'库中种子数': 'Torrents in library',
'高频关键词': 'Top keywords',
'未连接': 'Disconnected',
'正常': 'OK',
'点击直接再搜一次': 'Click to search again',
'刷新': 'Refresh',
'刷新中…': 'Refreshing…',
'清空': 'Clear all',
'关键词': 'Keyword',
'结果数': 'Results',
'耗时': 'Time',
'时间': 'Time',
'删除': 'Delete',
'还没有搜索记录': 'No search history yet',
'加载中…': 'Loading…',
'更新': 'Updated',
'打开': 'Open',
'库里还没有种子': 'No torrents in library yet',
'已删除该条记录': 'Record deleted',
'删除失败': 'Delete failed',
'确定清空全部搜索历史?': 'Clear all search history?',
'清空失败': 'Clear failed',
// ===== 种子详情 TorrentDetail =====
'未知来源': 'Unknown source',
'磁力搜索': 'Magnet Search',
'本地上传': 'Local upload',
'数据库': 'Database',
'文件清单已解析': 'File list resolved',
'磁力链已复制到剪贴板': 'Magnet link copied to clipboard',
'复制失败,请手动选中复制': 'Copy failed, please select and copy manually',
'解析失败': 'Resolve failed',
'总大小': 'Total size',
'文件数': 'File count',
'做种 / 下载': 'Seeders / Leechers',
'发布时间': 'Published',
'展开': 'Expand',
'收起': 'Collapse',
'备注': 'Comment',
'复制磁力链': 'Copy magnet',
'下载 .torrent': 'Download .torrent',
'打开来源页面': 'Open source page',
// ===== SendPanel =====
'SEND TO LOCAL CLIENT': 'SEND TO LOCAL CLIENT',
'探测中…': 'Probing…',
'重新探测': 'Re-probe',
'配置': 'Configure',
'还没有配置任何下载器。去「设置」里填 qBittorrent / Transmission / aria2 的地址和账号密码。': 'No download client configured. Go to Settings and provide qBittorrent / Transmission / aria2 address and credentials.',
'目标下载器': 'Target client',
'下载目录(留空用客户端默认)': 'Download dir (blank = client default)',
'该下载器当前不可用': 'This client is currently unavailable',
'投递到本机下载器': 'Send to local client',
'投递中…': 'Sending…',
'种子交给本机客户端下载,服务器不中转文件内容': 'The torrent is downloaded by your local client; the server does not proxy file contents',
'读取下载器配置失败': 'Failed to read client config',
'投递成功': 'Delivered',
'投递失败': 'Delivery failed',
// ===== FileTable =====
'正在通过 DHT / tracker 获取种子元数据,通常几秒到几十秒…': 'Fetching torrent metadata via DHT / tracker, usually takes seconds to tens of seconds…',
'这个种子只有 infoHash,还没有文件清单。可以点击下面的按钮在线拉取元数据(需要本机能连上 DHT / tracker)。': 'This torrent only has an infoHash and no file list yet. Click the button below to fetch metadata online (requires the server to reach DHT / trackers).',
'在线解析文件清单': 'Resolve file list online',
'FILE LIST': 'FILE LIST',
'过滤文件名…': 'Filter filenames…',
'按大小': 'By size',
'按名称': 'By name',
'路径': 'Path',
'占比': 'Ratio',
'过大': 'Too large',
'结果较多,表格只展示前 500 条,可用过滤框缩小范围': 'Too many results; table shows the first 500 rows. Use the filter to narrow down',
// ===== ImagePreview =====
'IMAGE': 'IMAGE',
'关闭': 'Close',
'正在从做种节点拉取这张图片,取决于对方的上传速度…': 'Fetching this image from seeding peers, depending on their upload speed…',
'重试': 'Retry',
'直接打开图片地址': 'Open image URL directly',
'上一张': 'Previous',
'下一张': 'Next',
// ===== 设置 settings =====
'搜索源与本机下载器': 'Search sources & local clients',
'所有配置写在 MongoDB 的 settings 集合里(没连库时回落到 .env)。下载器地址默认指向本机,如果服务部署在别的机器上,改成本机可访问的地址即可。': "All config is stored in MongoDB's settings collection (falls back to .env when the DB is down). Downloader addresses point to localhost by default; change them to whatever address is reachable.",
'保存中…': 'Saving…',
'保存全部配置': 'Save all settings',
'重新加载': 'Reload',
'MongoDB 未连接,配置只能在本次运行内生效': 'MongoDB not connected; config only takes effect for this session',
'启用的搜索源': 'Enabled search sources',
'HTTP 代理(搜索源用)': 'HTTP proxy (for search sources)',
'搜索超时(毫秒)': 'Search timeout (ms)',
'元数据解析超时(毫秒)': 'Metadata parse timeout (ms)',
'公开磁力站在部分网络环境下不可直连,配好代理后「设置 → 保存」再回搜索页重试即可。代理只作用于搜索源请求,不影响下载器通信。': 'Public magnet sites may be unreachable on some networks. After setting a proxy, Save in Settings then retry from the search page. The proxy only affects search requests, not downloader communication.',
'LOCAL BT CLIENTS': 'LOCAL BT CLIENTS',
'新增下载器': 'Add client',
'启用': 'Enable',
'测试连接': 'Test connection',
'类型': 'Type',
'主机': 'Host',
'端口': 'Port',
'RPC 路径(可空)': 'RPC path (optional)',
'默认': 'default',
'用户名': 'Username',
'(aria2 用不到)': '(not used by aria2)',
'RPC 密钥 --rpc-secret': 'RPC secret --rpc-secret',
'密码': 'Password',
'aria2 的 --rpc-secret 值': "aria2's --rpc-secret value",
'默认下载目录': 'Default download dir',
'留空用客户端默认': 'Blank = client default',
'使用 HTTPS': 'Use HTTPS',
'可达': 'Reachable',
'qBittorrent:开启「工具 → 选项 → Web UI」,默认端口 8080;首次访问会让客户端把本机 IP 加入白名单。': 'qBittorrent: enable "Tools → Options → Web UI", default port 8080; first visit whitelists your local IP in the client.',
'Transmission:开启远程控制,默认端口 9091,RPC 路径 /transmission/rpc;没设用户名密码就留空。': 'Transmission: enable remote control, default port 9091, RPC path /transmission/rpc; leave blank if no username/password.',
'aria2:启动时加 --enable-rpc --rpc-listen-all=true --rpc-secret=你的密钥,把密钥填到「RPC 密钥」框。': 'aria2: start with --enable-rpc --rpc-listen-all=true --rpc-secret=YOUR_SECRET, and put the secret in the RPC secret field.',
'还没有下载器,点右上角「新增下载器」': 'No downloaders yet. Click "Add client" at the top right.',
'HANDOFF LOG': 'HANDOFF LOG',
'最近 25 条投递记录': 'Last 25 handoff records',
'下载器': 'Client',
'目录': 'Dir',
'结果': 'Result',
'成功': 'Success',
'失败': 'Failed',
'还没有投递记录': 'No handoff records yet',
'RUNTIME': 'RUNTIME',
'已连接': 'Connected',
'磁力解析': 'Magnet parsing',
'webtorrent 可用,可通过 DHT/tracker 取文件清单': 'webtorrent available; can fetch file list via DHT/tracker',
'webtorrent 不可用,磁力链只能显示 infoHash': 'webtorrent unavailable; magnet links can only show infoHash',
// ===== 输入框占位符 =====
'输入片名 / 剧名 / 关键词,回车搜索': 'Movie / show / keyword, press Enter to search',
'http://127.0.0.1:7890 / 留空直连': 'http://127.0.0.1:7890 / blank for direct'
}
export const en2zh: Record<string, string> = Object.fromEntries(
Object.entries(zh2en).map(([zh, en]) => [en, zh])
)
/** 归一化:折叠所有连续空白为单个空格,并去掉首尾空白 */
export function normalizeWs(s: string): string {
return s.trim().replace(/\s+/g, ' ')
}
+65
View File
@@ -0,0 +1,65 @@
import { en2zh, normalizeWs, zh2en } from './i18n-dict'
export type Locale = 'en' | 'zh'
const STORAGE_KEY = 'dsl-locale'
function readInitialLocale(): Locale {
if (import.meta.client) {
const v = localStorage.getItem(STORAGE_KEY)
if (v === 'en' || v === 'zh') return v
}
return 'en' // 默认英文
}
// 模块级响应式语言状态(默认英文)
export const locale = ref<Locale>(readInitialLocale())
export function setLocale(next: Locale) {
locale.value = next
if (import.meta.client) {
localStorage.setItem(STORAGE_KEY, next)
document.documentElement.lang = next === 'zh' ? 'zh-CN' : 'en'
translateDocument()
}
}
/** 把一段中文(或英文)文本按当前语言翻译;找不到映射时原样返回 */
export function translateText(input: string): string {
if (!input) return input
const key = normalizeWs(input)
const map = locale.value === 'zh' ? en2zh : zh2en
return map[key] ?? input
}
/** 遍历 root 下的文本节点与常见属性,按当前语言就地翻译 */
export function walkAndTranslate(root: Node = document.body) {
if (!root || !import.meta.client) return
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
const fixes: { node: Text; text: string }[] = []
let n: Node | null
while ((n = walker.nextNode())) {
const t = (n as Text).textContent || ''
if (!t.trim()) continue
const tr = translateText(t)
if (tr !== t) fixes.push({ node: n as Text, text: tr })
}
for (const f of fixes) f.node.textContent = f.text
// 翻译属性(title / placeholder / alt)
if (root instanceof HTMLElement) {
root.querySelectorAll<HTMLElement>('*').forEach((el) => {
for (const attr of ['title', 'placeholder', 'alt']) {
const v = el.getAttribute(attr)
if (!v) continue
const tr = translateText(v)
if (tr !== v) el.setAttribute(attr, tr)
}
})
}
}
export function translateDocument() {
if (import.meta.client) walkAndTranslate(document.body)
}
+29
View File
@@ -0,0 +1,29 @@
services:
app:
build: .
image: bt-preview:latest
container_name: bt-preview
ports:
- "3000:3000"
environment:
MONGODB_URI: mongodb://mongo:27017
MONGODB_DB: torrent_console
SEARCH_PROXY: ""
SEARCH_TIMEOUT: "12000"
METADATA_TIMEOUT: "30000"
APIBAY_BASE: https://apibay.org
NYAA_BASE: https://nyaa.si
CILISOUSUO_BASE: https://cilisousuo.cc
depends_on:
- mongo
restart: unless-stopped
mongo:
image: mongo:7
container_name: bt-preview-mongo
volumes:
- mongo_data:/data/db
restart: unless-stopped
volumes:
mongo_data:
+26
View File
@@ -0,0 +1,26 @@
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: false },
css: ['~/assets/css/main.css'],
app: {
head: {
htmlAttrs: { lang: 'zh-CN' },
title: 'BT 种子预览台',
meta: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'description', content: '搜索 / 解析 / 预览 BT 种子内容,并投递到本机下载工具' }
]
}
},
nitro: {
// 这些包体积大且带有原生/动态依赖,必须保持外部化,不能被打包进 Nitro 产物
externals: {
external: ['webtorrent', 'mongodb', 'parse-torrent', 'undici']
}
},
vite: {
server: {
fs: { strict: false }
}
}
})
+11294
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "torrent-preview-console",
"private": true,
"type": "module",
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"fast-xml-parser": "^4.5.1",
"mongodb": "^6.12.0",
"nuxt": "^4.0.3",
"parse-torrent": "^11.0.24",
"undici": "^7.3.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"webtorrent": "^3.0.21"
},
"devDependencies": {
"@types/node": "^22.13.0",
"typescript": "^5.7.3"
}
}
+27
View File
@@ -0,0 +1,27 @@
import { loadSettings, type BtClientConfig } from '../../utils/appconfig'
import { ADAPTERS, listClientStatuses } from '../../utils/clients'
/** 客户端配置 + 实时在线状态 */
export default defineEventHandler(async () => {
const settings = await loadSettings()
const statuses = await listClientStatuses()
return {
adapters: ADAPTERS.map((a) => ({
type: a.type,
label: a.label,
defaultPort: a.defaultPort,
defaultPath: a.defaultPath || null
})),
configs: settings.btClients as BtClientConfig[],
statuses,
online: statuses.filter((s) => s.online).length,
total: statuses.length,
settings: {
searchProxy: settings.searchProxy,
searchTimeout: settings.searchTimeout,
metadataTimeout: settings.metadataTimeout,
enabledProviders: settings.enabledProviders
}
}
})
+32
View File
@@ -0,0 +1,32 @@
import { normalizeClient, saveSettings, type AppSettings, type BtClientConfig } from '../../utils/appconfig'
/** 保存客户端配置 / 搜索源配置 */
export default defineEventHandler(async (event) => {
const body = await readBody(event).catch(() => ({}))
const patch: Partial<AppSettings> = {}
if (Array.isArray(body?.btClients)) {
patch.btClients = (body.btClients as Partial<BtClientConfig>[]).map((raw, i) => normalizeClient(raw, i))
}
if (typeof body?.searchProxy === 'string') patch.searchProxy = body.searchProxy.trim()
if (body?.searchTimeout != null) patch.searchTimeout = Math.min(Math.max(Number(body.searchTimeout) || 12000, 1000), 60000)
if (body?.metadataTimeout != null) patch.metadataTimeout = Math.min(Math.max(Number(body.metadataTimeout) || 30000, 3000), 180000)
if (Array.isArray(body?.enabledProviders) && body.enabledProviders.length) {
patch.enabledProviders = body.enabledProviders.map((p: unknown) => String(p))
}
if (!Object.keys(patch).length) {
throw createError({ statusCode: 400, statusMessage: '没有可保存的字段' })
}
let settings: AppSettings
try {
settings = await saveSettings(patch)
} catch (err) {
throw createError({
statusCode: 503,
statusMessage: `MongoDB 不可用,配置无法保存:${err instanceof Error ? err.message : err}`
})
}
return { ok: true, settings }
})
+10
View File
@@ -0,0 +1,10 @@
import { listAllTasks } from '../../utils/clients'
export default defineEventHandler(async () => {
const groups = await listAllTasks()
return {
groups,
totalCount: groups.reduce((sum, g) => sum + g.tasks.length, 0),
onlineCount: groups.filter((g) => g.online).length
}
})
+22
View File
@@ -0,0 +1,22 @@
import { normalizeClient, type BtClientConfig } from '../../utils/appconfig'
import { endpointOf, friendlyClientError, getAdapter } from '../../utils/clients'
/** 保存前先试连一次,确认地址 / 账号可用 */
export default defineEventHandler(async (event) => {
const body = await readBody(event).catch(() => ({}))
const cfg: BtClientConfig = normalizeClient((body?.client || body) as Partial<BtClientConfig>)
const adapter = getAdapter(cfg.type)
if (!adapter) throw createError({ statusCode: 400, statusMessage: `不支持的客户端类型:${cfg.type}` })
try {
const result = await adapter.probe(cfg)
return { ok: true, online: result.online, version: result.version || null, endpoint: endpointOf(cfg) }
} catch (err) {
return {
ok: false,
online: false,
endpoint: endpointOf(cfg),
error: friendlyClientError(err)
}
}
})
+14
View File
@@ -0,0 +1,14 @@
import { downloadsCol, tryMongo } from '../utils/mongo'
/** 投递下载记录 */
export default defineEventHandler(async (event) => {
const limit = Math.min(Number(getQuery(event).limit) || 30, 200)
const data = await tryMongo(async () => {
const col = await downloadsCol()
const items = await col.find({}).sort({ createdAt: -1 }).limit(limit).toArray()
return items.map((it) => ({ ...it, _id: String(it._id) }))
})
return { items: data || [], offline: data === null }
})
+18
View File
@@ -0,0 +1,18 @@
import { mongoStatus } from '../utils/mongo'
import { listProviders } from '../utils/search'
import { checkWebTorrent } from '../utils/torrent'
import { loadSettings } from '../utils/appconfig'
export default defineEventHandler(async () => {
const [mongo, webTorrent, settings] = await Promise.all([mongoStatus(), checkWebTorrent(), loadSettings()])
return {
ok: true,
time: new Date().toISOString(),
mongo,
webTorrent,
providers: listProviders(),
enabledProviders: settings.enabledProviders,
searchProxy: settings.searchProxy ? '已配置' : '未配置',
clients: settings.btClients.map((c) => ({ id: c.id, type: c.type, name: c.name, endpoint: `${c.host}:${c.port}`, enabled: c.enabled !== false }))
}
})
+23
View File
@@ -0,0 +1,23 @@
import { ObjectId } from 'mongodb'
import { searchesCol, tryMongo } from '../utils/mongo'
/** 删除单条历史(?id=)或清空全部历史 */
export default defineEventHandler(async (event) => {
const id = String(getQuery(event).id || '').trim()
if (id && !ObjectId.isValid(id)) {
throw createError({ statusCode: 400, statusMessage: 'id 不是合法的 ObjectId' })
}
const result = await tryMongo(async () => {
const col = await searchesCol()
if (id) {
const res = await col.deleteOne({ _id: new ObjectId(id) })
return { deleted: res.deletedCount }
}
const res = await col.deleteMany({})
return { deleted: res.deletedCount }
})
if (!result) throw createError({ statusCode: 503, statusMessage: 'MongoDB 不可用,无法操作历史记录' })
return { ok: true, ...result }
})
+31
View File
@@ -0,0 +1,31 @@
import { searchesCol, torrentsCol, tryMongo } from '../utils/mongo'
/** 搜索历史 + 高频关键词 */
export default defineEventHandler(async (event) => {
const limit = Math.min(Number(getQuery(event).limit) || 50, 200)
const data = await tryMongo(async () => {
const col = await searchesCol()
const items = await col.find({}).sort({ createdAt: -1 }).limit(limit).toArray()
const hot = await col
.aggregate<{ _id: string; count: number; last: Date }>([
{ $group: { _id: '$keyword', count: { $sum: 1 }, last: { $max: '$createdAt' } } },
{ $sort: { count: -1, last: -1 } },
{ $limit: 12 }
])
.toArray()
const total = await col.estimatedDocumentCount()
const torrentTotal = await (await torrentsCol()).estimatedDocumentCount()
return {
items: items.map((it) => ({ ...it, _id: String(it._id) })),
hot: hot.map((h) => ({ keyword: h._id, count: h.count, last: h.last })),
total,
torrentTotal
}
})
if (!data) {
return { items: [], hot: [], total: 0, torrentTotal: 0, offline: true }
}
return { ...data, offline: false }
})
+46
View File
@@ -0,0 +1,46 @@
import { parseMagnet, parseTorrentBuffer } from '../utils/torrent'
import { tryMongo } from '../utils/mongo'
import { upsertParsedTorrent } from '../utils/store'
const MAX_UPLOAD = 4 * 1024 * 1024
const MAX_INLINE_STORE = 1024 * 1024
export default defineEventHandler(async (event) => {
const contentType = getHeader(event, 'content-type') || ''
let info
let fileBase64: string | null = null
let originalName: string | null = null
if (contentType.includes('multipart/form-data')) {
const parts = await readMultipartFormData(event)
const filePart = parts?.find((p) => p.name === 'file' && p.data?.length)
if (!filePart) throw createError({ statusCode: 400, statusMessage: '没有收到种子文件(字段名应为 file)' })
if (filePart.data.length > MAX_UPLOAD) {
throw createError({ statusCode: 413, statusMessage: `种子文件过大(上限 ${MAX_UPLOAD / 1024 / 1024}MB)` })
}
try {
info = await parseTorrentBuffer(filePart.data)
} catch (err) {
throw createError({ statusCode: 400, statusMessage: err instanceof Error ? err.message : '种子文件解析失败' })
}
originalName = filePart.filename || null
if (filePart.data.length <= MAX_INLINE_STORE) fileBase64 = filePart.data.toString('base64')
} else {
const body = await readBody(event).catch(() => ({}))
const magnet = String(body?.magnet || '').trim()
if (!magnet) throw createError({ statusCode: 400, statusMessage: '请上传 .torrent 文件,或提供 magnet 字段' })
try {
info = await parseMagnet(magnet)
} catch (err) {
throw createError({ statusCode: 400, statusMessage: err instanceof Error ? err.message : '磁力链接解析失败' })
}
}
const stored = await tryMongo(async () => {
await upsertParsedTorrent(info!, { fileBase64, originalName, source: fileBase64 ? 'upload' : 'magnet' })
return true
})
return { torrent: { ...info, hasFile: !!fileBase64 }, stored: stored === true, fromUpload: !!fileBase64 }
})
+44
View File
@@ -0,0 +1,44 @@
import { runSearch } from '../utils/search'
import { tryMongo } from '../utils/mongo'
import { recordSearch, upsertSearchResults } from '../utils/store'
import type { ProviderReport } from '../utils/search/types'
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const keyword = String(query.q || '').trim()
if (!keyword) throw createError({ statusCode: 400, statusMessage: '缺少查询参数 q' })
const providers = String(query.providers || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const limit = Number(query.limit) || 0
const started = Date.now()
const { results, reports } = await runSearch(keyword, {
providers: providers.length ? providers : undefined,
limit: limit || 300
})
const durationMs = Date.now() - started
const stored = await tryMongo(async () => {
await recordSearch({
keyword,
providers: reports.filter((r) => r.ok).map((r) => r.id),
resultCount: results.length,
durationMs,
ok: reports.some((r) => r.ok),
error: reports.filter((r) => !r.ok).map((r: ProviderReport) => `${r.label}: ${r.error}`).join(' | ') || null
})
await upsertSearchResults(results)
return true
})
return {
query: keyword,
durationMs,
results,
reports,
stored: stored === true
}
})
+62
View File
@@ -0,0 +1,62 @@
import { tryMongo } from '../utils/mongo'
import { getTorrent, recordDownload } from '../utils/store'
import { friendlyClientError, sendTask } from '../utils/clients'
/** 把种子投递到本机 BT 下载工具 */
export default defineEventHandler(async (event) => {
const body = await readBody(event).catch(() => ({}))
const clientId = String(body?.clientId || '').trim()
if (!clientId) throw createError({ statusCode: 400, statusMessage: '缺少 clientId' })
const infoHash = String(body?.infoHash || '').toLowerCase() || null
let magnet = String(body?.magnet || '').trim() || undefined
let torrentBase64 = String(body?.torrentBase64 || '').trim() || undefined
let name = String(body?.name || '').trim()
const downloadDir = String(body?.downloadDir || '').trim() || undefined
if (!magnet && !torrentBase64) {
if (!infoHash) {
throw createError({ statusCode: 400, statusMessage: '需要提供 infoHash、magnet 或 torrentBase64 之一' })
}
const doc = await tryMongo(() => getTorrent(infoHash))
if (!doc) throw createError({ statusCode: 404, statusMessage: '库中找不到该种子,请把 magnet 一起传上来' })
magnet = doc.magnet || undefined
torrentBase64 = doc.fileBase64 || undefined
name = name || doc.name
}
let result: { ok: boolean; message: string; client: { id: string; type: string; name: string } | null }
try {
result = await sendTask(clientId, { magnet, torrentBase64, downloadDir, name })
} catch (err) {
const message = friendlyClientError(err)
await tryMongo(() =>
recordDownload({
infoHash,
name: name || magnet?.slice(0, 80) || '未知任务',
clientId,
clientType: '',
clientName: '',
downloadDir: downloadDir || null,
ok: false,
message
})
)
throw createError({ statusCode: 502, statusMessage: message })
}
await tryMongo(() =>
recordDownload({
infoHash,
name: name || magnet?.slice(0, 80) || '未知任务',
clientId,
clientType: result.client?.type || '',
clientName: result.client?.name || '',
downloadDir: downloadDir || null,
ok: result.ok,
message: result.message
})
)
return { ok: result.ok, message: result.message, client: result.client }
})
+27
View File
@@ -0,0 +1,27 @@
import { tryMongo } from '../../utils/mongo'
import { getTorrent } from '../../utils/store'
import { parseMagnet } from '../../utils/torrent'
export default defineEventHandler(async (event) => {
const infoHash = String(getRouterParam(event, 'infohash') || '').toLowerCase()
if (!/^[a-f0-9]{40}$/.test(infoHash)) {
throw createError({ statusCode: 400, statusMessage: 'infoHash 格式不正确(应为 40 位 hex)' })
}
const doc = await tryMongo(() => getTorrent(infoHash))
if (doc) {
const { fileBase64, ...rest } = doc
return { torrent: { ...rest, hasFile: !!fileBase64 }, source: 'database' as const }
}
const magnet = String(getQuery(event).magnet || '').trim()
if (magnet) {
try {
return { torrent: await parseMagnet(magnet), source: 'query' as const }
} catch (err) {
throw createError({ statusCode: 400, statusMessage: err instanceof Error ? err.message : '磁力链解析失败' })
}
}
throw createError({ statusCode: 404, statusMessage: '该种子不在库中(MongoDB 未连接或未收录),请在链接上带上 magnet 参数' })
})
+25
View File
@@ -0,0 +1,25 @@
import { tryMongo } from '../../../utils/mongo'
import { getTorrent } from '../../../utils/store'
/** 下载用户上传时的原始 .torrent 文件 */
export default defineEventHandler(async (event) => {
const infoHash = String(getRouterParam(event, 'infohash') || '').toLowerCase()
if (!/^[a-f0-9]{40}$/.test(infoHash)) {
throw createError({ statusCode: 400, statusMessage: 'infoHash 格式不正确' })
}
const doc = await tryMongo(() => getTorrent(infoHash))
if (!doc?.fileBase64) {
throw createError({ statusCode: 404, statusMessage: '没有保存该种子的原始文件(只有手动上传的种子会保存)' })
}
const bytes = Buffer.from(doc.fileBase64, 'base64')
setHeader(event, 'content-type', 'application/x-bittorrent')
setHeader(event, 'content-length', String(bytes.length))
setHeader(
event,
'content-disposition',
`attachment; filename="${encodeURIComponent((doc.name || infoHash).slice(0, 120))}.torrent"`
)
return bytes
})
@@ -0,0 +1,83 @@
import { loadSettings } from '../../../utils/appconfig'
import { withSwarm } from '../../../utils/swarm'
const IMAGE_TYPES: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
bmp: 'image/bmp',
avif: 'image/avif'
}
/** 超过这个大小就不做在线预览了,从 swarm 拉一张大图的代价太高 */
const MAX_PREVIEW_BYTES = 12 * 1024 * 1024
/** 单张图片的下载时间上限 */
const DOWNLOAD_TIMEOUT_MS = 25_000
function normPath(value: string): string {
return value.replace(/\\/g, '/').replace(/^\.\//, '')
}
/**
* 从 swarm 里只把某一张图片的字节拉下来用于预览。
* 只下载该文件覆盖到的分片,不落库、不长期占用磁盘。
*/
export default defineEventHandler(async (event) => {
const infoHash = String(getRouterParam(event, 'infohash') || '').toLowerCase()
if (!/^[a-f0-9]{40}$/.test(infoHash)) {
throw createError({ statusCode: 400, statusMessage: 'infoHash 格式不正确' })
}
const query = getQuery(event)
const magnet = String(query.magnet || '')
const filePath = String(query.path || '')
if (!/^magnet:\?/i.test(magnet)) {
throw createError({ statusCode: 400, statusMessage: '缺少可用的磁力链,无法定位做种节点' })
}
if (!filePath) {
throw createError({ statusCode: 400, statusMessage: '缺少 path 参数' })
}
const ext = filePath.slice(filePath.lastIndexOf('.') + 1).toLowerCase()
const mime = IMAGE_TYPES[ext]
if (!mime) {
throw createError({ statusCode: 400, statusMessage: '只支持预览图片文件(jpg / png / gif / webp / bmp / avif)' })
}
const settings = await loadSettings()
const bytes = await withSwarm(infoHash, magnet, settings.metadataTimeout, async (torrent) => {
const target = (torrent.files || []).find(
(f: any) => normPath(String(f.path || f.name || '')) === normPath(filePath)
)
if (!target) {
throw createError({ statusCode: 404, statusMessage: '这个种子里没有该文件,文件清单可能已过期' })
}
if (target.length > MAX_PREVIEW_BYTES) {
throw createError({
statusCode: 413,
statusMessage: `这张图 ${Math.round(target.length / 1024 / 1024)} MB,超过在线预览上限 ${MAX_PREVIEW_BYTES / 1024 / 1024} MB`
})
}
return Promise.race<ArrayBuffer>([
target.arrayBuffer(),
new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error('下载图片超时:暂时没有可用的做种节点,稍后再试')),
DOWNLOAD_TIMEOUT_MS
)
})
])
})
setHeader(event, 'content-type', mime)
setHeader(event, 'content-length', String(bytes.byteLength))
setHeader(
event,
'content-disposition',
`inline; filename="${encodeURIComponent(filePath.slice(filePath.lastIndexOf('/') + 1))}"`
)
setHeader(event, 'cache-control', 'private, max-age=3600')
return Buffer.from(bytes)
})
@@ -0,0 +1,30 @@
import { loadSettings } from '../../../utils/appconfig'
import { tryMongo } from '../../../utils/mongo'
import { getTorrent, markResolved } from '../../../utils/store'
import { fetchMetadataFromMagnet } from '../../../utils/torrent'
export default defineEventHandler(async (event) => {
const infoHash = String(getRouterParam(event, 'infohash') || '').toLowerCase()
if (!/^[a-f0-9]{40}$/.test(infoHash)) {
throw createError({ statusCode: 400, statusMessage: 'infoHash 格式不正确(应为 40 位 hex)' })
}
const body = await readBody(event).catch(() => ({}))
const doc = await tryMongo(() => getTorrent(infoHash))
const magnet = String(body?.magnet || doc?.magnet || '').trim()
if (!magnet) {
throw createError({ statusCode: 400, statusMessage: '缺少磁力链,无法在线获取文件清单' })
}
const settings = await loadSettings()
try {
const meta = await fetchMetadataFromMagnet(magnet, settings.metadataTimeout)
if (!meta.files.length) throw new Error('已连上节点,但对方没有返回任何文件信息')
await tryMongo(() => markResolved(infoHash, { files: meta.files, size: meta.size, name: meta.name || doc?.name }, null))
return { infoHash, name: meta.name, files: meta.files, size: meta.size }
} catch (err) {
const message = err instanceof Error ? err.message : '获取种子元数据失败'
await tryMongo(() => markResolved(infoHash, null, message))
throw createError({ statusCode: 502, statusMessage: message })
}
})
+25
View File
@@ -0,0 +1,25 @@
import { torrentsCol, tryMongo } from '../utils/mongo'
/** 最近入库 / 已解析的种子列表 */
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const limit = Math.min(Number(query.limit) || 30, 200)
const keyword = String(query.q || '').trim()
const onlyResolved = String(query.resolved || '') === '1'
const filter: Record<string, unknown> = {}
if (onlyResolved) filter.resolvedAt = { $ne: null }
if (keyword) filter.name = { $regex: keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), $options: 'i' }
const data = await tryMongo(async () => {
const col = await torrentsCol()
const items = await col
.find(filter, { projection: { fileBase64: 0 } })
.sort({ updatedAt: -1 })
.limit(limit)
.toArray()
return items.map((it) => ({ ...it, _id: String(it._id) }))
})
return { items: data || [], offline: data === null }
})
+116
View File
@@ -0,0 +1,116 @@
import { getDb } from './mongo'
export type BtClientType = 'qbittorrent' | 'transmission' | 'aria2'
export interface BtClientConfig {
id: string
type: BtClientType
name: string
host: string
port: number
/** 自定义 RPC / API 路径,留空使用各客户端的默认路径 */
path?: string
username?: string
password?: string
downloadDir?: string
useHttps?: boolean
enabled?: boolean
}
export interface AppSettings {
/** 访问搜索源时使用的 HTTP 代理 */
searchProxy: string
searchTimeout: number
metadataTimeout: number
enabledProviders: string[]
btClients: BtClientConfig[]
}
/** 本机常见 BT 客户端的默认端点,用户没配置时自动探测这些 */
export const DEFAULT_CLIENTS: BtClientConfig[] = [
{ id: 'qbittorrent-local', type: 'qbittorrent', name: 'qBittorrent(本机)', host: '127.0.0.1', port: 8080, username: 'admin', password: '', enabled: true },
{ id: 'transmission-local', type: 'transmission', name: 'Transmission(本机)', host: '127.0.0.1', port: 9091, path: '/transmission/rpc', username: '', password: '', enabled: true },
{ id: 'aria2-local', type: 'aria2', name: 'aria2(本机)', host: '127.0.0.1', port: 6800, path: '/jsonrpc', password: '', enabled: true }
]
const SETTINGS_ID = 'app'
function envDefaults(): AppSettings {
return {
searchProxy: process.env.SEARCH_PROXY || '',
searchTimeout: Number(process.env.SEARCH_TIMEOUT || 12000),
metadataTimeout: Number(process.env.METADATA_TIMEOUT || 30000),
enabledProviders: ['apibay', 'nyaa', 'cilisousuo'],
btClients: DEFAULT_CLIENTS.map((c) => ({ ...c }))
}
}
let cache: { value: AppSettings; at: number; ttl: number } | null = null
export function invalidateSettingsCache() {
cache = null
}
export async function loadSettings(): Promise<AppSettings> {
if (cache && Date.now() - cache.at < cache.ttl) return cache.value
const base = envDefaults()
try {
const db = await getDb()
const doc = await db.collection('settings').findOne({ _id: SETTINGS_ID as never })
const stored = (doc?.value || {}) as Partial<AppSettings>
const merged: AppSettings = { ...base, ...stored }
if (!Array.isArray(merged.btClients) || merged.btClients.length === 0) merged.btClients = base.btClients
if (!Array.isArray(merged.enabledProviders) || merged.enabledProviders.length === 0) {
merged.enabledProviders = base.enabledProviders
}
cache = { value: merged, at: Date.now(), ttl: 5000 }
return merged
} catch {
// MongoDB 不可用:短时缓存默认值,避免每个请求都卡 3 秒去连库
cache = { value: base, at: Date.now(), ttl: 3000 }
return base
}
}
export async function saveSettings(patch: Partial<AppSettings>): Promise<AppSettings> {
const db = await getDb()
const current = await loadSettings()
const next: AppSettings = { ...current, ...patch }
await db
.collection('settings')
.updateOne(
{ _id: SETTINGS_ID as never },
{ $set: { value: next, updatedAt: new Date() } },
{ upsert: true }
)
cache = { value: next, at: Date.now(), ttl: 5000 }
return next
}
export function normalizeClient(raw: Partial<BtClientConfig>, index = 0): BtClientConfig {
const type = (raw.type || 'qbittorrent') as BtClientType
return {
id: raw.id || `${type}-${Date.now().toString(36)}-${index}`,
type,
name: raw.name || type,
host: raw.host || '127.0.0.1',
port: Number(raw.port) || defaultPort(type),
path: raw.path || undefined,
username: raw.username || '',
password: raw.password || '',
downloadDir: raw.downloadDir || '',
useHttps: !!raw.useHttps,
enabled: raw.enabled !== false
}
}
export function defaultPort(type: BtClientType): number {
if (type === 'transmission') return 9091
if (type === 'aria2') return 6800
return 8080
}
export function baseUrlOf(cfg: BtClientConfig): string {
const scheme = cfg.useHttps ? 'https' : 'http'
return `${scheme}://${cfg.host}:${cfg.port}`
}
+114
View File
@@ -0,0 +1,114 @@
import { baseUrlOf, type BtClientConfig } from '../appconfig'
import { httpFetch } from '../http'
import type { AddTaskInput, AddTaskResult, BtClientAdapter, ProbeResult } from './types'
function rpcUrl(cfg: BtClientConfig) {
const path = cfg.path && cfg.path.trim() ? cfg.path.trim() : '/jsonrpc'
return `${baseUrlOf(cfg)}${path.startsWith('/') ? path : `/${path}`}`
}
function headersOf(cfg: BtClientConfig) {
const headers: Record<string, string> = { 'content-type': 'application/json' }
if (cfg.username) {
headers.authorization = `Basic ${Buffer.from(`${cfg.username}:${cfg.password || ''}`).toString('base64')}`
}
return headers
}
/** aria2 的 RPC 密钥以 token:xxx 形式作为第一个参数传入 */
async function call(cfg: BtClientConfig, method: string, params: unknown[]) {
const secret = (cfg.password || '').trim()
const payload = {
jsonrpc: '2.0',
id: `dsl-${Date.now().toString(36)}`,
method,
params: secret ? [`token:${secret}`, ...params] : params
}
const res = await httpFetch(rpcUrl(cfg), {
method: 'POST',
timeoutMs: 10000,
headers: headersOf(cfg),
body: JSON.stringify(payload)
})
if (res.status === 401) throw new Error('aria2 认证失败,请检查 --rpc-user / --rpc-pass')
if (!res.ok) throw new Error(`aria2 返回 HTTP ${res.status}`)
const data = (await res.json()) as { result?: unknown; error?: { code: number; message: string } }
if (data.error) throw new Error(`aria2 错误 ${data.error.code}:${data.error.message}`)
return data.result
}
export const aria2Adapter: BtClientAdapter = {
type: 'aria2',
label: 'aria2',
defaultPort: 6800,
defaultPath: '/jsonrpc',
async probe(cfg: BtClientConfig): Promise<ProbeResult> {
const result = (await call(cfg, 'aria2.getVersion', [])) as { version?: string } | undefined
return { online: true, version: result?.version }
},
async add(cfg: BtClientConfig, input: AddTaskInput): Promise<AddTaskResult> {
if (!input.magnet && !input.torrentBase64) throw new Error('缺少磁力链或种子文件内容')
const dir = input.downloadDir || cfg.downloadDir
const options: Record<string, unknown> = {}
if (dir) options.dir = dir
// 磁力链需要先下载元数据,开启种子相关选项以便更好识别
options['bt-metadata-only'] = 'false'
const gid = input.magnet
? await call(cfg, 'aria2.addUri', [[input.magnet], options])
: await call(cfg, 'aria2.addTorrent', [input.torrentBase64, [], options])
return { ok: true, message: `已提交到 aria2(gid: ${String(gid)})`, detail: gid }
},
async getTasks(cfg: BtClientConfig): Promise<BtTask[]> {
// 获取正在下载、等待中、已停止的任务
const [active, waiting, stopped] = (await Promise.all([
call(cfg, 'aria2.tellActive', []),
call(cfg, 'aria2.tellWaiting', [0, 100]),
call(cfg, 'aria2.tellStopped', [0, 100])
])) as any[][]
const all = [...active, ...waiting, ...stopped]
return all.map((t) => {
const size = Number(t.totalLength) || 0
const downloaded = Number(t.completedLength) || 0
// aria2 的 infoHash 存在 bittorrent.info.hash
const infoHash = t.bittorrent?.info?.hash || t.infoHash || ''
return {
infoHash,
name: t.bittorrent?.info?.name || t.files?.[0]?.path || 'aria2-task',
progress: size > 0 ? downloaded / size : 0,
size,
downloaded,
uploaded: Number(t.uploadLength) || 0,
dlspeed: Number(t.downloadSpeed) || 0,
upspeed: Number(t.uploadSpeed) || 0,
status: mapAria2Status(t.status),
eta: calculateEta(size, downloaded, Number(t.downloadSpeed))
}
})
}
}
function mapAria2Status(status: string): BtTask['status'] {
switch (status) {
case 'active': return 'downloading'
case 'waiting': return 'queued'
case 'paused': return 'paused'
case 'error': return 'error'
case 'complete': return 'completed'
case 'removed': return 'unknown'
default: return 'unknown'
}
}
function calculateEta(total: number, current: number, speed: number): number | undefined {
if (speed <= 0 || total <= current) return undefined
return Math.floor((total - current) / speed)
}
+114
View File
@@ -0,0 +1,114 @@
import { baseUrlOf, loadSettings, type BtClientConfig } from '../appconfig'
import { aria2Adapter } from './aria2'
import { qbittorrentAdapter } from './qbittorrent'
import { transmissionAdapter } from './transmission'
import type { AddTaskInput, AddTaskResult, BtClientAdapter, ClientStatus } from './types'
export const ADAPTERS: BtClientAdapter[] = [qbittorrentAdapter, transmissionAdapter, aria2Adapter]
export function getAdapter(type: string): BtClientAdapter | undefined {
return ADAPTERS.find((a) => a.type === type)
}
export function endpointOf(cfg: BtClientConfig): string {
const adapter = getAdapter(cfg.type)
const path = cfg.path && cfg.path.trim() ? cfg.path : adapter?.defaultPath || ''
return `${baseUrlOf(cfg)}${path}`
}
export function friendlyClientError(err: unknown): string {
// undici 的 fetch 失败时外层只有 "fetch failed",真正的 ECONNREFUSED 藏在 err.cause 里
const parts: string[] = []
let current: unknown = err
for (let i = 0; i < 5 && current; i++) {
if (current instanceof Error) {
parts.push(`${(current as Error & { code?: string }).code || ''} ${current.message}`)
current = (current as Error & { cause?: unknown }).cause
} else {
parts.push(String(current))
break
}
}
const msg = parts.join(' | ')
if (/ECONNREFUSED/i.test(msg)) return '端口未监听:客户端没有启动,或地址 / 端口填错了'
if (/ETIMEDOUT|timeout|aborted|超时/i.test(msg)) return '连接超时:可能是防火墙拦截或地址填错了'
if (/ENOTFOUND|EAI_AGAIN/i.test(msg)) return '无法解析该主机名'
if (/self-signed|certificate|SSL|TLS/i.test(msg)) return 'TLS 证书错误:如果客户端是自签证书,请改用 http'
return err instanceof Error ? err.message : String(err)
}
/** 探测所有已启用的客户端,返回在线状态 */
export async function listClientStatuses(): Promise<ClientStatus[]> {
const settings = await loadSettings()
const targets = settings.btClients.filter((c) => c.enabled !== false)
if (!targets.length) return []
return await Promise.all(
targets.map(async (cfg): Promise<ClientStatus> => {
const adapter = getAdapter(cfg.type)
const base: ClientStatus = {
id: cfg.id,
type: cfg.type,
name: cfg.name,
label: adapter?.label || cfg.type,
endpoint: endpointOf(cfg),
online: false
}
if (!adapter) return { ...base, error: `不支持的客户端类型:${cfg.type}` }
try {
const result = await adapter.probe(cfg)
return { ...base, online: result.online, version: result.version, error: result.error }
} catch (err) {
return { ...base, error: friendlyClientError(err) }
}
})
)
}
export async function sendTask(
clientId: string,
input: AddTaskInput
): Promise<{ ok: boolean; message: string; client: BtClientConfig | null }> {
const settings = await loadSettings()
const cfg = settings.btClients.find((c) => c.id === clientId)
if (!cfg) throw new Error(`找不到客户端配置:${clientId}`)
const adapter = getAdapter(cfg.type)
if (!adapter) throw new Error(`不支持的客户端类型:${cfg.type}`)
const result: AddTaskResult = await adapter.add(cfg, input)
return { ok: result.ok, message: result.message, client: cfg }
}
export interface ClientTaskGroup {
clientId: string
clientName: string
online: boolean
error?: string
tasks: BtTask[]
}
/** 获取所有在线客户端的任务 */
export async function listAllTasks(): Promise<ClientTaskGroup[]> {
const settings = await loadSettings()
const targets = settings.btClients.filter((c) => c.enabled !== false)
return await Promise.all(
targets.map(async (cfg): Promise<ClientTaskGroup> => {
const adapter = getAdapter(cfg.type)
const group: ClientTaskGroup = {
clientId: cfg.id,
clientName: cfg.name,
online: false,
tasks: []
}
if (!adapter) return { ...group, error: `不支持的客户端类型:${cfg.type}` }
try {
const tasks = await adapter.getTasks(cfg)
return { ...group, online: true, tasks }
} catch (err) {
return { ...group, error: friendlyClientError(err) }
}
})
)
}
+122
View File
@@ -0,0 +1,122 @@
import { baseUrlOf, type BtClientConfig } from '../appconfig'
import { httpFetch } from '../http'
import type { AddTaskInput, AddTaskResult, BtClientAdapter, ProbeResult } from './types'
function base(cfg: BtClientConfig) {
return baseUrlOf(cfg)
}
/** 登录拿到 SID Cookie;新版 qBittorrent(5.x)Cookie 名为 QBT_SID_<port>,这里统一透传 */
async function login(cfg: BtClientConfig): Promise<string> {
const url = `${base(cfg)}/api/v2/auth/login`
const body = new URLSearchParams({
username: cfg.username || 'admin',
password: cfg.password || ''
})
const res = await httpFetch(url, {
method: 'POST',
body,
timeoutMs: 6000,
headers: {
'content-type': 'application/x-www-form-urlencoded',
referer: base(cfg),
origin: base(cfg)
}
})
const cookies = res.headers.getSetCookie?.() ?? []
const sid = cookies.map((c) => c.split(';')[0]).join('; ')
const text = (await res.text()).trim()
if (text === 'Fails.') throw new Error('qBittorrent 用户名或密码错误')
if (!res.ok || (text && text !== 'Ok.')) throw new Error(`qBittorrent 登录失败:${text || `HTTP ${res.status}`}`)
return sid
}
export const qbittorrentAdapter: BtClientAdapter = {
type: 'qbittorrent',
label: 'qBittorrent',
defaultPort: 8080,
async probe(cfg: BtClientConfig): Promise<ProbeResult> {
let sid = ''
try {
sid = await login(cfg)
} catch (err) {
// 有些配置开启了「本机免密」,登录失败但接口仍可访问,继续尝试读取版本
const fallback = await httpFetch(`${base(cfg)}/api/v2/app/version`, { timeoutMs: 5000 })
if (!fallback.ok) throw err
return { online: true, version: (await fallback.text()).trim().replace(/^v/, '') }
}
const res = await httpFetch(`${base(cfg)}/api/v2/app/version`, {
timeoutMs: 5000,
headers: sid ? { cookie: sid } : {}
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return { online: true, version: (await res.text()).trim().replace(/^v/, '') }
},
async add(cfg: BtClientConfig, input: AddTaskInput): Promise<AddTaskResult> {
if (!input.magnet && !input.torrentBase64) throw new Error('缺少磁力链或种子文件内容')
const sid = await login(cfg)
const form = new FormData()
if (input.magnet) form.append('urls', input.magnet)
if (input.torrentBase64) {
const bytes = Buffer.from(input.torrentBase64, 'base64')
form.append('torrents', new Blob([bytes], { type: 'application/x-bittorrent' }), 'upload.torrent')
}
const savePath = input.downloadDir || cfg.downloadDir
if (savePath) form.append('savepath', savePath)
form.append('paused', 'false')
const res = await httpFetch(`${base(cfg)}/api/v2/torrents/add`, {
method: 'POST',
body: form,
timeoutMs: 15000,
headers: sid ? { cookie: sid, referer: base(cfg), origin: base(cfg) } : { referer: base(cfg) }
})
const text = (await res.text()).trim()
if (!res.ok) throw new Error(`qBittorrent 返回 HTTP ${res.status}${text ? `:${text}` : ''}`)
if (text === 'Fails.') throw new Error('qBittorrent 拒绝了该任务(可能是种子重复或路径不可用)')
return { ok: true, message: '已提交到 qBittorrent', detail: text }
},
async getTasks(cfg: BtClientConfig): Promise<BtTask[]> {
let sid = ''
try {
sid = await login(cfg)
} catch {
/* ignore login failure for local bypass */
}
const res = await httpFetch(`${base(cfg)}/api/v2/torrents/info`, {
headers: sid ? { cookie: sid } : {}
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const list = (await res.json()) as any[]
return list.map((t) => ({
infoHash: t.hash,
name: t.name,
progress: t.progress,
size: t.size,
downloaded: t.downloaded,
uploaded: t.uploaded,
dlspeed: t.dlspeed,
upspeed: t.upspeed,
status: mapQbtStatus(t.state),
eta: t.eta
}))
}
}
function mapQbtStatus(state: string): BtTask['status'] {
const s = state.toLowerCase()
if (s.includes('error') || s.includes('missing')) return 'error'
if (s.includes('checking')) return 'checking'
if (s.includes('paused')) return 'paused'
if (s.includes('queued')) return 'queued'
if (s.includes('dl')) return 'downloading'
if (s.includes('up')) return 'seeding'
if (s === 'completed' || s === 'uploading') return 'seeding'
return 'unknown'
}
+112
View File
@@ -0,0 +1,112 @@
import { baseUrlOf, type BtClientConfig } from '../appconfig'
import { httpFetch } from '../http'
import type { AddTaskInput, AddTaskResult, BtClientAdapter, ProbeResult } from './types'
function rpcUrl(cfg: BtClientConfig) {
const path = cfg.path && cfg.path.trim() ? cfg.path.trim() : '/transmission/rpc'
return `${baseUrlOf(cfg)}${path.startsWith('/') ? path : `/${path}`}`
}
function headersOf(cfg: BtClientConfig, sessionId?: string) {
const headers: Record<string, string> = { 'content-type': 'application/json' }
if (cfg.username) {
headers.authorization = `Basic ${Buffer.from(`${cfg.username}:${cfg.password || ''}`).toString('base64')}`
}
if (sessionId) headers['x-transmission-session-id'] = sessionId
return headers
}
/** Transmission 首次调用必定返回 409 并带上 session id,需要带着它重试一次 */
async function call(cfg: BtClientConfig, method: string, args: Record<string, unknown>) {
const send = (sessionId?: string) =>
httpFetch(rpcUrl(cfg), {
method: 'POST',
timeoutMs: 10000,
headers: headersOf(cfg, sessionId),
body: JSON.stringify({ method, arguments: args })
})
let res = await send()
if (res.status === 409) {
const sessionId = res.headers.get('x-transmission-session-id')
if (!sessionId) throw new Error('Transmission 返回 409 但没有 session id')
res = await send(sessionId)
}
if (res.status === 401) throw new Error('Transmission 认证失败,请检查用户名 / 密码')
if (!res.ok) throw new Error(`Transmission 返回 HTTP ${res.status}`)
const data = (await res.json()) as { result?: string; arguments?: Record<string, any> }
if (data.result && data.result !== 'success') throw new Error(`Transmission 调用失败:${data.result}`)
return data.arguments || {}
}
export const transmissionAdapter: BtClientAdapter = {
type: 'transmission',
label: 'Transmission',
defaultPort: 9091,
defaultPath: '/transmission/rpc',
async probe(cfg: BtClientConfig): Promise<ProbeResult> {
const args = await call(cfg, 'session-get', {})
return { online: true, version: args.version ? String(args.version) : undefined }
},
async add(cfg: BtClientConfig, input: AddTaskInput): Promise<AddTaskResult> {
if (!input.magnet && !input.torrentBase64) throw new Error('缺少磁力链或种子文件内容')
const args: Record<string, unknown> = {}
if (input.magnet) args.filename = input.magnet
else args.metainfo = input.torrentBase64
const dir = input.downloadDir || cfg.downloadDir
if (dir) args['download-dir'] = dir
const result = await call(cfg, 'torrent-add', args)
if (result['torrent-duplicate']) return { ok: true, message: 'Transmission 中已存在该任务(未重复添加)' }
const added = result['torrent-added']
return {
ok: true,
message: added?.name ? `已添加:${added.name}` : '已提交到 Transmission',
detail: result
}
},
async getTasks(cfg: BtClientConfig): Promise<BtTask[]> {
const fields = ['hashString', 'name', 'percentDone', 'totalSize', 'downloadedEver', 'uploadedEver', 'rateDownload', 'rateUpload', 'status', 'eta']
const result = await call(cfg, 'torrent-get', { fields })
const list = (result.torrents || []) as any[]
return list.map((t) => ({
infoHash: t.hashString,
name: t.name,
progress: Number(t.percentDone) || 0,
size: t.totalSize,
downloaded: t.downloadedEver,
uploaded: t.uploadedEver,
dlspeed: t.rateDownload,
upspeed: t.rateUpload,
status: mapTransmissionStatus(t.status),
eta: t.eta
}))
}
}
/**
* 0: STOPPED (Paused)
* 1: CHECK_WAIT
* 2: CHECK
* 3: DOWNLOAD_WAIT
* 4: DOWNLOAD
* 5: SEED_WAIT
* 6: SEED
*/
function mapTransmissionStatus(status: number): BtTask['status'] {
switch (status) {
case 0: return 'paused'
case 1:
case 2: return 'checking'
case 3:
case 5: return 'queued'
case 4: return 'downloading'
case 6: return 'seeding'
default: return 'unknown'
}
}
+60
View File
@@ -0,0 +1,60 @@
import type { BtClientConfig, BtClientType } from '../appconfig'
export interface ProbeResult {
online: boolean
version?: string
error?: string
}
export interface AddTaskInput {
/** 磁力链 */
magnet?: string
/** .torrent 文件内容(base64) */
torrentBase64?: string
/** 覆盖客户端的默认下载目录 */
downloadDir?: string
/** 任务名,仅用于记录与显示 */
name?: string
}
export interface AddTaskResult {
ok: boolean
message: string
/** 客户端返回的原始信息,便于排错 */
detail?: unknown
}
export interface BtTask {
infoHash: string
name: string
progress: number
size: number
downloaded: number
uploaded: number
dlspeed: number
upspeed: number
status: 'downloading' | 'seeding' | 'paused' | 'error' | 'queued' | 'checking' | 'completed' | 'unknown'
eta?: number
}
export interface BtClientAdapter {
type: BtClientType
label: string
defaultPort: number
defaultPath?: string
probe(cfg: BtClientConfig): Promise<ProbeResult>
add(cfg: BtClientConfig, input: AddTaskInput): Promise<AddTaskResult>
/** 获取当前活跃或全部任务 */
getTasks(cfg: BtClientConfig): Promise<BtTask[]>
}
export interface ClientStatus {
id: string
type: BtClientType
name: string
label: string
endpoint: string
online: boolean
version?: string
error?: string
}
+54
View File
@@ -0,0 +1,54 @@
import { ProxyAgent, fetch as undiciFetch, type RequestInit } from 'undici'
const agentCache = new Map<string, ProxyAgent>()
export function getProxyAgent(proxy?: string | null): ProxyAgent | undefined {
const uri = (proxy || '').trim()
if (!uri) return undefined
let agent = agentCache.get(uri)
if (!agent) {
agent = new ProxyAgent(uri)
agentCache.set(uri, agent)
}
return agent
}
export interface HttpOptions extends RequestInit {
/** 走 HTTP/HTTPS 代理,例如 http://127.0.0.1:7890 */
proxy?: string | null
timeoutMs?: number
}
/** 统一出口:带超时 + 可选代理的 fetch */
export async function httpFetch(url: string, options: HttpOptions = {}) {
const { proxy, timeoutMs = 12000, ...init } = options
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
return await undiciFetch(url, {
...init,
dispatcher: getProxyAgent(proxy),
signal: controller.signal
})
} catch (err) {
if (controller.signal.aborted) throw new Error(`请求超时(${timeoutMs}ms):${url}`)
throw err
} finally {
clearTimeout(timer)
}
}
export async function httpText(url: string, options: HttpOptions = {}): Promise<string> {
const res = await httpFetch(url, options)
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`)
return await res.text()
}
export async function httpJson<T = unknown>(url: string, options: HttpOptions = {}): Promise<T> {
const res = await httpFetch(url, options)
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`)
return (await res.json()) as T
}
export const DEFAULT_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
+93
View File
@@ -0,0 +1,93 @@
import { MongoClient, type Collection, type Db } from 'mongodb'
import type { DownloadDoc, SearchDoc, TorrentDoc } from './types'
let client: MongoClient | null = null
let connecting: Promise<MongoClient> | null = null
let indexState: 'pending' | 'done' = 'pending'
export const mongoUri = () => process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017'
export const mongoDbName = () => process.env.MONGODB_DB || 'torrent_console'
/** 最近一次连接失败信息,用于在 UI 上提示用户去装 / 启 MongoDB */
let lastError: { message: string; at: Date } | null = null
export async function getClient(): Promise<MongoClient> {
if (client) return client
if (!connecting) {
const next = new MongoClient(mongoUri(), {
serverSelectionTimeoutMS: 3000,
connectTimeoutMS: 3000
})
connecting = next
.connect()
.then((cli) => {
client = cli
connecting = null
lastError = null
return cli
})
.catch((err: unknown) => {
connecting = null
lastError = { message: err instanceof Error ? err.message : String(err), at: new Date() }
next.close().catch(() => {})
throw err
})
}
return connecting
}
export async function getDb(): Promise<Db> {
const cli = await getClient()
const db = cli.db(mongoDbName())
if (indexState === 'pending') {
await ensureIndexes(db)
indexState = 'done'
}
return db
}
async function ensureIndexes(db: Db) {
await db.collection<TorrentDoc>('torrents').createIndex({ infoHash: 1 }, { unique: true })
await db.collection<TorrentDoc>('torrents').createIndex({ updatedAt: -1 })
await db.collection<SearchDoc>('searches').createIndex({ createdAt: -1 })
await db.collection<SearchDoc>('searches').createIndex({ keyword: 1 })
await db.collection<DownloadDoc>('downloads').createIndex({ createdAt: -1 })
}
export async function torrentsCol(): Promise<Collection<TorrentDoc>> {
return (await getDb()).collection<TorrentDoc>('torrents')
}
export async function searchesCol(): Promise<Collection<SearchDoc>> {
return (await getDb()).collection<SearchDoc>('searches')
}
export async function downloadsCol(): Promise<Collection<DownloadDoc>> {
return (await getDb()).collection<DownloadDoc>('downloads')
}
/** 供 /api/health 使用:探测 MongoDB 是否可用 */
export async function mongoStatus() {
try {
const db = await getDb()
await db.command({ ping: 1 })
return { ok: true, uri: mongoUri(), db: mongoDbName(), error: null as string | null }
} catch (err) {
return {
ok: false,
uri: mongoUri(),
db: mongoDbName(),
error: lastError?.message || (err instanceof Error ? err.message : String(err))
}
}
}
/** MongoDB 不可用时的兜底:只记录日志,不打断主流程 */
export async function tryMongo<T>(fn: () => Promise<T>): Promise<T | null> {
try {
return await fn()
} catch (err) {
console.warn('[mongo] 操作失败,已跳过:', err instanceof Error ? err.message : err)
return null
}
}
+62
View File
@@ -0,0 +1,62 @@
import { DEFAULT_TRACKERS } from '../torrent'
import { DEFAULT_UA, httpJson } from '../http'
import type { ProviderContext, SearchProvider, SearchResult } from './types'
/** The Pirate Bay 分类编号 → 可读标签(只保留常见项) */
const TPB_CATEGORIES: Record<string, string> = {
'101': '音乐', '102': '有声书', '103': '视频', '104': '电影', '105': '剧集', '106': 'MV', '199': '其他',
'201': '软件', '202': '安卓', '203': 'Mac', '204': '游戏', '205': 'PC游戏', '206': 'Mac游戏', '207': '其他',
'301': '视频', '302': '视频', '303': '图片', '304': '其他', '399': '其他',
'401': '游戏', '402': 'PC游戏', '403': 'Mac游戏', '404': '其他',
'501': '其他', '502': '电子书', '503': '漫画', '504': '图片', '505': '其他', '599': '其他',
'601': '其他', '602': '电子书', '603': '音频', '604': '视频', '605': '其他', '699': '其他'
}
function apibayBase() {
return (process.env.APIBAY_BASE || 'https://apibay.org').replace(/\/+$/, '')
}
export const apibayProvider: SearchProvider = {
id: 'apibay',
label: 'The Pirate Bay',
homepage: 'https://thepiratebay.org',
async search(query: string, ctx: ProviderContext): Promise<SearchResult[]> {
const url = `${apibayBase()}/q.php?q=${encodeURIComponent(query)}&cat=0`
const raw = await httpJson<any[]>(url, {
proxy: ctx.proxy,
timeoutMs: ctx.timeoutMs,
headers: { accept: 'application/json', 'user-agent': DEFAULT_UA }
})
if (!Array.isArray(raw)) return []
return raw
.filter((it) => {
if (!it || typeof it.info_hash !== 'string') return false
if (it.info_hash === '0000000000000000000000000000000000000000') return false
if (typeof it.name === 'string' && /no results returned/i.test(it.name)) return false
return true
})
.map((it) => {
const infoHash = String(it.info_hash).toLowerCase()
const name = String(it.name || infoHash)
return {
infoHash,
name,
size: Number(it.size) || 0,
seeders: Number(it.seeders) || 0,
leechers: Number(it.leechers) || 0,
fileCount: Number(it.num_files) || null,
category: TPB_CATEGORIES[String(it.category)] || (it.category ? `分类 ${it.category}` : null),
source: 'apibay',
sourceLabel: 'The Pirate Bay',
sourceId: it.id ? String(it.id) : null,
sourceUrl: it.id ? `https://thepiratebay.org/description.php?id=${it.id}` : null,
addedAt: it.added ? new Date(Number(it.added) * 1000) : null,
magnet: `magnet:?xt=urn:btih:${infoHash}&dn=${encodeURIComponent(name)}${DEFAULT_TRACKERS.map(
(t) => `&tr=${encodeURIComponent(t)}`
).join('')}`
} satisfies SearchResult
})
}
}
+145
View File
@@ -0,0 +1,145 @@
import { buildMagnet } from '../torrent'
import { DEFAULT_UA, httpText } from '../http'
import type { ProviderContext, SearchProvider, SearchResult } from './types'
const HOMEPAGE = 'https://cilisousuo.cc'
const SIZE_UNITS: Record<string, number> = {
b: 1,
kb: 1024,
kib: 1024,
mb: 1024 ** 2,
mib: 1024 ** 2,
gb: 1024 ** 3,
gib: 1024 ** 3,
tb: 1024 ** 4,
tib: 1024 ** 4
}
/** "330.3MB" / "1.1TB" → 字节数 */
function parseSize(input: unknown): number {
const text = String(input || '').trim()
const m = text.match(/^([\d.]+)\s*([a-zA-Z]+)?$/)
if (!m) return 0
const value = Number(m[1]) || 0
const unit = (m[2] || 'b').toLowerCase()
return Math.round(value * (SIZE_UNITS[unit] ?? 1))
}
function cilisousuoBase() {
return (process.env.CILISOUSUO_BASE || HOMEPAGE).replace(/\/+$/, '')
}
/** 去掉 HTML 标签并解码常见实体 */
function stripHtml(input: string): string {
return input
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&#0?39;|&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/\s+/g, ' ')
.trim()
}
interface ListItem {
title: string
filename: string
size: number
code: string
}
/** 从列表页解析出条目(列表页只含名称/大小与 /magnet/{code},infoHash 在详情页) */
function parseList(html: string): ListItem[] {
const items: ListItem[] = []
const liRe = /<li class="item">([\s\S]*?)<\/li>/g
let m: RegExpExecArray | null
while ((m = liRe.exec(html))) {
const block = m[1]
const titleM = block.match(/<div class="result-title">([\s\S]*?)<\/div>/)
const fileM = block.match(/<div class="filename">([\s\S]*?)<\/div>/)
const sizeM = block.match(/<div class="size">([\s\S]*?)<\/div>/)
const codeM = block.match(/href="\/magnet\/([^"]+)"/)
if (!codeM) continue
const title = titleM ? stripHtml(titleM[1]) : ''
const filename = fileM ? stripHtml(fileM[1]) : ''
items.push({
title,
filename,
size: sizeM ? parseSize(sizeM[1]) : 0,
code: codeM[1]
})
}
return items
}
/** 并发受限地映射 */
async function mapWithConcurrency<T, R>(items: T[], concurrency: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const out: R[] = new Array(items.length)
let cursor = 0
async function worker() {
while (cursor < items.length) {
const i = cursor++
out[i] = await fn(items[i])
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()))
return out
}
async function fetchDetail(it: ListItem, ctx: ProviderContext): Promise<SearchResult | null> {
const url = `${cilisousuoBase()}/magnet/${it.code}`
const html = await httpText(url, {
proxy: ctx.proxy,
timeoutMs: Math.min(ctx.timeoutMs, 8000),
headers: { accept: 'text/html,application/xhtml+xml', 'user-agent': DEFAULT_UA }
})
const hashM = html.match(/xt=urn:btih:([a-f0-9]{40})/i)
if (!hashM) return null
const infoHash = hashM[1].toLowerCase()
const fileCountM = html.match(/文件数量[^\d]*<dd>\s*(\d+)/)
const fileCount = fileCountM ? Number(fileCountM[1]) : null
const name = it.title || it.filename || infoHash
return {
infoHash,
name,
size: it.size,
seeders: null,
leechers: null,
fileCount,
category: null,
source: 'cilisousuo',
sourceLabel: '磁力搜索',
sourceId: it.code,
sourceUrl: `${cilisousuoBase()}/magnet/${it.code}`,
addedAt: null,
magnet: buildMagnet(infoHash, name)
} satisfies SearchResult
}
export const cilisousuoProvider: SearchProvider = {
id: 'cilisousuo',
label: '磁力搜索',
homepage: HOMEPAGE,
async search(query: string, ctx: ProviderContext): Promise<SearchResult[]> {
const url = `${cilisousuoBase()}/search?q=${encodeURIComponent(query)}`
const html = await httpText(url, {
proxy: ctx.proxy,
timeoutMs: ctx.timeoutMs,
headers: { accept: 'text/html,application/xhtml+xml', 'user-agent': DEFAULT_UA }
})
const items = parseList(html)
if (!items.length) return []
// 列表页不含 infoHash,逐条抓详情页补全磁力链;最多取前 24 条并发抓取
const capped = items.slice(0, 24)
const results = await mapWithConcurrency(capped, 5, (it) => fetchDetail(it, ctx).catch(() => null))
return results.filter((x): x is SearchResult => x !== null)
}
}
+86
View File
@@ -0,0 +1,86 @@
import { loadSettings } from '../appconfig'
import { apibayProvider } from './apibay'
import { cilisousuoProvider } from './cilisousuo'
import { nyaaProvider } from './nyaa'
import { solidtorrentsProvider } from './solidtorrents'
import type { ProviderReport, SearchProvider, SearchResult } from './types'
export const PROVIDERS: SearchProvider[] = [apibayProvider, nyaaProvider, solidtorrentsProvider, cilisousuoProvider]
export function listProviders() {
return PROVIDERS.map((p) => ({ id: p.id, label: p.label, homepage: p.homepage }))
}
function friendlyError(err: unknown): string {
const msg = err instanceof Error ? err.message : String(err)
if (/fetch failed|ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ECONNRESET|ETIMEDOUT|socket hang up/i.test(msg)) {
return '网络不可达,通常是站点被墙,请在「设置」里配置 HTTP 代理后重试'
}
if (/aborted|超时/.test(msg)) return msg
if (/HTTP 5\d\d/.test(msg)) return `源站异常:${msg}`
if (/HTTP 4\d\d/.test(msg)) return `源站拒绝访问:${msg}`
return msg
}
export interface RunSearchOptions {
providers?: string[]
limit?: number
}
export interface RunSearchResult {
results: SearchResult[]
reports: ProviderReport[]
}
export async function runSearch(query: string, options: RunSearchOptions = {}): Promise<RunSearchResult> {
const settings = await loadSettings()
const wanted = options.providers?.length ? options.providers : settings.enabledProviders
const targets = PROVIDERS.filter((p) => wanted.includes(p.id))
const ctx = { proxy: settings.searchProxy || null, timeoutMs: settings.searchTimeout }
const reports = await Promise.all(
targets.map(async (provider): Promise<ProviderReport & { results: SearchResult[] }> => {
const started = Date.now()
try {
const results = await provider.search(query, ctx)
return {
id: provider.id,
label: provider.label,
ok: true,
count: results.length,
ms: Date.now() - started,
error: null,
results
}
} catch (err) {
return {
id: provider.id,
label: provider.label,
ok: false,
count: 0,
ms: Date.now() - started,
error: friendlyError(err),
results: []
}
}
})
)
// 按 infoHash 去重,保留做种数更高的那条
const merged = new Map<string, SearchResult>()
for (const report of reports) {
for (const item of report.results) {
const exists = merged.get(item.infoHash)
if (!exists || (item.seeders ?? 0) > (exists.seeders ?? 0)) merged.set(item.infoHash, item)
}
}
const results = [...merged.values()].sort(
(a, b) => (b.seeders ?? 0) - (a.seeders ?? 0) || b.size - a.size
)
return {
results: options.limit ? results.slice(0, options.limit) : results,
reports: reports.map(({ results: _omit, ...rest }) => rest)
}
}
+94
View File
@@ -0,0 +1,94 @@
import { XMLParser } from 'fast-xml-parser'
import { DEFAULT_TRACKERS } from '../torrent'
import { DEFAULT_UA, httpText } from '../http'
import type { ProviderContext, SearchProvider, SearchResult } from './types'
const SIZE_UNITS: Record<string, number> = {
b: 1,
kb: 1024,
kib: 1024,
mb: 1024 ** 2,
mib: 1024 ** 2,
gb: 1024 ** 3,
gib: 1024 ** 3,
tb: 1024 ** 4,
tib: 1024 ** 4
}
/** "1.23 GiB" / "456 MB" → 字节数 */
function parseSize(input: unknown): number {
if (typeof input === 'number') return input
const text = String(input || '').trim()
const m = text.match(/^([\d.]+)\s*([a-zA-Z]+)?$/)
if (!m) return 0
const value = Number(m[1]) || 0
const unit = (m[2] || 'b').toLowerCase()
return Math.round(value * (SIZE_UNITS[unit] ?? 1))
}
function nyaaBase() {
return (process.env.NYAA_BASE || 'https://nyaa.si').replace(/\/+$/, '')
}
function asArray<T>(value: T | T[] | undefined): T[] {
if (value === undefined || value === null) return []
return Array.isArray(value) ? value : [value]
}
function pickString(value: unknown): string {
if (value === undefined || value === null) return ''
if (typeof value === 'object' && '#text' in (value as Record<string, unknown>)) {
return String((value as Record<string, unknown>)['#text'] ?? '')
}
return String(value)
}
export const nyaaProvider: SearchProvider = {
id: 'nyaa',
label: 'Nyaa',
homepage: 'https://nyaa.si',
async search(query: string, ctx: ProviderContext): Promise<SearchResult[]> {
// c=0_0 全部分类;f=0 只过滤可信发布者之外的全部结果
const url = `${nyaaBase()}/?page=rss&q=${encodeURIComponent(query)}&c=0_0&f=0`
const xml = await httpText(url, {
proxy: ctx.proxy,
timeoutMs: ctx.timeoutMs,
headers: { accept: 'application/rss+xml, application/xml, text/xml', 'user-agent': DEFAULT_UA }
})
const parser = new XMLParser({ removeNSPrefix: true, trimValues: true, parseTagValue: false })
const doc = parser.parse(xml) as Record<string, any>
const items = asArray(doc?.rss?.channel?.item)
return items
.map((item: Record<string, any>) => {
const infoHash = pickString(item.infoHash).toLowerCase()
if (!/^[a-f0-9]{40}$/.test(infoHash)) return null
const name = pickString(item.title) || infoHash
const torrentUrl = pickString(item.guid) || pickString(item.link)
const category = pickString(item.category)
const pubDate = pickString(item.pubDate)
const added = pubDate ? new Date(pubDate) : null
return {
infoHash,
name,
size: parseSize(item.size),
seeders: Number(pickString(item.seeders)) || 0,
leechers: Number(pickString(item.leechers)) || 0,
fileCount: null,
category: category || null,
source: 'nyaa',
sourceLabel: 'Nyaa',
sourceId: pickString(item.link) || null,
sourceUrl: torrentUrl || null,
addedAt: added && !Number.isNaN(added.getTime()) ? added : null,
magnet: `magnet:?xt=urn:btih:${infoHash}&dn=${encodeURIComponent(name)}${DEFAULT_TRACKERS.map(
(t) => `&tr=${encodeURIComponent(t)}`
).join('')}`
} satisfies SearchResult
})
.filter((item): item is SearchResult => item !== null)
}
}
+42
View File
@@ -0,0 +1,42 @@
import { DEFAULT_TRACKERS } from '../torrent'
import { DEFAULT_UA, httpJson } from '../http'
import type { ProviderContext, SearchProvider, SearchResult } from './types'
export const solidtorrentsProvider: SearchProvider = {
id: 'solidtorrents',
label: 'SolidTorrents',
homepage: 'https://solidtorrents.net',
async search(query: string, ctx: ProviderContext): Promise<SearchResult[]> {
const url = `https://solidtorrents.net/api/v1/search?q=${encodeURIComponent(query)}&category=all&sort=seeders`
const raw = await httpJson<any>(url, {
proxy: ctx.proxy,
timeoutMs: ctx.timeoutMs,
headers: { accept: 'application/json', 'user-agent': DEFAULT_UA }
})
if (!raw || !Array.isArray(raw.results)) return []
return raw.results.map((it: any) => {
const infoHash = String(it.infohash).toLowerCase()
const name = String(it.title || infoHash)
return {
infoHash,
name,
size: Number(it.size) || 0,
seeders: Number(it.swarm?.seeders) || 0,
leechers: Number(it.swarm?.leechers) || 0,
fileCount: null, // API doesn't seem to provide this in search list
category: it.category || null,
source: 'solidtorrents',
sourceLabel: 'SolidTorrents',
sourceId: null,
sourceUrl: `https://solidtorrents.to/view/${infoHash}`,
addedAt: it.createdAt ? new Date(it.createdAt) : null,
magnet: `magnet:?xt=urn:btih:${infoHash}&dn=${encodeURIComponent(name)}${DEFAULT_TRACKERS.map(
(t) => `&tr=${encodeURIComponent(t)}`
).join('')}`
} satisfies SearchResult
})
}
}
+37
View File
@@ -0,0 +1,37 @@
export interface SearchResult {
infoHash: string
name: string
size: number
seeders: number | null
leechers: number | null
fileCount: number | null
category: string | null
source: string
sourceLabel: string
sourceId: string | null
sourceUrl: string | null
addedAt: Date | null
magnet: string
}
export interface ProviderContext {
proxy: string | null
timeoutMs: number
}
export interface SearchProvider {
id: string
label: string
/** 该站点所在的站点根地址,便于 UI 上提示 */
homepage: string
search(query: string, ctx: ProviderContext): Promise<SearchResult[]>
}
export interface ProviderReport {
id: string
label: string
ok: boolean
count: number
ms: number
error: string | null
}
+157
View File
@@ -0,0 +1,157 @@
import { downloadsCol, searchesCol, torrentsCol } from './mongo'
import type { SearchResult } from './search/types'
import type { TorrentInfo } from './torrent'
import type { DownloadDoc, SearchDoc, TorrentDoc } from './types'
function trackersFromMagnet(magnet: string): string[] {
const out: string[] = []
const re = /[?&]tr=([^&]+)/g
let m: RegExpExecArray | null
while ((m = re.exec(magnet))) {
try {
out.push(decodeURIComponent(m[1]))
} catch {
out.push(m[1])
}
}
return out
}
/** 只保留不在 $set 里出现过的字段,避免 MongoDB 报「字段冲突」 */
function insertOnly(candidates: Record<string, unknown>, set: Record<string, unknown>) {
const out: Record<string, unknown> = {}
for (const [key, value] of Object.entries(candidates)) {
if (!(key in set)) out[key] = value
}
return out
}
export async function recordSearch(doc: Omit<SearchDoc, 'createdAt'>) {
const col = await searchesCol()
await col.insertOne({ ...doc, createdAt: new Date() })
}
/** 把搜索结果写入 torrents 集合(infoHash 唯一,重复搜索只更新易变字段) */
export async function upsertSearchResults(results: SearchResult[]) {
if (!results.length) return
const col = await torrentsCol()
const now = new Date()
await col.bulkWrite(
results.map((r) => {
const set: Record<string, unknown> = {
name: r.name,
size: r.size,
magnet: r.magnet,
trackers: trackersFromMagnet(r.magnet),
source: r.source,
sourceId: r.sourceId,
sourceUrl: r.sourceUrl,
category: r.category,
seeders: r.seeders,
leechers: r.leechers,
updatedAt: now
}
return {
updateOne: {
filter: { infoHash: r.infoHash },
update: {
$set: set,
$setOnInsert: {
infoHash: r.infoHash,
createdAt: now,
...insertOnly(
{
files: null,
fileCount: r.fileCount ?? null,
fileBase64: null,
comment: null,
creationDate: null,
resolvedAt: null,
resolveError: null
},
set
)
}
},
upsert: true
}
}
}),
{ ordered: false }
)
}
/** 把解析出来的种子信息写入 torrents 集合 */
export async function upsertParsedTorrent(
info: TorrentInfo,
extra: { fileBase64?: string | null; originalName?: string | null; source?: string } = {}
) {
const col = await torrentsCol()
const now = new Date()
const set: Record<string, unknown> = {
name: info.name,
size: info.size,
magnet: info.magnet,
trackers: info.trackers,
comment: info.comment,
creationDate: info.creationDate,
updatedAt: now
}
if (info.files) {
set.files = info.files
set.fileCount = info.files.length
set.resolvedAt = now
set.resolveError = null
}
if (extra.fileBase64) set.fileBase64 = extra.fileBase64
await col.updateOne(
{ infoHash: info.infoHash },
{
$set: set,
$setOnInsert: {
infoHash: info.infoHash,
createdAt: now,
source: extra.source || 'upload',
sourceId: null,
sourceUrl: null,
category: null,
seeders: null,
leechers: null,
...insertOnly(
{ files: null, fileCount: info.fileCount ?? null, fileBase64: null, resolvedAt: null, resolveError: null },
set
)
}
},
{ upsert: true }
)
}
export async function getTorrent(infoHash: string): Promise<TorrentDoc | null> {
const col = await torrentsCol()
return await col.findOne({ infoHash })
}
export async function markResolved(
infoHash: string,
data: { files: TorrentDoc['files']; size: number; name?: string } | null,
error: string | null
) {
const col = await torrentsCol()
const now = new Date()
const update: Record<string, unknown> = { resolveError: error, updatedAt: now }
if (data) {
update.files = data.files
update.fileCount = data.files ? data.files.length : null
update.size = data.size
update.resolvedAt = now
if (data.name) update.name = data.name
}
await col.updateOne({ infoHash }, { $set: update })
}
export async function recordDownload(doc: Omit<DownloadDoc, 'createdAt'>) {
const col = await downloadsCol()
await col.insertOne({ ...doc, createdAt: new Date() })
}
+90
View File
@@ -0,0 +1,90 @@
import { mkdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { loadWebTorrent, withDefaultTrackers } from './torrent'
/** 会话空闲多久后释放(毫秒) */
const IDLE_MS = 120_000
interface SwarmSession {
client: any
torrent: any
dir: string
ready: Promise<any>
timer: NodeJS.Timeout | null
}
const sessions = new Map<string, SwarmSession>()
/**
* 复用一个常驻的 webtorrent 会话来取文件内容。
* 每张图片都重建一次客户端就得重新找一遍节点(快则 1 秒,慢则十几秒),
* 所以同一个 infoHash 的连续预览共用一条会话,空闲 2 分钟自动释放。
*/
export async function withSwarm<T>(
infoHash: string,
magnet: string,
timeoutMs: number,
fn: (torrent: any) => Promise<T>
): Promise<T> {
const key = infoHash.toLowerCase()
let session = sessions.get(key)
if (!session) {
session = await createSession(key, magnet, timeoutMs)
sessions.set(key, session)
}
touchSession(key, session)
const torrent = await session.ready
return fn(torrent)
}
function touchSession(key: string, session: SwarmSession) {
if (session.timer) clearTimeout(session.timer)
session.timer = setTimeout(() => releaseSession(key), IDLE_MS)
session.timer.unref?.()
}
async function createSession(infoHash: string, magnet: string, timeoutMs: number): Promise<SwarmSession> {
const WebTorrent = await loadWebTorrent()
const dir = join(tmpdir(), `dsl-swarm-${infoHash.slice(0, 8)}-${Date.now().toString(36)}`)
await mkdir(dir, { recursive: true })
const client = new WebTorrent({ dht: true, lsd: false, utp: false })
client.on('error', () => {})
const torrent = client.add(withDefaultTrackers(magnet), { path: dir })
torrent.on('warning', () => {})
const ready = new Promise<any>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`获取种子元数据超时(${Math.round(timeoutMs / 1000)}s),可能没有可用节点或 tracker 被墙`))
}, timeoutMs)
const done = (fn: () => void) => {
clearTimeout(timer)
fn()
}
if (torrent.metadata) return done(() => resolve(torrent))
torrent.on('metadata', () => done(() => resolve(torrent)))
torrent.on('error', (err: Error) => done(() => reject(err)))
client.on('error', (err: Error) => done(() => reject(err)))
})
const session: SwarmSession = { client, torrent, dir, ready, timer: null }
touchSession(infoHash, session)
// 元数据都拿不到就别留着占资源
ready.catch(() => releaseSession(infoHash))
return session
}
function releaseSession(infoHash: string) {
const session = sessions.get(infoHash)
if (!session) return
sessions.delete(infoHash)
if (session.timer) clearTimeout(session.timer)
try {
session.client.destroy(() => {})
} catch {
/* ignore */
}
rm(session.dir, { recursive: true, force: true }).catch(() => {})
}
+242
View File
@@ -0,0 +1,242 @@
import { mkdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import parseTorrent, { toMagnetURI } from 'parse-torrent'
import type { TorrentFileEntry } from './types'
export interface TorrentInfo {
infoHash: string
name: string
size: number
files: TorrentFileEntry[] | null
fileCount: number | null
trackers: string[]
magnet: string
comment: string | null
creationDate: Date | null
pieceLength: number | null
isPrivate: boolean
}
export const DEFAULT_TRACKERS = [
'udp://tracker.opentrackr.org:1337/announce',
'udp://open.tracker.cl:1337/announce',
'udp://open.demonii.com:1337/announce',
'udp://tracker.openbittorrent.com:6969/announce',
'udp://exodus.desync.com:6969/announce',
'udp://tracker.torrent.eu.org:451/announce'
]
export function buildMagnet(infoHash: string, name?: string | null, trackers: string[] = DEFAULT_TRACKERS): string {
const params = [`xt=urn:btih:${infoHash}`]
if (name) params.push(`dn=${encodeURIComponent(name)}`)
for (const tr of trackers) params.push(`tr=${encodeURIComponent(tr)}`)
return `magnet:?${params.join('&')}`
}
/** 从磁力链里取出已有的 tracker */
function trackersInMagnet(magnet: string): string[] {
const out = new Set<string>()
for (const m of magnet.matchAll(/[?&]tr=([^&]+)/g)) {
try {
out.add(decodeURIComponent(m[1]))
} catch {
/* 编码坏了就跳过 */
}
}
return [...out]
}
/**
* 给没有 tracker 的磁力链补上 tracker。
* 社区里流传的裸磁力链(只有 xt + dn)常常一个 tracker 都没有,
* 纯靠 DHT 在家庭网络/被墙环境里几乎拉不到元数据,补公共 tracker 能显著提高成功率。
*/
export function withDefaultTrackers(magnet: string, extra: string[] = []): string {
if (!magnet || /[?&]tr=/.test(magnet)) return magnet
const list = extra.length ? extra : DEFAULT_TRACKERS
return magnet + list.map((t) => `&tr=${encodeURIComponent(t)}`).join('')
}
function toInfoHash(value: unknown): string {
if (!value) return ''
if (typeof value === 'string') return value.toLowerCase()
if (value instanceof Uint8Array || Buffer.isBuffer(value)) return Buffer.from(value as Uint8Array).toString('hex')
return String(value).toLowerCase()
}
function pickTrackers(parsed: Record<string, unknown>): string[] {
const out = new Set<string>()
const announce = parsed.announce
if (typeof announce === 'string') out.add(announce)
else if (Array.isArray(announce)) for (const a of announce) if (typeof a === 'string') out.add(a)
const list = parsed.announceList
if (Array.isArray(list)) {
for (const tier of list) {
if (Array.isArray(tier)) for (const a of tier) if (typeof a === 'string') out.add(a)
}
}
return [...out]
}
function normalize(parsed: Record<string, any>, magnetFallback?: string): TorrentInfo {
const infoHash = toInfoHash(parsed.infoHash)
let files: TorrentFileEntry[] | null = null
if (Array.isArray(parsed.files) && parsed.files.length) {
files = parsed.files.map((f: any) => ({
// parse-torrent 用 path.sep 拼接,Windows 下是反斜杠,统一成正斜杠便于展示
path: String(f.path ?? f.name ?? '').replace(/\\/g, '/'),
length: Number(f.length) || 0
}))
} else if (typeof parsed.length === 'number') {
// 单文件种子:parse-torrent 只给出 length + name
files = [{ path: String(parsed.name || 'unknown'), length: Number(parsed.length) || 0 }]
}
const size = files ? files.reduce((sum, f) => sum + (f.length || 0), 0) : Number(parsed.length) || 0
let trackers = pickTrackers(parsed)
let magnet = magnetFallback || ''
if (!magnet) {
try {
magnet = toMagnetURI(parsed as never)
} catch {
magnet = infoHash ? buildMagnet(infoHash, parsed.name, trackers.length ? trackers : DEFAULT_TRACKERS) : ''
}
}
magnet = withDefaultTrackers(magnet, trackers)
// 补过 tracker 的磁力链,trackers 以最终磁力链为准,避免 UI 显示“无 tracker”
if (!trackers.length) trackers = trackersInMagnet(magnet)
return {
infoHash,
name: String(parsed.name || ''),
size,
files,
fileCount: files ? files.length : null,
trackers,
magnet,
comment: typeof parsed.comment === 'string' ? parsed.comment : null,
creationDate: parsed.created instanceof Date
? parsed.created
: parsed.creationDate instanceof Date
? parsed.creationDate
: null,
pieceLength: typeof parsed.pieceLength === 'number' ? parsed.pieceLength : null,
isPrivate: parsed.private === true
}
}
/** 解析 .torrent 文件内容(parse-torrent v11 起为异步 API) */
export async function parseTorrentBuffer(buf: Buffer): Promise<TorrentInfo> {
let parsed: any
try {
parsed = await parseTorrent(buf)
} catch {
throw new Error('种子文件解析失败,可能不是合法的 .torrent 文件')
}
const info = normalize(parsed)
if (!info.infoHash) throw new Error('种子文件中缺少 info 字段,无法计算 infoHash')
return info
}
/** 解析磁力链(不含文件清单,需要通过 fetchMetadataFromMagnet 才能拿到) */
export async function parseMagnet(uri: string): Promise<TorrentInfo> {
const raw = uri.trim()
if (!/^magnet:\?/i.test(raw)) throw new Error('不是合法的磁力链接,应以 magnet:? 开头')
if (!/xt=urn:btih:[a-z0-9]+/i.test(raw)) throw new Error('磁力链接中缺少 xt=urn:btih: 参数')
let parsed: any
try {
parsed = await parseTorrent(raw)
} catch {
throw new Error('磁力链接解析失败')
}
const info = normalize(parsed, raw)
if (!info.infoHash) throw new Error('无法从磁力链接中提取 infoHash')
return info
}
/** 判断字符串是否为磁力链 */
export function isMagnet(input: string): boolean {
return /^magnet:\?/i.test(input.trim())
}
let webTorrentMod: unknown = null
export async function loadWebTorrent(): Promise<any> {
if (webTorrentMod) return webTorrentMod
try {
const mod: any = await import('webtorrent')
webTorrentMod = mod?.default || mod
return webTorrentMod
} catch {
throw new Error('未安装 webtorrent 依赖,无法通过磁力链在线获取文件清单(仅能显示 infoHash)')
}
}
export function hasWebTorrent(): boolean {
return webTorrentMod !== null
}
/** 探测 webtorrent 依赖是否可用(用于健康检查与 UI 提示) */
export async function checkWebTorrent(): Promise<boolean> {
try {
await loadWebTorrent()
return true
} catch {
return false
}
}
/**
* 通过磁力链从 DHT / tracker 拉取种子元数据,从而得到文件清单。
* 只取元数据,取到后立刻销毁客户端并清理临时目录。
*/
export async function fetchMetadataFromMagnet(
magnetUri: string,
timeoutMs = 30000
): Promise<{ name: string; files: TorrentFileEntry[]; size: number }> {
const WebTorrent = await loadWebTorrent()
const dir = join(tmpdir(), `dsl-meta-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`)
await mkdir(dir, { recursive: true })
const client = new WebTorrent({ dht: true, lsd: false, utp: false })
try {
// 纯 DHT 很可能找不到节点,没 tracker 的磁力链先补上公共 tracker
const torrent = client.add(withDefaultTrackers(magnetUri), { path: dir })
torrent.on('warning', () => {})
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`获取种子元数据超时(${Math.round(timeoutMs / 1000)}s),可能没有可用节点或 tracker 被墙`))
}, timeoutMs)
const done = (fn: () => void) => {
clearTimeout(timer)
fn()
}
if (torrent.metadata) return done(resolve)
torrent.on('metadata', () => done(resolve))
torrent.on('error', (err: Error) => done(() => reject(err)))
client.on('error', (err: Error) => done(() => reject(err)))
})
const files: TorrentFileEntry[] = (torrent.files || []).map((f: any) => ({
// Windows 下 webtorrent 用反斜杠拼路径,统一成正斜杠便于展示
path: String(f.path || f.name || '').replace(/\\/g, '/'),
length: Number(f.length) || 0
}))
return {
name: String(torrent.name || ''),
files,
size: files.reduce((sum, f) => sum + f.length, 0)
}
} finally {
try {
// webtorrent v3 的 destroy 是回调式 API
await new Promise<void>((resolve) => client.destroy(() => resolve()))
} catch {
/* ignore */
}
rm(dir, { recursive: true, force: true }).catch(() => {})
}
}
+55
View File
@@ -0,0 +1,55 @@
/** 数据库文档结构定义(服务端内部使用) */
export interface TorrentFileEntry {
path: string
length: number
}
/** torrents 集合:种子元信息 + 解析结果缓存,infoHash 唯一 */
export interface TorrentDoc {
infoHash: string
name: string
size: number
files: TorrentFileEntry[] | null
fileCount: number | null
trackers: string[]
magnet: string
source: string
sourceId: string | null
sourceUrl: string | null
category: string | null
seeders: number | null
leechers: number | null
comment: string | null
creationDate: Date | null
/** 用户上传的原始 .torrent 内容(base64,仅 <= 1MB 时保存) */
fileBase64: string | null
resolvedAt: Date | null
resolveError: string | null
createdAt: Date
updatedAt: Date
}
/** searches 集合:用户搜索记录 */
export interface SearchDoc {
keyword: string
providers: string[]
resultCount: number
durationMs: number
ok: boolean
error: string | null
createdAt: Date
}
/** downloads 集合:投递到本机 BT 客户端的记录 */
export interface DownloadDoc {
infoHash: string | null
name: string
clientId: string
clientType: string
clientName: string
downloadDir: string | null
ok: boolean
message: string
createdAt: Date
}
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}