feat: DSL 磁力聚合 — 搜索/解析/预览/投递 + cilisousuo 源 + 中英双语 + Docker 部署
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.output
|
||||
.nuxt
|
||||
.data
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
*.log
|
||||
npm-debug.log*
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.data
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
+31
@@ -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"]
|
||||
@@ -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 磁力链 / 种子解析工具
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
<AppToasts />
|
||||
</template>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ||
|
||||
'请求失败'
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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/<infoHash>?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>
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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 }[]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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, ' ')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
})
|
||||
Generated
+11294
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }))
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 参数' })
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/�?39;|'/g, "'")
|
||||
.replace(/"/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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() })
|
||||
}
|
||||
@@ -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(() => {})
|
||||
}
|
||||
@@ -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(() => {})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
Reference in New Issue
Block a user