Initial commit: BiliDownloader Web 版:Python 零依赖后端 + shadcn/ui 前端
This commit is contained in:
@@ -0,0 +1,119 @@
|
|||||||
|
# BiliDownloader
|
||||||
|
|
||||||
|
哔哩哔哩视频下载器 —— **Python 零依赖本地服务 + shadcn/ui 界面 + Edge 应用模式窗口**
|
||||||
|
|
||||||
|
> 这是**弃用 WinUI3 后的重构版**。旧版(C# / WinUI3,位于 `E:\deepseek\BiliDownloader`)能编译能跑,
|
||||||
|
> 但需要 MSIX 打包且本机存在 WinUI3 启动限制;这一版改成浏览器界面,双击即用、无运行时依赖。
|
||||||
|
|
||||||
|
## 启动
|
||||||
|
|
||||||
|
双击 **`start.cmd`** —— 它会起本地服务并打开一个 Edge 应用模式窗口(无地址栏、无标签页,看起来就是独立应用)。
|
||||||
|
|
||||||
|
停止:双击 `stop.cmd`。
|
||||||
|
|
||||||
|
也可以手动:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python server.py # 起服务并自动开窗口
|
||||||
|
python server.py --no-open # 只起服务,自己用浏览器打开 http://127.0.0.1:8799/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- **站内搜索**:WBI 签名的官方搜索接口,卡片列表展示封面 / 标题 / UP主 / 时长 / 播放量
|
||||||
|
- **粘贴链接或 BV 号**直接解析
|
||||||
|
- **清晰度可选**:按**真正能下到的流**列出(不是标称支持),默认最高
|
||||||
|
- **多线程加速下载**:文件切段并发下载(默认 16,可调 1~64),断线按分片续传,支持跨进程断点续传
|
||||||
|
- **实时进度**:SSE 推送,总体 + 视频流 + 音频流三条进度、实时速度、速度曲线、ETA
|
||||||
|
- **ffmpeg 无损封装**为 mp4,产物文件名带清晰度
|
||||||
|
- 深浅色主题(默认深色)并记忆
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
BiliDownloaderWeb/
|
||||||
|
├─ start.cmd / stop.cmd 双击启动 / 停止
|
||||||
|
├─ server.py 本地 HTTP 服务(标准库 http.server,只监听 127.0.0.1)
|
||||||
|
│ JSON API + SSE 进度推送 + 托管前端产物
|
||||||
|
├─ engine.py 下载引擎(已实测验证的核心逻辑)
|
||||||
|
│ ├─ WBI 签名的站内搜索
|
||||||
|
│ ├─ 视频详情 / 分P / 清晰度
|
||||||
|
│ ├─ 多线程分段下载(Range 分片 + 每片重试 + 旁路状态文件续传)
|
||||||
|
│ └─ ffmpeg 封装
|
||||||
|
├─ config.json sessdata / outputDir / threads / preferAvc / keepParts
|
||||||
|
├─ ui/ React 19 + Tailwind 4 + Vite 7 + shadcn/ui(new-york)
|
||||||
|
│ └─ src/{App.tsx,components/,lib/}
|
||||||
|
└─ tools/ 验收脚本
|
||||||
|
├─ smoke_api.py 后端 API 冒烟
|
||||||
|
├─ e2e_download.py 端到端下载验收(含 ffprobe 与全片解码校验)
|
||||||
|
└─ ui-check.mjs 浏览器端验收(Edge 无头 + CDP,真的驱动一次搜索)
|
||||||
|
```
|
||||||
|
|
||||||
|
为什么是"本地薄服务 + 浏览器界面":前端需要跨域访问 B 站 API、需要写本地磁盘、需要用 ffmpeg,
|
||||||
|
浏览器自己做不到,所以后端必须存在;而这层后端用 Python 标准库就够,**零第三方依赖**,
|
||||||
|
`engine.py` 里那套逻辑也已在上一轮里逐项实测过。
|
||||||
|
|
||||||
|
## 验收结果(都是实测,非推断)
|
||||||
|
|
||||||
|
**后端 API**(`tools/smoke_api.py`)
|
||||||
|
|
||||||
|
| 项 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 站内搜索 | `numResults=1000`,返回 20 条,封面 URL 正常 |
|
||||||
|
| 视频详情 | 标题/UP主/时长/分P 全部正确 |
|
||||||
|
| 清晰度列表 | 按 `dash.video` 实际流构建 |
|
||||||
|
|
||||||
|
**端到端下载**(`tools/e2e_download.py`)—— **13/13 通过**
|
||||||
|
|
||||||
|
```
|
||||||
|
产物: [4K]当你用《航拍中国》的方式打开崂山育才——航拍育才 [480P标清].mp4
|
||||||
|
大小: 11.2 MB 流: h264 852x480 + aac
|
||||||
|
时长: 90.0s(源 91s) 全片解码: 无错误
|
||||||
|
速度: 峰值 15.9 MB/s,16 线程 耗时: 2.0s
|
||||||
|
中间文件: 已清理
|
||||||
|
```
|
||||||
|
|
||||||
|
**浏览器界面**(`tools/ui-check.mjs`,Edge 无头 + CDP)—— **18/18 通过**
|
||||||
|
|
||||||
|
其中包含**真的驱动界面**:填入关键词 → 触发提交 → 等结果渲染(21 个卡片)→ 点选第一个
|
||||||
|
→ 等清晰度解析 → 断言「开始下载」可点。另有:React 挂载、深色主题生效、无横向溢出、零脚本错误。
|
||||||
|
|
||||||
|
## 两个必须知道的坑(都踩过)
|
||||||
|
|
||||||
|
### 1. 清晰度列表必须取 `dash.video`,不能取 `accept_quality`
|
||||||
|
|
||||||
|
`accept_quality` / `support_formats` 只反映视频**标称**支持什么,与当前账号能否下载无关。
|
||||||
|
实测:未登录时 `accept_quality` 仍列出 `120(4K)`,但 `dash.video` 里只有 `32(480P)`。
|
||||||
|
按前者建列表会让用户选到根本拿不到的清晰度,点下载才报错。
|
||||||
|
|
||||||
|
### 2. 产物文件名必须带清晰度
|
||||||
|
|
||||||
|
否则同一视频下不同清晰度会互相覆盖 —— 实测踩过:480P 的验收下载把之前下好的 4K 文件直接盖掉。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
`config.json`(与 `server.py` 同目录):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sessdata": "...",
|
||||||
|
"outputDir": "E:\\deepseek\\downloads",
|
||||||
|
"threads": 16,
|
||||||
|
"preferAvc": true,
|
||||||
|
"keepParts": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`threads` 实测:16 线程约 4.1 MB/s,32 线程约 6.8 MB/s;过高可能触发 CDN 限流。
|
||||||
|
|
||||||
|
> ⚠️ `sessdata` 等同于账号登录凭据,明文存在本机。**别把这个目录分享给他人。**
|
||||||
|
> 未登录/失效时 B 站只放出 480P 及以下,界面会明确提示。
|
||||||
|
|
||||||
|
## 重新构建前端
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd ui
|
||||||
|
npm install # 首次
|
||||||
|
npm run build # 产物到 ui/dist,由 server.py 托管
|
||||||
|
npm run dev # 开发模式(热更新,代理 /api 到 8799)
|
||||||
|
```
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"sessdata": "12892060%2C1804723781%2Caa6de%2A91CjBKyjjjtXUBib-hcvUvs7CT7YUiP5j-0qIMAlKTi3BHY_jNPgE6Ze49_LBruJQGivwSVk9NeEh3RXBBajc3TXJlbWRqYlQ0SzJjRzVsSE51dmtuSXV4VF9ZSkZwSHY3d1paaTBUSGtxdWRXbHVYRG9JVmk5bmRhR0s3QUEwVHZZRlV1aG5WMUR3IIEC",
|
||||||
|
"outputDir": "E:\\deepseek\\downloads",
|
||||||
|
"threads": 16,
|
||||||
|
"preferAvc": true,
|
||||||
|
"keepParts": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
engine.py —— BiliDownloader 下载引擎(被 server.py 导入)
|
||||||
|
|
||||||
|
这里的核心逻辑全部来自已经实测跑通的 bili_download.py,行为保持一致:
|
||||||
|
* WBI 签名的站内搜索(老的无签名接口已被 B 站封禁)
|
||||||
|
* 视频详情 / 分P / 清晰度列表(playurl qn=127 拿全部可用清晰度)
|
||||||
|
* 多线程分段加速下载:Range 分片 + 并发 + 每片独立重试续传 + 旁路状态文件跨进程续传
|
||||||
|
* ffmpeg 无损封装为 mp4
|
||||||
|
|
||||||
|
只依赖 Python 标准库;ffmpeg 需在 PATH 或常见安装位置。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
|
API_VIEW = "https://api.bilibili.com/x/web-interface/view?bvid={bvid}"
|
||||||
|
API_NAV = "https://api.bilibili.com/x/web-interface/nav"
|
||||||
|
API_SEARCH = "https://api.bilibili.com/x/web-interface/wbi/search/type"
|
||||||
|
API_PLAYURL = ("https://api.bilibili.com/x/player/playurl?bvid={bvid}&cid={cid}"
|
||||||
|
"&qn=127&fnver=0&fnval=4048&fourk=1")
|
||||||
|
|
||||||
|
QN_NAME = {
|
||||||
|
127: "8K 超高清", 126: "杜比视界", 125: "HDR 真彩", 120: "4K 超清",
|
||||||
|
116: "1080P60", 112: "1080P 高码率", 80: "1080P 高清", 74: "720P60",
|
||||||
|
64: "720P 高清", 32: "480P 清晰", 16: "360P 流畅", 6: "240P 极速",
|
||||||
|
}
|
||||||
|
|
||||||
|
# WBI 签名置换表(与 bilibili-API-collect 公开算法一致)
|
||||||
|
MIXIN_KEY_ENC_TAB = [
|
||||||
|
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
|
||||||
|
33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61,
|
||||||
|
26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36,
|
||||||
|
20, 34, 44, 52,
|
||||||
|
]
|
||||||
|
|
||||||
|
CHUNK = 1 << 18 # 256KB
|
||||||
|
SEG_MIN = 2 << 20 # 小于 2MB 不值得分片
|
||||||
|
|
||||||
|
|
||||||
|
class BiliError(RuntimeError):
|
||||||
|
"""带 B 站错误码的异常。"""
|
||||||
|
|
||||||
|
def __init__(self, code: int, message: str):
|
||||||
|
super().__init__(f"B 站接口错误 {code}: {message}")
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
|
def human(n: float) -> str:
|
||||||
|
for unit in ("B", "KB", "MB", "GB"):
|
||||||
|
if n < 1024 or unit == "GB":
|
||||||
|
return f"{n:.1f}{unit}" if unit != "B" else f"{int(n)}B"
|
||||||
|
n /= 1024
|
||||||
|
return f"{n:.1f}GB"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ HTTP
|
||||||
|
|
||||||
|
def _headers(referer: str, cookie: str | None, extra: dict | None = None) -> dict:
|
||||||
|
h = {"User-Agent": UA, "Referer": referer, "Accept": "*/*",
|
||||||
|
"Accept-Encoding": "identity", "Connection": "keep-alive"}
|
||||||
|
if cookie:
|
||||||
|
h["Cookie"] = cookie
|
||||||
|
if extra:
|
||||||
|
h.update(extra)
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def _get(url: str, referer: str = "https://www.bilibili.com",
|
||||||
|
cookie: str | None = None, timeout: int = 30) -> bytes:
|
||||||
|
req = urllib.request.Request(url, headers=_headers(referer, cookie))
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
return r.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _api(url: str, cookie: str | None = None, referer: str = "https://www.bilibili.com") -> dict:
|
||||||
|
raw = _get(url, referer, cookie)
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw.decode("utf-8"))
|
||||||
|
except Exception as exc:
|
||||||
|
raise BiliError(-1, "B 站返回了非 JSON 内容(可能触发了风控)") from exc
|
||||||
|
if payload.get("code") != 0:
|
||||||
|
raise BiliError(payload.get("code", -1), payload.get("message", ""))
|
||||||
|
return payload["data"]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ WBI 签名
|
||||||
|
|
||||||
|
def _mixin_key(img_url: str, sub_url: str) -> str:
|
||||||
|
img = os.path.basename(urllib.parse.urlparse(img_url).path).split(".")[0]
|
||||||
|
sub = os.path.basename(urllib.parse.urlparse(sub_url).path).split(".")[0]
|
||||||
|
raw = img + sub
|
||||||
|
return "".join(raw[i] for i in MIXIN_KEY_ENC_TAB)[:32]
|
||||||
|
|
||||||
|
|
||||||
|
def _sign(params: dict, mixin_key: str) -> str:
|
||||||
|
params = dict(params)
|
||||||
|
params["wts"] = int(time.time())
|
||||||
|
items = sorted(params.items())
|
||||||
|
query = urllib.parse.urlencode(
|
||||||
|
[(k, "".join(c for c in str(v) if c not in "!'()*")) for k, v in items])
|
||||||
|
w_rid = hashlib.md5((query + mixin_key).encode()).hexdigest()
|
||||||
|
return f"{query}&w_rid={w_rid}"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 业务接口
|
||||||
|
|
||||||
|
def login_status(cookie: str | None) -> dict:
|
||||||
|
"""未登录时 nav 返回 code=-101,但 data.wbi_img 仍然可用,所以这里不抛异常。"""
|
||||||
|
try:
|
||||||
|
raw = _get(API_NAV, cookie=cookie)
|
||||||
|
payload = json.loads(raw.decode("utf-8"))
|
||||||
|
except Exception as exc:
|
||||||
|
return {"isLogin": False, "uname": "", "error": str(exc)}
|
||||||
|
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
return {"isLogin": bool(data.get("isLogin")), "uname": data.get("uname") or "",
|
||||||
|
"code": payload.get("code")}
|
||||||
|
|
||||||
|
|
||||||
|
def search(keyword: str, page: int = 1, page_size: int = 20,
|
||||||
|
cookie: str | None = None) -> dict:
|
||||||
|
"""站内搜索。必须走 WBI 签名接口 —— 老的无签名接口已被封(返回非 JSON)。"""
|
||||||
|
nav = json.loads(_get(API_NAV, cookie=cookie).decode("utf-8"))
|
||||||
|
wbi = (nav.get("data") or {}).get("wbi_img") or {}
|
||||||
|
img_url, sub_url = wbi.get("img_url"), wbi.get("sub_url")
|
||||||
|
if not img_url or not sub_url:
|
||||||
|
raise BiliError(-1, "无法获取 WBI 密钥")
|
||||||
|
|
||||||
|
query = _sign({"search_type": "video", "keyword": keyword,
|
||||||
|
"page": page, "page_size": page_size}, _mixin_key(img_url, sub_url))
|
||||||
|
|
||||||
|
payload = json.loads(_get(f"{API_SEARCH}?{query}", cookie=cookie).decode("utf-8"))
|
||||||
|
if payload.get("code") != 0:
|
||||||
|
raise BiliError(payload.get("code", -1), payload.get("message", ""))
|
||||||
|
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
results = []
|
||||||
|
for item in (data.get("result") or []):
|
||||||
|
title = re.sub(r"<em[^>]*>|</em>", "", item.get("title") or "")
|
||||||
|
pic = item.get("pic") or ""
|
||||||
|
if pic.startswith("//"):
|
||||||
|
pic = "https:" + pic
|
||||||
|
results.append({
|
||||||
|
"bvid": item.get("bvid"),
|
||||||
|
"title": title,
|
||||||
|
"author": item.get("author"),
|
||||||
|
"duration": item.get("duration"), # 字符串,如 "2:28"
|
||||||
|
"play": item.get("play"),
|
||||||
|
"danmaku": item.get("danmaku"),
|
||||||
|
"pic": pic,
|
||||||
|
"description": item.get("description") or item.get("desc") or "",
|
||||||
|
})
|
||||||
|
return {"items": results, "page": page,
|
||||||
|
"numResults": data.get("numResults"),
|
||||||
|
"hasMore": len(results) >= page_size}
|
||||||
|
|
||||||
|
|
||||||
|
def video_detail(bvid: str, cookie: str | None = None) -> dict:
|
||||||
|
data = _api(API_VIEW.format(bvid=bvid), cookie)
|
||||||
|
pic = data.get("pic") or ""
|
||||||
|
if pic.startswith("//"):
|
||||||
|
pic = "https:" + pic
|
||||||
|
return {
|
||||||
|
"bvid": data.get("bvid"),
|
||||||
|
"title": data.get("title"),
|
||||||
|
"pic": pic,
|
||||||
|
"duration": data.get("duration"), # int 秒
|
||||||
|
"desc": data.get("desc") or "",
|
||||||
|
"owner": (data.get("owner") or {}).get("name"),
|
||||||
|
"stat": data.get("stat") or {},
|
||||||
|
"pages": [{"cid": p.get("cid"), "page": p.get("page"),
|
||||||
|
"part": p.get("part"), "duration": p.get("duration")}
|
||||||
|
for p in (data.get("pages") or [])],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def play_url(bvid: str, cid: int, cookie: str | None = None) -> dict:
|
||||||
|
"""qn=127 请求最高清晰度,让服务端把可用的清晰度都返回。
|
||||||
|
|
||||||
|
★ 关键:清晰度列表必须按 `dash.video` 里**真正拿到的流**来建,不能用
|
||||||
|
`accept_quality` / `support_formats` —— 那两个只反映视频"标称"支持什么,
|
||||||
|
与当前账号能否下载无关。实测:未登录时 accept_quality 仍列出 120(4K),
|
||||||
|
但 dash.video 里只有 32(480P)。若按 accept_quality 建列表,用户会选到根本拿不到的清晰度,
|
||||||
|
点下载才报错。
|
||||||
|
"""
|
||||||
|
data = _api(API_PLAYURL.format(bvid=bvid, cid=cid), cookie)
|
||||||
|
accept = data.get("accept_quality") or []
|
||||||
|
formats = {f.get("quality"): f for f in (data.get("support_formats") or [])}
|
||||||
|
|
||||||
|
dash = data.get("dash") or {}
|
||||||
|
videos = [{"quality": s.get("id"), "baseUrl": s.get("baseUrl"),
|
||||||
|
"backupUrl": s.get("backupUrl") or [], "codecs": s.get("codecs") or "",
|
||||||
|
"width": s.get("width"), "height": s.get("height"),
|
||||||
|
"bandwidth": s.get("bandwidth")}
|
||||||
|
for s in (dash.get("video") or []) if s.get("baseUrl")]
|
||||||
|
audios = [{"baseUrl": s.get("baseUrl"), "backupUrl": s.get("backupUrl") or [],
|
||||||
|
"codecs": s.get("codecs") or "", "bandwidth": s.get("bandwidth")}
|
||||||
|
for s in (dash.get("audio") or []) if s.get("baseUrl")]
|
||||||
|
|
||||||
|
# 同一清晰度可能有多种编码,取码率最高的一条来展示尺寸
|
||||||
|
best: dict[int, dict] = {}
|
||||||
|
for s in videos:
|
||||||
|
q = s["quality"]
|
||||||
|
if q is None:
|
||||||
|
continue
|
||||||
|
if q not in best or (s["bandwidth"] or 0) > (best[q]["bandwidth"] or 0):
|
||||||
|
best[q] = s
|
||||||
|
|
||||||
|
qualities = []
|
||||||
|
for q in sorted(best, reverse=True):
|
||||||
|
s = best[q]
|
||||||
|
f = formats.get(q) or {}
|
||||||
|
qualities.append({
|
||||||
|
"quality": q,
|
||||||
|
"label": f.get("new_description") or QN_NAME.get(q, str(q)),
|
||||||
|
"width": s.get("width"), "height": s.get("height"),
|
||||||
|
"codecs": s.get("codecs"),
|
||||||
|
"bandwidth": s.get("bandwidth"),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"qualities": qualities, "video": videos, "audio": audios,
|
||||||
|
# 实际可下载的最高清晰度(用它判断是否被"未登录"限制到 480P)
|
||||||
|
"maxQuality": max(best) if best else 0,
|
||||||
|
# 视频标称支持的清晰度,仅作参考展示
|
||||||
|
"advertisedQuality": max(accept) if accept else 0,
|
||||||
|
"duration": dash.get("duration") or 0}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 下载
|
||||||
|
|
||||||
|
def _state_path(dest: str) -> str:
|
||||||
|
return dest + ".parts.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _resource_key(url: str) -> str:
|
||||||
|
"""取 URL 中标识资源的稳定部分。
|
||||||
|
|
||||||
|
baseUrl 带 e= 时效签名,重跑时签名字段与 CDN 域名都会变;只有 path
|
||||||
|
(含 avid/cid/清晰度)保持不变,所以续传状态必须以 path 为键。
|
||||||
|
"""
|
||||||
|
return urllib.parse.urlparse(url).path
|
||||||
|
|
||||||
|
|
||||||
|
def _load_state(dest: str, url: str, total: int, n: int) -> list[int]:
|
||||||
|
if not os.path.exists(dest) or os.path.getsize(dest) != total:
|
||||||
|
return [0] * n
|
||||||
|
try:
|
||||||
|
with open(_state_path(dest), "r", encoding="utf-8") as f:
|
||||||
|
st = json.load(f)
|
||||||
|
if st.get("key") != _resource_key(url) or st.get("total") != total:
|
||||||
|
return [0] * n
|
||||||
|
offs = [int(x) for x in st["offsets"]]
|
||||||
|
if len(offs) != n:
|
||||||
|
return [0] * n
|
||||||
|
return [max(0, min(o, total)) for o in offs]
|
||||||
|
except Exception:
|
||||||
|
return [0] * n
|
||||||
|
|
||||||
|
|
||||||
|
def _save_state(dest: str, url: str, total: int, offsets: list[int]) -> None:
|
||||||
|
tmp = _state_path(dest) + ".tmp"
|
||||||
|
try:
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"key": _resource_key(url), "total": total, "offsets": offsets}, f)
|
||||||
|
os.replace(tmp, _state_path(dest))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def probe(url: str, referer: str, cookie: str | None) -> tuple[int, bool]:
|
||||||
|
"""探测总长度与是否支持 Range 分片。"""
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, headers=_headers(referer, cookie, {"Range": "bytes=0-0"}))
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
if r.status == 206:
|
||||||
|
cr = r.headers.get("Content-Range", "")
|
||||||
|
return (int(cr.split("/")[-1]) if "/" in cr else 0), True
|
||||||
|
return int(r.headers.get("Content-Length") or 0), False
|
||||||
|
|
||||||
|
|
||||||
|
def _download_range(url: str, fd, referer: str, cookie: str | None,
|
||||||
|
start: int, end: int, on_bytes, cancel, retries: int = 24,
|
||||||
|
on_chunk=None) -> None:
|
||||||
|
cur = start
|
||||||
|
attempt = 0
|
||||||
|
while cur <= end:
|
||||||
|
if cancel is not None and cancel.is_set():
|
||||||
|
raise InterruptedError("已取消")
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, headers=_headers(referer, cookie, {"Range": f"bytes={cur}-{end}"}))
|
||||||
|
with urllib.request.urlopen(req, timeout=45) as r:
|
||||||
|
if r.status != 206:
|
||||||
|
raise IOError(f"服务器未返回 206 (got {r.status})")
|
||||||
|
os.lseek(fd, cur, os.SEEK_SET)
|
||||||
|
while True:
|
||||||
|
chunk = r.read(CHUNK)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
os.write(fd, chunk)
|
||||||
|
cur += len(chunk)
|
||||||
|
on_bytes(len(chunk))
|
||||||
|
if on_chunk:
|
||||||
|
on_chunk(cur)
|
||||||
|
if cur <= end:
|
||||||
|
raise IOError("连接提前结束")
|
||||||
|
attempt = 0
|
||||||
|
except InterruptedError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
attempt += 1
|
||||||
|
if attempt > retries:
|
||||||
|
raise RuntimeError(f"分片重试 {retries} 次仍失败: {e}")
|
||||||
|
time.sleep(min(0.5 * attempt, 5))
|
||||||
|
|
||||||
|
|
||||||
|
def download_fast(url: str, dest: str, referer: str, cookie: str | None,
|
||||||
|
threads: int = 16, on_progress=None, cancel=None,
|
||||||
|
label: str = "视频") -> dict:
|
||||||
|
"""多线程分段加速下载,支持跨进程断点续传。返回统计信息。"""
|
||||||
|
total, ranges_ok = probe(url, referer, cookie)
|
||||||
|
|
||||||
|
state = {"done": 0, "total": total, "t0": time.time(), "resumed": 0}
|
||||||
|
|
||||||
|
def report():
|
||||||
|
if not on_progress:
|
||||||
|
return
|
||||||
|
el = max(time.time() - state["t0"], 1e-6)
|
||||||
|
on_progress({
|
||||||
|
"label": label,
|
||||||
|
"downloaded": state["done"],
|
||||||
|
"total": total,
|
||||||
|
"percent": (state["done"] * 100 / total) if total else 0,
|
||||||
|
"speed": state["done"] / el,
|
||||||
|
"threads": 1 if not ranges_ok else max(1, min(threads, 64)),
|
||||||
|
"resumed": state["resumed"],
|
||||||
|
})
|
||||||
|
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
def on_bytes(n):
|
||||||
|
with lock:
|
||||||
|
state["done"] += n
|
||||||
|
|
||||||
|
if not ranges_ok or total < SEG_MIN or threads <= 1:
|
||||||
|
have = os.path.getsize(dest) if os.path.exists(dest) else 0
|
||||||
|
if have and have < total:
|
||||||
|
state["done"] = have
|
||||||
|
state["resumed"] = have
|
||||||
|
report()
|
||||||
|
mode = "ab" if have else "wb"
|
||||||
|
req_headers = _headers(referer, cookie,
|
||||||
|
{"Range": f"bytes={have}-"} if have else None)
|
||||||
|
with urllib.request.urlopen(urllib.request.Request(url, headers=req_headers),
|
||||||
|
timeout=45) as r:
|
||||||
|
if have and r.status != 206:
|
||||||
|
have = 0
|
||||||
|
mode = "wb"
|
||||||
|
state["done"] = 0
|
||||||
|
with open(dest, mode) as f:
|
||||||
|
while True:
|
||||||
|
if cancel is not None and cancel.is_set():
|
||||||
|
raise InterruptedError("已取消")
|
||||||
|
chunk = r.read(CHUNK)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
f.write(chunk)
|
||||||
|
on_bytes(len(chunk))
|
||||||
|
report()
|
||||||
|
return {"total": total, "threads": 1, "resumed": state["resumed"]}
|
||||||
|
|
||||||
|
n = max(1, min(threads, 64))
|
||||||
|
seg = total // n
|
||||||
|
spans = [(i * seg, total - 1 if i == n - 1 else (i + 1) * seg - 1) for i in range(n)]
|
||||||
|
|
||||||
|
offsets = _load_state(dest, url, total, n)
|
||||||
|
if not os.path.exists(dest) or os.path.getsize(dest) != total:
|
||||||
|
with open(dest, "wb") as f:
|
||||||
|
f.truncate(total)
|
||||||
|
offsets = [0] * n
|
||||||
|
|
||||||
|
resume_done = sum(offsets)
|
||||||
|
if resume_done:
|
||||||
|
state["done"] = resume_done
|
||||||
|
state["resumed"] = resume_done
|
||||||
|
report()
|
||||||
|
|
||||||
|
last_flush = [time.time()]
|
||||||
|
errors: list[Exception] = []
|
||||||
|
state_lock = threading.Lock()
|
||||||
|
|
||||||
|
def flush(force=False):
|
||||||
|
with state_lock:
|
||||||
|
now = time.time()
|
||||||
|
if not force and now - last_flush[0] < 2.0:
|
||||||
|
return
|
||||||
|
last_flush[0] = now
|
||||||
|
_save_state(dest, url, total, offsets)
|
||||||
|
|
||||||
|
def worker(i, s, e):
|
||||||
|
try:
|
||||||
|
fd = os.open(dest, os.O_RDWR | getattr(os, "O_BINARY", 0))
|
||||||
|
try:
|
||||||
|
def on_chunk(cur_abs, i=i, s=s):
|
||||||
|
with state_lock:
|
||||||
|
offsets[i] = cur_abs - s + 1
|
||||||
|
flush()
|
||||||
|
_download_range(url, fd, referer, cookie, s + offsets[i], e,
|
||||||
|
on_bytes, cancel, on_chunk=on_chunk)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
with state_lock:
|
||||||
|
offsets[i] = e - s + 1
|
||||||
|
except InterruptedError as ex:
|
||||||
|
errors.append(ex)
|
||||||
|
except Exception as ex:
|
||||||
|
errors.append(ex)
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
reporter_stop = threading.Event()
|
||||||
|
|
||||||
|
def reporter():
|
||||||
|
while not reporter_stop.wait(0.4):
|
||||||
|
report()
|
||||||
|
|
||||||
|
threading.Thread(target=reporter, daemon=True).start()
|
||||||
|
|
||||||
|
ts = [threading.Thread(target=worker, args=(i, s, e), daemon=True)
|
||||||
|
for i, (s, e) in enumerate(spans)]
|
||||||
|
for t in ts:
|
||||||
|
t.start()
|
||||||
|
for t in ts:
|
||||||
|
t.join()
|
||||||
|
reporter_stop.set()
|
||||||
|
|
||||||
|
if any(isinstance(e, InterruptedError) for e in errors):
|
||||||
|
flush(force=True)
|
||||||
|
raise InterruptedError("已取消")
|
||||||
|
if errors:
|
||||||
|
flush(force=True)
|
||||||
|
raise RuntimeError(f"下载失败(进度已保存, 重跑可续传): {errors[0]}")
|
||||||
|
|
||||||
|
if os.path.getsize(dest) != total:
|
||||||
|
raise RuntimeError(f"大小不符: {os.path.getsize(dest)} != {total}")
|
||||||
|
if os.path.exists(_state_path(dest)):
|
||||||
|
os.remove(_state_path(dest))
|
||||||
|
|
||||||
|
report()
|
||||||
|
return {"total": total, "threads": n, "resumed": resume_done,
|
||||||
|
"elapsed": time.time() - t0}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ ffmpeg
|
||||||
|
|
||||||
|
def find_ffmpeg() -> str | None:
|
||||||
|
exe = shutil.which("ffmpeg")
|
||||||
|
if exe:
|
||||||
|
return exe
|
||||||
|
for p in (r"C:\ffmpeg\bin\ffmpeg.exe",
|
||||||
|
r"C:\Users\Administrator\miniconda3\Library\bin\ffmpeg.exe"):
|
||||||
|
if os.path.exists(p):
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def merge(video: str, audio: str | None, out: str) -> None:
|
||||||
|
ff = find_ffmpeg()
|
||||||
|
if not ff:
|
||||||
|
raise RuntimeError("找不到 ffmpeg")
|
||||||
|
cmd = [ff, "-y", "-hide_banner", "-loglevel", "error", "-i", video]
|
||||||
|
if audio:
|
||||||
|
cmd += ["-i", audio, "-map", "0:v:0", "-map", "1:a:0", "-c", "copy"]
|
||||||
|
else:
|
||||||
|
cmd += ["-c", "copy"]
|
||||||
|
cmd += ["-movflags", "+faststart", out]
|
||||||
|
subprocess.run(cmd, check=True, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 工具
|
||||||
|
|
||||||
|
def safe_name(name: str) -> str:
|
||||||
|
name = re.sub(r'[\\/:*?"<>|\r\n\t]', "_", name).strip(" .")
|
||||||
|
return (name or "video")[:120]
|
||||||
|
|
||||||
|
|
||||||
|
def pick_streams(play: dict, quality: int, prefer_avc: bool = True):
|
||||||
|
"""在指定清晰度下选码流:优先 H.264(兼容性最好),音频取码率最大。"""
|
||||||
|
pool = [s for s in play["video"] if s["quality"] == quality]
|
||||||
|
if not pool:
|
||||||
|
return None, None
|
||||||
|
if prefer_avc:
|
||||||
|
avc = [s for s in pool if s["codecs"].startswith(("avc", "av01"))]
|
||||||
|
pool = avc or pool
|
||||||
|
video = max(pool, key=lambda s: s["bandwidth"])
|
||||||
|
if not play["audio"]:
|
||||||
|
return video, None
|
||||||
|
audio = max(play["audio"], key=lambda s: s["bandwidth"])
|
||||||
|
return video, audio
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
server.py —— BiliDownloader 本地服务(零第三方依赖)
|
||||||
|
|
||||||
|
只监听 127.0.0.1。职责:
|
||||||
|
* 托管前端构建产物 (ui/dist)
|
||||||
|
* 提供 JSON API(搜索 / 详情 / 清晰度 / 开始下载 / 取消 / 配置)
|
||||||
|
* 用 Server-Sent Events 把下载进度实时推给浏览器
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python server.py [--port 8799] [--no-open]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
import urllib.parse
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
import engine
|
||||||
|
|
||||||
|
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
DIST = os.path.join(BASE, "ui", "dist")
|
||||||
|
CONFIG_PATH = os.path.join(BASE, "config.json")
|
||||||
|
|
||||||
|
DEFAULT_CONFIG = {
|
||||||
|
"sessdata": "",
|
||||||
|
"outputDir": os.path.join(os.path.expanduser("~"), "Videos", "BiliDownloader"),
|
||||||
|
"threads": 16,
|
||||||
|
"preferAvc": True,
|
||||||
|
"keepParts": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
_config_lock = threading.Lock()
|
||||||
|
_job_lock = threading.Lock()
|
||||||
|
_job: dict | None = None # 当前/最近一次下载任务
|
||||||
|
_cancel: threading.Event | None = None
|
||||||
|
_streams: dict[str, dict] = {} # (bvid,cid) -> playurl 结果
|
||||||
|
_subscribers: list[queue.Queue] = []
|
||||||
|
_play_cache: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 配置
|
||||||
|
|
||||||
|
def load_config() -> dict:
|
||||||
|
cfg = dict(DEFAULT_CONFIG)
|
||||||
|
try:
|
||||||
|
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||||
|
cfg.update(json.load(f))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
cfg["threads"] = max(1, min(64, int(cfg.get("threads") or 16)))
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(cfg: dict) -> dict:
|
||||||
|
merged = load_config()
|
||||||
|
merged.update({k: v for k, v in cfg.items() if k in DEFAULT_CONFIG})
|
||||||
|
merged["threads"] = max(1, min(64, int(merged.get("threads") or 16)))
|
||||||
|
with _config_lock:
|
||||||
|
tmp = CONFIG_PATH + ".tmp"
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(merged, f, ensure_ascii=False, indent=2)
|
||||||
|
os.replace(tmp, CONFIG_PATH)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def cookie_of(cfg: dict) -> str | None:
|
||||||
|
s = (cfg.get("sessdata") or "").strip()
|
||||||
|
return f"SESSDATA={s}" if s else None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 事件广播
|
||||||
|
|
||||||
|
def broadcast(event: str, data: dict) -> None:
|
||||||
|
payload = f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||||
|
dead = []
|
||||||
|
for q in list(_subscribers):
|
||||||
|
try:
|
||||||
|
q.put_nowait(payload)
|
||||||
|
except Exception:
|
||||||
|
dead.append(q)
|
||||||
|
for q in dead:
|
||||||
|
try:
|
||||||
|
_subscribers.remove(q)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 下载任务
|
||||||
|
|
||||||
|
def start_download(bvid: str, cid: int, quality: int, title: str) -> dict:
|
||||||
|
global _job, _cancel
|
||||||
|
|
||||||
|
with _job_lock:
|
||||||
|
if _job and _job.get("state") == "running":
|
||||||
|
raise RuntimeError("已有下载任务在进行中")
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
key = f"{bvid}:{cid}"
|
||||||
|
play = _streams.get(key)
|
||||||
|
if not play:
|
||||||
|
raise RuntimeError("播放地址不存在或已过期,请重新选择视频")
|
||||||
|
|
||||||
|
video, audio = engine.pick_streams(play, quality, cfg.get("preferAvc", True))
|
||||||
|
if video is None:
|
||||||
|
raise RuntimeError("当前清晰度没有可用的视频流")
|
||||||
|
|
||||||
|
out_dir = cfg["outputDir"]
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
base = engine.safe_name(title)
|
||||||
|
# 文件名必须带上清晰度:否则同一视频下不同清晰度会互相覆盖
|
||||||
|
# (实测踩过:480P 的验收下载把之前下好的 4K 文件直接盖掉了)
|
||||||
|
q_label = next((q["label"] for q in play.get("qualities", [])
|
||||||
|
if q["quality"] == quality), str(quality))
|
||||||
|
q_tag = engine.safe_name(q_label).replace(" ", "")
|
||||||
|
stamp = time.strftime("%Y%m%d%H%M%S")
|
||||||
|
final_path = os.path.join(out_dir, f"{base} [{q_tag}].mp4")
|
||||||
|
tmp_v = os.path.join(out_dir, f".{base}.{cid}.{stamp}.video.m4s")
|
||||||
|
tmp_a = os.path.join(out_dir, f".{base}.{cid}.{stamp}.audio.m4s")
|
||||||
|
|
||||||
|
_cancel = threading.Event()
|
||||||
|
_job = {
|
||||||
|
"state": "running", "stage": "下载视频流", "bvid": bvid, "cid": cid,
|
||||||
|
"title": title, "quality": quality, "outputPath": final_path,
|
||||||
|
"startedAt": time.time(), "video": None, "audio": None,
|
||||||
|
"progress": {"video": None, "audio": None}, "error": None,
|
||||||
|
"resumed": 0, "threads": cfg["threads"],
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast("job", dict(_job))
|
||||||
|
threading.Thread(target=_run_job, args=(video, audio, tmp_v, tmp_a, final_path,
|
||||||
|
cfg, bvid, cid), daemon=True).start()
|
||||||
|
return dict(_job)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_job(video, audio, tmp_v, tmp_a, final_path, cfg, bvid, cid) -> None:
|
||||||
|
global _job
|
||||||
|
cookie = cookie_of(cfg)
|
||||||
|
referer = f"https://www.bilibili.com/video/{bvid}"
|
||||||
|
threads = cfg["threads"]
|
||||||
|
|
||||||
|
def make_on_progress(which):
|
||||||
|
def on_progress(info):
|
||||||
|
_job["progress"][which] = info
|
||||||
|
broadcast("progress", {"which": which, **info, "stage": _job["stage"]})
|
||||||
|
return on_progress
|
||||||
|
|
||||||
|
try:
|
||||||
|
_job["stage"] = "下载视频流"
|
||||||
|
broadcast("stage", {"stage": _job["stage"]})
|
||||||
|
vres = engine.download_fast(video["baseUrl"], tmp_v, referer, cookie,
|
||||||
|
threads, make_on_progress("video"), _cancel, "视频")
|
||||||
|
_job["resumed"] = vres.get("resumed", 0)
|
||||||
|
|
||||||
|
if audio:
|
||||||
|
_job["stage"] = "下载音频流"
|
||||||
|
broadcast("stage", {"stage": _job["stage"]})
|
||||||
|
engine.download_fast(audio["baseUrl"], tmp_a, referer, cookie,
|
||||||
|
max(1, min(8, threads)), make_on_progress("audio"),
|
||||||
|
_cancel, "音频")
|
||||||
|
|
||||||
|
_job["stage"] = "ffmpeg 封装"
|
||||||
|
broadcast("stage", {"stage": _job["stage"]})
|
||||||
|
engine.merge(tmp_v, tmp_a if audio else None, final_path)
|
||||||
|
|
||||||
|
if not cfg.get("keepParts"):
|
||||||
|
for p in (tmp_v, tmp_a):
|
||||||
|
try:
|
||||||
|
if os.path.exists(p):
|
||||||
|
os.remove(p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_job["state"] = "done"
|
||||||
|
_job["stage"] = "完成"
|
||||||
|
_job["size"] = os.path.getsize(final_path)
|
||||||
|
_job["elapsed"] = time.time() - _job["startedAt"]
|
||||||
|
broadcast("job", dict(_job))
|
||||||
|
|
||||||
|
except InterruptedError:
|
||||||
|
for p in (final_path,):
|
||||||
|
try:
|
||||||
|
if os.path.exists(p):
|
||||||
|
os.remove(p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if not cfg.get("keepParts"):
|
||||||
|
for p in (tmp_v, tmp_a):
|
||||||
|
try:
|
||||||
|
if os.path.exists(p):
|
||||||
|
os.remove(p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
_job["state"] = "cancelled"
|
||||||
|
_job["stage"] = "已取消,未完成的文件已清理"
|
||||||
|
broadcast("job", dict(_job))
|
||||||
|
except Exception as exc:
|
||||||
|
for p in (final_path,):
|
||||||
|
try:
|
||||||
|
if os.path.exists(p):
|
||||||
|
os.remove(p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
_job["state"] = "error"
|
||||||
|
_job["error"] = str(exc)
|
||||||
|
broadcast("job", dict(_job))
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ HTTP
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "BiliDownloader"
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
pass # 静音访问日志
|
||||||
|
|
||||||
|
# ---------- helpers
|
||||||
|
def _json(self, obj, status=200):
|
||||||
|
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _error(self, message, status=400):
|
||||||
|
self._json({"ok": False, "error": str(message)}, status)
|
||||||
|
|
||||||
|
def _query(self) -> dict:
|
||||||
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
|
return {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items()}, parsed.path
|
||||||
|
|
||||||
|
def _body(self) -> dict:
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
if not length:
|
||||||
|
return {}
|
||||||
|
return json.loads(self.rfile.read(length).decode("utf-8"))
|
||||||
|
|
||||||
|
# ---------- routes
|
||||||
|
def do_GET(self):
|
||||||
|
params, path = self._query()
|
||||||
|
try:
|
||||||
|
if path == "/api/config":
|
||||||
|
cfg = load_config()
|
||||||
|
cfg["configPath"] = CONFIG_PATH
|
||||||
|
cfg["ffmpeg"] = engine.find_ffmpeg()
|
||||||
|
return self._json(cfg)
|
||||||
|
|
||||||
|
if path == "/api/login":
|
||||||
|
cfg = load_config()
|
||||||
|
return self._json(engine.login_status(cookie_of(cfg)))
|
||||||
|
|
||||||
|
if path == "/api/search":
|
||||||
|
cfg = load_config()
|
||||||
|
return self._json(engine.search(
|
||||||
|
params.get("q", ""), int(params.get("page", 1)), 20, cookie_of(cfg)))
|
||||||
|
|
||||||
|
if path == "/api/video":
|
||||||
|
cfg = load_config()
|
||||||
|
return self._json(engine.video_detail(params["bvid"], cookie_of(cfg)))
|
||||||
|
|
||||||
|
if path == "/api/playurl":
|
||||||
|
cfg = load_config()
|
||||||
|
bvid, cid = params["bvid"], int(params["cid"])
|
||||||
|
play = engine.play_url(bvid, cid, cookie_of(cfg))
|
||||||
|
_streams[f"{bvid}:{cid}"] = play
|
||||||
|
return self._json(play)
|
||||||
|
|
||||||
|
if path == "/api/job":
|
||||||
|
return self._json(_job or {})
|
||||||
|
|
||||||
|
if path == "/api/events":
|
||||||
|
return self._sse()
|
||||||
|
|
||||||
|
if path.startswith("/api/"):
|
||||||
|
return self._error("未知接口", 404)
|
||||||
|
|
||||||
|
return self._static(path)
|
||||||
|
except engine.BiliError as exc:
|
||||||
|
return self._error(f"{exc.message}(code {exc.code})", 502)
|
||||||
|
except Exception as exc:
|
||||||
|
traceback.print_exc()
|
||||||
|
return self._error(exc, 500)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
_, path = self._query()
|
||||||
|
try:
|
||||||
|
if path == "/api/config":
|
||||||
|
return self._json(save_config(self._body()))
|
||||||
|
|
||||||
|
if path == "/api/download":
|
||||||
|
data = self._body()
|
||||||
|
job = start_download(data["bvid"], int(data["cid"]),
|
||||||
|
int(data["quality"]), data.get("title") or "video")
|
||||||
|
return self._json(job)
|
||||||
|
|
||||||
|
if path == "/api/cancel":
|
||||||
|
if _cancel:
|
||||||
|
_cancel.set()
|
||||||
|
return self._json({"ok": True})
|
||||||
|
|
||||||
|
if path == "/api/open-folder":
|
||||||
|
target = (self._body() or {}).get("path") or load_config()["outputDir"]
|
||||||
|
if os.path.exists(target):
|
||||||
|
subprocess.Popen(["explorer", "/select,", os.path.normpath(target)])
|
||||||
|
else:
|
||||||
|
os.makedirs(target, exist_ok=True)
|
||||||
|
subprocess.Popen(["explorer", os.path.normpath(target)])
|
||||||
|
return self._json({"ok": True})
|
||||||
|
|
||||||
|
return self._error("未知接口", 404)
|
||||||
|
except Exception as exc:
|
||||||
|
traceback.print_exc()
|
||||||
|
return self._error(exc, 500)
|
||||||
|
|
||||||
|
# ---------- SSE
|
||||||
|
def _sse(self):
|
||||||
|
q: queue.Queue = queue.Queue()
|
||||||
|
_subscribers.append(q)
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("Connection", "keep-alive")
|
||||||
|
self.end_headers()
|
||||||
|
try:
|
||||||
|
hello = f"event: job\ndata: {json.dumps(_job or {}, ensure_ascii=False)}\n\n"
|
||||||
|
self.wfile.write(hello.encode("utf-8"))
|
||||||
|
self.wfile.flush()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
payload = q.get(timeout=15)
|
||||||
|
self.wfile.write(payload.encode("utf-8"))
|
||||||
|
except queue.Empty:
|
||||||
|
self.wfile.write(b": ping\n\n") # 心跳,防代理断连
|
||||||
|
self.wfile.flush()
|
||||||
|
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
_subscribers.remove(q)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ---------- 静态文件
|
||||||
|
def _static(self, path: str):
|
||||||
|
if not os.path.isdir(DIST):
|
||||||
|
body = ("<!doctype html><meta charset=utf-8>"
|
||||||
|
"<h1>前端还没构建</h1>"
|
||||||
|
"<p>请在 ui 目录执行 <code>npm run build</code>,"
|
||||||
|
"或直接用 <code>npm run dev</code> 起开发服务器。</p>").encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
return
|
||||||
|
|
||||||
|
rel = urllib.parse.unquote(path.lstrip("/")) or "index.html"
|
||||||
|
target = os.path.normpath(os.path.join(DIST, rel))
|
||||||
|
# 防目录穿越
|
||||||
|
if not target.startswith(os.path.normpath(DIST)):
|
||||||
|
return self._error("非法路径", 403)
|
||||||
|
if not os.path.isfile(target):
|
||||||
|
target = os.path.join(DIST, "index.html") # SPA 回退
|
||||||
|
if not os.path.isfile(target):
|
||||||
|
return self._error("Not found", 404)
|
||||||
|
|
||||||
|
ctype = mimetypes.guess_type(target)[0] or "application/octet-stream"
|
||||||
|
if ctype.startswith("text/") or ctype in ("application/javascript", "application/json"):
|
||||||
|
ctype += "; charset=utf-8"
|
||||||
|
with open(target, "rb") as f:
|
||||||
|
body = f.read()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", ctype)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store" if target.endswith(".html") else "public, max-age=3600")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--port", type=int, default=8799)
|
||||||
|
ap.add_argument("--no-open", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
if not os.path.exists(CONFIG_PATH):
|
||||||
|
save_config(cfg)
|
||||||
|
|
||||||
|
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
||||||
|
url = f"http://127.0.0.1:{args.port}/"
|
||||||
|
print(f"BiliDownloader 服务已启动: {url}", flush=True)
|
||||||
|
print(f"配置: {CONFIG_PATH}", flush=True)
|
||||||
|
print(f"ffmpeg: {engine.find_ffmpeg() or '未找到(合并会失败)'}", flush=True)
|
||||||
|
|
||||||
|
if not args.no_open:
|
||||||
|
threading.Thread(target=_open_browser, args=(url,), daemon=True).start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
httpd.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _open_browser(url: str) -> None:
|
||||||
|
time.sleep(0.8)
|
||||||
|
try:
|
||||||
|
edge = None
|
||||||
|
for p in (r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
|
||||||
|
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe"):
|
||||||
|
if os.path.exists(p):
|
||||||
|
edge = p
|
||||||
|
break
|
||||||
|
if edge:
|
||||||
|
# 应用模式窗口:无地址栏、无标签页,看起来就是一个独立应用
|
||||||
|
subprocess.Popen([edge, f"--app={url}", "--window-size=1280,860"])
|
||||||
|
else:
|
||||||
|
subprocess.Popen(["cmd", "/c", "start", "", url], shell=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
@echo off
|
||||||
|
rem BiliDownloader 启动脚本:起本地服务 + 开 Edge 应用模式窗口
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
set "PYTHONIOENCODING=utf-8"
|
||||||
|
|
||||||
|
rem 已经有实例在跑,就直接把窗口拉起来,不要再起一个服务
|
||||||
|
powershell -NoProfile -Command "if (Get-NetTCPConnection -LocalPort 8799 -State Listen -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }" >nul 2>nul
|
||||||
|
if %errorlevel%==0 (
|
||||||
|
start "" "msedge" --app=http://127.0.0.1:8799/ --window-size=1280,860
|
||||||
|
exit /b 0
|
||||||
|
)
|
||||||
|
|
||||||
|
rem 优先用 pythonw(无控制台窗口),没有就退回 python
|
||||||
|
where pythonw >nul 2>nul
|
||||||
|
if %errorlevel%==0 (
|
||||||
|
start "" pythonw "%~dp0server.py" --port 8799
|
||||||
|
) else (
|
||||||
|
start "" /min python "%~dp0server.py" --port 8799
|
||||||
|
)
|
||||||
|
exit /b 0
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
@echo off
|
||||||
|
rem 停止 BiliDownloader 本地服务
|
||||||
|
setlocal
|
||||||
|
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":8799" ^| findstr LISTENING') do (
|
||||||
|
taskkill /PID %%a /F >nul 2>nul
|
||||||
|
)
|
||||||
|
echo 已停止 BiliDownloader 服务。
|
||||||
|
exit /b 0
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""诊断:nav 为何报未登录 + dash.video 实际内容。"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
sys.path.insert(0, r"E:\deepseek\BiliDownloaderWeb")
|
||||||
|
import engine
|
||||||
|
|
||||||
|
cfg = json.load(open(r"E:\deepseek\BiliDownloaderWeb\config.json", encoding="utf-8"))
|
||||||
|
sess = cfg["sessdata"]
|
||||||
|
print("config 里 sessdata 长度 =", len(sess))
|
||||||
|
print("前 24 字符 =", sess[:24])
|
||||||
|
print("后 24 字符 =", sess[-24:])
|
||||||
|
|
||||||
|
print("\n--- 直接请求 nav,看原始返回 ---")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
engine.API_NAV,
|
||||||
|
headers={"User-Agent": engine.UA, "Referer": "https://www.bilibili.com",
|
||||||
|
"Cookie": "SESSDATA=" + sess})
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
raw = json.loads(r.read().decode("utf-8"))
|
||||||
|
print("code =", raw.get("code"), " message =", raw.get("message"))
|
||||||
|
d = raw.get("data") or {}
|
||||||
|
print("isLogin =", d.get("isLogin"), " uname =", d.get("uname"))
|
||||||
|
print("有 wbi_img =", bool(d.get("wbi_img")))
|
||||||
|
|
||||||
|
print("\n--- dash.video 实际内容(qn=127)---")
|
||||||
|
vid = engine.video_detail("BV1HVBAYLEAo", "SESSDATA=" + sess)
|
||||||
|
cid = vid["pages"][0]["cid"]
|
||||||
|
p = engine.play_url("BV1HVBAYLEAo", cid, "SESSDATA=" + sess)
|
||||||
|
print("maxQuality =", p["maxQuality"])
|
||||||
|
for s in p["video"]:
|
||||||
|
print(f" 视频流 qn={s['quality']:>4} {s['width']}x{s['height']} codec={s['codecs']} {s['bandwidth']//1000}kbps")
|
||||||
|
for s in p["audio"]:
|
||||||
|
print(f" 音频流 {s['codecs']} {s['bandwidth']//1000}kbps")
|
||||||
|
print("qualities 的 width/height 字段示例:", p["qualities"][0])
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
e2e_download.py —— 端到端验收:通过服务的 HTTP 接口真的下一次,并校验产物。
|
||||||
|
|
||||||
|
流程:playurl -> POST /api/download -> 订阅 SSE 收进度 -> 等完成 -> ffprobe 校验
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
BASE = "http://127.0.0.1:8799"
|
||||||
|
BVID = sys.argv[1] if len(sys.argv) > 1 else "BV1HVBAYLEAo"
|
||||||
|
|
||||||
|
ok_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, ok, detail=""):
|
||||||
|
global ok_count, fail_count
|
||||||
|
if ok:
|
||||||
|
ok_count += 1
|
||||||
|
print(f" [OK] {name}" + (f" — {detail}" if detail else ""))
|
||||||
|
else:
|
||||||
|
fail_count += 1
|
||||||
|
print(f" [XX] {name}" + (f" — {detail}" if detail else ""))
|
||||||
|
|
||||||
|
|
||||||
|
def get(path):
|
||||||
|
with urllib.request.urlopen(BASE + path, timeout=60) as r:
|
||||||
|
return json.loads(r.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def post(path, payload):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BASE + path, data=json.dumps(payload).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
return json.loads(r.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
print("=" * 62)
|
||||||
|
print(f"端到端下载验收: {BVID}")
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
video = get(f"/api/video?bvid={BVID}")
|
||||||
|
cid = video["pages"][0]["cid"]
|
||||||
|
print(f"\n视频: {video['title']}")
|
||||||
|
print(f"UP主: {video['owner']} 时长: {video['duration']}s")
|
||||||
|
|
||||||
|
play = get(f"/api/playurl?bvid={BVID}&cid={cid}")
|
||||||
|
check("拿到清晰度列表", len(play["qualities"]) > 0,
|
||||||
|
" / ".join(q["label"] for q in play["qualities"]))
|
||||||
|
quality = play["qualities"][0]["quality"]
|
||||||
|
print(f"选择清晰度: {quality} ({play['qualities'][0]['label']})")
|
||||||
|
|
||||||
|
# 先确认没有正在跑的任务
|
||||||
|
job0 = get("/api/job")
|
||||||
|
check("初始无进行中的任务", job0.get("state") != "running", str(job0.get("state")))
|
||||||
|
|
||||||
|
# 订阅 SSE
|
||||||
|
events = []
|
||||||
|
prog_samples = []
|
||||||
|
stop = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
|
def listen():
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(BASE + "/api/events", timeout=600) as r:
|
||||||
|
event = None
|
||||||
|
while not stop.is_set():
|
||||||
|
line = r.readline().decode("utf-8", "replace").strip()
|
||||||
|
if line.startswith("event:"):
|
||||||
|
event = line.split(":", 1)[1].strip()
|
||||||
|
elif line.startswith("data:"):
|
||||||
|
data = json.loads(line.split(":", 1)[1].strip())
|
||||||
|
events.append((event, data))
|
||||||
|
if event == "progress":
|
||||||
|
prog_samples.append(data)
|
||||||
|
except Exception as exc:
|
||||||
|
if not stop.is_set():
|
||||||
|
print(" SSE 读取结束:", exc)
|
||||||
|
|
||||||
|
|
||||||
|
t = threading.Thread(target=listen, daemon=True)
|
||||||
|
t.start()
|
||||||
|
time.sleep(0.8)
|
||||||
|
|
||||||
|
print("\n--- 开始下载 ---")
|
||||||
|
t0 = time.time()
|
||||||
|
job = post("/api/download", {"bvid": BVID, "cid": cid, "quality": quality,
|
||||||
|
"title": video["title"]})
|
||||||
|
check("已受理下载任务", job.get("state") == "running", str(job.get("state")))
|
||||||
|
|
||||||
|
deadline = time.time() + 300
|
||||||
|
last_state = None
|
||||||
|
while time.time() < deadline:
|
||||||
|
time.sleep(1)
|
||||||
|
cur = get("/api/job")
|
||||||
|
if cur.get("state") != last_state:
|
||||||
|
last_state = cur.get("state")
|
||||||
|
print(f" [{time.time() - t0:5.1f}s] state={last_state} stage={cur.get('stage')}")
|
||||||
|
if cur.get("state") in ("done", "error", "cancelled"):
|
||||||
|
break
|
||||||
|
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
stop.set()
|
||||||
|
final = get("/api/job")
|
||||||
|
|
||||||
|
check("任务正常结束", final.get("state") == "done", str(final.get("state") or final.get("error")))
|
||||||
|
check("收到了进度事件", len(prog_samples) > 0, f"{len(prog_samples)} 条")
|
||||||
|
check("同时收到 job/stage 事件", len(events) > 0, f"{len(events)} 条")
|
||||||
|
|
||||||
|
out = final.get("outputPath") or ""
|
||||||
|
check("产物文件存在", bool(out) and os.path.exists(out), out)
|
||||||
|
if out and os.path.exists(out):
|
||||||
|
size = os.path.getsize(out)
|
||||||
|
check("产物大小合理", size > 100_000, f"{size / 1024 / 1024:.1f} MB")
|
||||||
|
ffprobe = None
|
||||||
|
import shutil
|
||||||
|
ffprobe = shutil.which("ffprobe") or r"C:\Users\Administrator\miniconda3\Library\bin\ffprobe.exe"
|
||||||
|
cp = subprocess.run(
|
||||||
|
[ffprobe, "-v", "error", "-show_entries",
|
||||||
|
"format=duration,size:stream=codec_type,codec_name,width,height",
|
||||||
|
"-of", "json", out],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
if cp.returncode == 0:
|
||||||
|
info = json.loads(cp.stdout)
|
||||||
|
streams = info.get("streams", [])
|
||||||
|
kinds = {s["codec_type"]: s for s in streams}
|
||||||
|
check("产物含视频流", "video" in kinds,
|
||||||
|
f"{kinds.get('video', {}).get('codec_name')} "
|
||||||
|
f"{kinds.get('video', {}).get('width')}x{kinds.get('video', {}).get('height')}")
|
||||||
|
check("产物含音频流", "audio" in kinds, str(kinds.get("audio", {}).get("codec_name")))
|
||||||
|
dur = float(info["format"]["duration"])
|
||||||
|
check("时长与源基本一致", abs(dur - video["duration"]) < 5, f"{dur:.1f}s vs {video['duration']}s")
|
||||||
|
else:
|
||||||
|
check("ffprobe 可解析产物", False, cp.stderr.strip()[:120])
|
||||||
|
|
||||||
|
# 全片解码校验
|
||||||
|
ffmpeg = shutil.which("ffmpeg") or r"C:\Users\Administrator\miniconda3\Library\bin\ffmpeg.exe"
|
||||||
|
cp2 = subprocess.run([ffmpeg, "-v", "error", "-i", out, "-f", "null", "-"],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
check("全片解码无错误", cp2.returncode == 0 and not cp2.stderr.strip(),
|
||||||
|
cp2.stderr.strip()[:160] or "clean")
|
||||||
|
|
||||||
|
# 临时文件应已清理
|
||||||
|
tmp_left = [f for f in os.listdir(os.path.dirname(out)) if f.endswith(".m4s")]
|
||||||
|
check("中间文件已清理", len(tmp_left) == 0, f"残留 {len(tmp_left)} 个" if tmp_left else "无残留")
|
||||||
|
|
||||||
|
# 并发保护:任务已结束,再来一次应能受理(说明状态机复位)
|
||||||
|
print(f"\n耗时 {elapsed:.1f}s")
|
||||||
|
print("\n进度采样(每 10 条取 1):")
|
||||||
|
for s in prog_samples[::max(1, len(prog_samples) // 10)][:10]:
|
||||||
|
print(f" {s['label']} {s['percent']:5.1f}% {s['downloaded']/1024/1024:6.1f}MB"
|
||||||
|
f"/{s['total']/1024/1024:.1f}MB {s['speed']/1024/1024:.2f}MB/s {s['threads']}线程")
|
||||||
|
|
||||||
|
print("\n" + "=" * 62)
|
||||||
|
print(f"结果: {ok_count} 通过 / {fail_count} 失败")
|
||||||
|
print("=" * 62)
|
||||||
|
sys.exit(1 if fail_count else 0)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
Add-Type @"
|
||||||
|
using System;
|
||||||
|
using System.Text;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
public class WinEnum {
|
||||||
|
public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);
|
||||||
|
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr lParam);
|
||||||
|
[DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr hWnd);
|
||||||
|
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder s, int n);
|
||||||
|
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
|
||||||
|
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT r);
|
||||||
|
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; }
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
|
||||||
|
$found = New-Object System.Collections.ArrayList
|
||||||
|
$cb = [WinEnum+EnumProc]{
|
||||||
|
param($h, $l)
|
||||||
|
$len = [WinEnum]::GetWindowTextLength($h)
|
||||||
|
if ($len -gt 0 -and [WinEnum]::IsWindowVisible($h)) {
|
||||||
|
$sb = New-Object System.Text.StringBuilder ($len + 2)
|
||||||
|
[void][WinEnum]::GetWindowText($h, $sb, $sb.Capacity)
|
||||||
|
$t = $sb.ToString()
|
||||||
|
$r = New-Object WinEnum+RECT
|
||||||
|
[void][WinEnum]::GetWindowRect($h, [ref]$r)
|
||||||
|
[void]$found.Add([pscustomobject]@{
|
||||||
|
Title = $t
|
||||||
|
W = $r.R - $r.L
|
||||||
|
H = $r.B - $r.T
|
||||||
|
X = $r.L
|
||||||
|
Y = $r.T
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
[void][WinEnum]::EnumWindows($cb, [IntPtr]::Zero)
|
||||||
|
|
||||||
|
Write-Output "=== 可见顶层窗口中标题含 BiliDownloader 的 ==="
|
||||||
|
$found | Where-Object { $_.Title -like "*BiliDownloader*" } |
|
||||||
|
Select-Object Title, W, H, X, Y | Format-Table -AutoSize | Out-String
|
||||||
|
|
||||||
|
Write-Output "=== 前 12 个可见窗口(对照)==="
|
||||||
|
$found | Sort-Object -Property W -Descending | Select-Object -First 12 Title, W, H |
|
||||||
|
Format-Table -AutoSize | Out-String
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""后端 API 冒烟测试。"""
|
||||||
|
import json
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
BASE = "http://127.0.0.1:8799"
|
||||||
|
|
||||||
|
|
||||||
|
def get(path):
|
||||||
|
with urllib.request.urlopen(BASE + path, timeout=60) as r:
|
||||||
|
return json.loads(r.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def post(path, payload):
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(BASE + path, data=data,
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
return json.loads(r.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
print("--- /api/config ---")
|
||||||
|
cfg = get("/api/config")
|
||||||
|
print(f"threads={cfg['threads']} outputDir={cfg['outputDir']}")
|
||||||
|
print(f"ffmpeg={cfg['ffmpeg']}")
|
||||||
|
print(f"configPath={cfg['configPath']}")
|
||||||
|
|
||||||
|
print("\n--- /api/login ---")
|
||||||
|
print(get("/api/login"))
|
||||||
|
|
||||||
|
print("\n--- /api/search?q=航拍中国 ---")
|
||||||
|
s = get("/api/search?q=" + urllib.parse.quote("航拍中国"))
|
||||||
|
print(f"numResults={s['numResults']} hasMore={s['hasMore']} 条数={len(s['items'])}")
|
||||||
|
for it in s["items"][:5]:
|
||||||
|
print(f" {it['bvid']} {it['title'][:34]} up={it['author']} dur={it['duration']} play={it['play']}")
|
||||||
|
print(f" pic = {s['items'][0]['pic']}")
|
||||||
|
|
||||||
|
print("\n--- /api/video?bvid=BV1HVBAYLEAo ---")
|
||||||
|
v = get("/api/video?bvid=BV1HVBAYLEAo")
|
||||||
|
print(f"title={v['title']}")
|
||||||
|
print(f"owner={v['owner']} duration={v['duration']}s pages={len(v['pages'])}")
|
||||||
|
cid = v["pages"][0]["cid"]
|
||||||
|
print(f"cid={cid}")
|
||||||
|
|
||||||
|
print("\n--- /api/playurl ---")
|
||||||
|
p = get(f"/api/playurl?bvid=BV1HVBAYLEAo&cid={cid}")
|
||||||
|
print(f"maxQuality={p['maxQuality']} video流={len(p['video'])} audio流={len(p['audio'])}")
|
||||||
|
for q in p["qualities"]:
|
||||||
|
print(f" qn={q['quality']:>4} {q['label']} {q.get('width')}x{q.get('height')}")
|
||||||
|
print(f" 4K 候选: " + ", ".join(
|
||||||
|
f"{s['quality']}/{s['codecs']}/{s['bandwidth']//1000}kbps" for s in p["video"] if s["quality"] == 120))
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* ui-check.mjs —— 浏览器端验收(Edge 无头 + CDP)
|
||||||
|
*
|
||||||
|
* 不只是"页面能打开":真的驱动界面做一次搜索、点选结果、等待清晰度解析,
|
||||||
|
* 并读取渲染后的 DOM 断言每一处关键内容。
|
||||||
|
*
|
||||||
|
* 用法: node tools/ui-check.mjs [baseUrl]
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import WebSocket from '../ui/node_modules/ws/index.js';
|
||||||
|
|
||||||
|
const BASE = process.argv[2] || 'http://127.0.0.1:8799/';
|
||||||
|
const PORT = 9333;
|
||||||
|
|
||||||
|
const EDGE_CANDIDATES = [
|
||||||
|
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||||
|
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||||
|
];
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
function check(name, ok, detail = '') {
|
||||||
|
results.push({ name, ok, detail });
|
||||||
|
console.log(`${ok ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const fs = await import('node:fs');
|
||||||
|
const edge = EDGE_CANDIDATES.find((p) => fs.existsSync(p));
|
||||||
|
if (!edge) throw new Error('找不到 Edge');
|
||||||
|
|
||||||
|
const profile = mkdtempSync(join(tmpdir(), 'bd-ui-'));
|
||||||
|
const child = spawn(
|
||||||
|
edge,
|
||||||
|
[
|
||||||
|
'--headless=new',
|
||||||
|
'--disable-gpu',
|
||||||
|
'--no-first-run',
|
||||||
|
'--no-default-browser-check',
|
||||||
|
`--remote-debugging-port=${PORT}`,
|
||||||
|
`--user-data-dir=${profile}`,
|
||||||
|
'--window-size=1280,860',
|
||||||
|
BASE,
|
||||||
|
],
|
||||||
|
{ stdio: 'ignore' },
|
||||||
|
);
|
||||||
|
|
||||||
|
let target = null;
|
||||||
|
for (let i = 0; i < 40 && !target; i++) {
|
||||||
|
await sleep(500);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${PORT}/json/list`);
|
||||||
|
const list = await res.json();
|
||||||
|
target = list.find((t) => t.type === 'page' && t.url.startsWith('http'));
|
||||||
|
} catch {
|
||||||
|
/* 还没起来 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!target) throw new Error('CDP 目标未就绪');
|
||||||
|
|
||||||
|
const ws = new WebSocket(target.webSocketDebuggerUrl, { maxPayload: 64 * 1024 * 1024 });
|
||||||
|
await new Promise((res, rej) => {
|
||||||
|
ws.once('open', res);
|
||||||
|
ws.once('error', rej);
|
||||||
|
});
|
||||||
|
|
||||||
|
let id = 0;
|
||||||
|
const pending = new Map();
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
const msg = JSON.parse(raw.toString());
|
||||||
|
if (msg.id && pending.has(msg.id)) {
|
||||||
|
pending.get(msg.id)(msg);
|
||||||
|
pending.delete(msg.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const send = (method, params = {}) =>
|
||||||
|
new Promise((res) => {
|
||||||
|
const myId = ++id;
|
||||||
|
pending.set(myId, res);
|
||||||
|
ws.send(JSON.stringify({ id: myId, method, params }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const evaluate = async (expression) => {
|
||||||
|
const r = await send('Runtime.evaluate', {
|
||||||
|
expression,
|
||||||
|
awaitPromise: true,
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
if (r.result?.exceptionDetails) {
|
||||||
|
throw new Error(r.result.exceptionDetails.exception?.description || 'JS 异常');
|
||||||
|
}
|
||||||
|
return r.result?.result?.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
await send('Runtime.enable');
|
||||||
|
await sleep(3500); // 等 React 挂载 + 首屏取配置/登录状态
|
||||||
|
|
||||||
|
console.log(`\n目标: ${BASE}\n`);
|
||||||
|
|
||||||
|
// ---------- 1. 首屏渲染 ----------
|
||||||
|
const text = await evaluate('document.body.innerText');
|
||||||
|
check('React 已挂载并渲染', !!text && text.length > 60, `${text?.length ?? 0} 字符`);
|
||||||
|
for (const key of ['BiliDownloader', '搜索站内视频', '未登录', '还没有选择视频', '清晰度', '开始下载', '日志']) {
|
||||||
|
check(`首屏含「${key}」`, text.includes(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 2. 暗色主题 ----------
|
||||||
|
const isDark = await evaluate("document.documentElement.classList.contains('dark')");
|
||||||
|
check('默认深色主题生效', isDark === true);
|
||||||
|
const bg = await evaluate(
|
||||||
|
"getComputedStyle(document.body).backgroundColor",
|
||||||
|
);
|
||||||
|
check('背景取自 shadcn token(非默认白)', bg !== 'rgb(255, 255, 255)' && bg !== 'rgba(0, 0, 0, 0)', bg);
|
||||||
|
|
||||||
|
// ---------- 3. 无横向溢出 ----------
|
||||||
|
const overflow = await evaluate(
|
||||||
|
'document.documentElement.scrollWidth - document.documentElement.clientWidth',
|
||||||
|
);
|
||||||
|
check('无横向溢出', overflow <= 0, `diff=${overflow}`);
|
||||||
|
|
||||||
|
// ---------- 4. 真的搜一次 ----------
|
||||||
|
console.log('\n —— 驱动一次真实搜索 ——');
|
||||||
|
await evaluate(`(() => {
|
||||||
|
const input = document.querySelector('input');
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
|
||||||
|
setter.call(input, '航拍中国');
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
const form = input.closest('form');
|
||||||
|
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
await sleep(6000);
|
||||||
|
|
||||||
|
const afterSearch = await evaluate('document.body.innerText');
|
||||||
|
const cardCount = await evaluate(
|
||||||
|
"document.querySelectorAll('button[type=button]').length",
|
||||||
|
);
|
||||||
|
check('搜索结果已渲染出卡片', cardCount >= 10, `${cardCount} 个可点卡片`);
|
||||||
|
|
||||||
|
// ---------- 5. 点选一个结果 → 清晰度解析 ----------
|
||||||
|
console.log('\n —— 点选第一个结果,等待清晰度解析 ——');
|
||||||
|
await evaluate(`(() => {
|
||||||
|
const btn = document.querySelector('button[type=button]');
|
||||||
|
btn.click();
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
await sleep(9000);
|
||||||
|
|
||||||
|
const afterPick = await evaluate('document.body.innerText');
|
||||||
|
check('已进入待下载状态(出现「保存到」)', afterPick.includes('保存到'));
|
||||||
|
check('详情面板显示 BV 号', /BV[0-9A-Za-z]{10}/.test(afterPick));
|
||||||
|
check('清晰度已解析(出现「可用清晰度」日志或档位)', /可用清晰度|480P|360P|1080P|4K/.test(afterPick));
|
||||||
|
const hasStartEnabled = await evaluate(`(() => {
|
||||||
|
const b = [...document.querySelectorAll('button')].find(x => x.textContent.includes('开始下载'));
|
||||||
|
return b ? !b.disabled : null;
|
||||||
|
})()`);
|
||||||
|
check('「开始下载」按钮可点', hasStartEnabled === true);
|
||||||
|
|
||||||
|
// ---------- 6. 控制台报错 ----------
|
||||||
|
const errs = await evaluate('window.__bdErrors ? window.__bdErrors.length : 0');
|
||||||
|
check('页面无致命脚本错误', errs === 0, `${errs} 个`);
|
||||||
|
|
||||||
|
console.log('\n —— 渲染文本采样 ——');
|
||||||
|
console.log(
|
||||||
|
afterPick
|
||||||
|
.split('\n')
|
||||||
|
.filter((l) => l.trim())
|
||||||
|
.slice(0, 26)
|
||||||
|
.map((l) => ' ' + l.slice(0, 96))
|
||||||
|
.join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
child.kill();
|
||||||
|
try {
|
||||||
|
rmSync(profile, { recursive: true, force: true });
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const failed = results.filter((r) => !r.ok);
|
||||||
|
console.log(`\n结果: ${results.length - failed.length}/${results.length} 通过`);
|
||||||
|
if (failed.length) {
|
||||||
|
console.log('失败项:');
|
||||||
|
for (const f of failed) console.log(` - ${f.name} ${f.detail}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('验收脚本异常:', e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>BiliDownloader</title>
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<script>
|
||||||
|
// 主题记忆(默认深色):尽早设置,避免首屏闪白
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('bd.theme');
|
||||||
|
const dark = saved ? saved === 'dark' : true;
|
||||||
|
document.documentElement.classList.toggle('dark', dark);
|
||||||
|
} catch (e) {}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+3620
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "wpywmail-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.2.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "WpywMail 客户端界面(shadcn/ui + Tailwind + Vite)",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b --noCheck && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.20",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-progress": "^1.1.16",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-select": "^2.3.7",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.548.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"tailwind-merge": "^3.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
|
"@types/node": "^24.9.1",
|
||||||
|
"@types/react": "^19.2.2",
|
||||||
|
"@types/react-dom": "^19.2.2",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"tailwindcss": "^4.1.16",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.1.12",
|
||||||
|
"ws": "^8.21.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="7" fill="#0a0a0a"/>
|
||||||
|
<rect x="6" y="9" width="20" height="14" rx="3" fill="none" stroke="#fafafa" stroke-width="2"/>
|
||||||
|
<path d="M7 11.5l9 6.5 9-6.5" fill="none" stroke="#fafafa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 348 B |
+342
@@ -0,0 +1,342 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { CloudDownload, Loader2, Moon, Search, Settings2, Sun, UserRound } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { SearchResults } from '@/components/SearchResults';
|
||||||
|
import { DownloadPanel } from '@/components/DownloadPanel';
|
||||||
|
import { SettingsDialog } from '@/components/SettingsDialog';
|
||||||
|
import { formatBytes } from '@/lib/format';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
extractBvid,
|
||||||
|
subscribe,
|
||||||
|
type AppConfig,
|
||||||
|
type Job,
|
||||||
|
type LoginStatus,
|
||||||
|
type PlayUrl,
|
||||||
|
type SearchItem,
|
||||||
|
type VideoDetail,
|
||||||
|
} from '@/lib/api';
|
||||||
|
|
||||||
|
const stamp = () => new Date().toLocaleTimeString('zh-CN', { hour12: false });
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [config, setConfig] = useState<AppConfig | null>(null);
|
||||||
|
const [login, setLogin] = useState<LoginStatus | null>(null);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [dark, setDark] = useState(() => localStorage.getItem('bd.theme') !== 'light');
|
||||||
|
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [items, setItems] = useState<SearchItem[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [searched, setSearched] = useState(false);
|
||||||
|
const [searchError, setSearchError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [video, setVideo] = useState<VideoDetail | null>(null);
|
||||||
|
const [pageIndex, setPageIndex] = useState(0);
|
||||||
|
const [play, setPlay] = useState<PlayUrl | null>(null);
|
||||||
|
const [loadingPlay, setLoadingPlay] = useState(false);
|
||||||
|
const [quality, setQuality] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const [job, setJob] = useState<Job | null>(null);
|
||||||
|
const [logs, setLogs] = useState<string[]>([]);
|
||||||
|
const [speedHistory, setSpeedHistory] = useState<number[]>([]);
|
||||||
|
|
||||||
|
const lastMilestone = useRef(0);
|
||||||
|
const jobRef = useRef<Job | null>(null);
|
||||||
|
jobRef.current = job;
|
||||||
|
|
||||||
|
const log = useCallback((msg: string) => {
|
||||||
|
setLogs((prev) => [...prev.slice(-299), `[${stamp()}] ${msg}`]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---------------- 主题 ----------------
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.toggle('dark', dark);
|
||||||
|
localStorage.setItem('bd.theme', dark ? 'dark' : 'light');
|
||||||
|
}, [dark]);
|
||||||
|
|
||||||
|
// ---------------- 启动:配置 + 登录状态 + 当前任务 ----------------
|
||||||
|
useEffect(() => {
|
||||||
|
api.config().then(setConfig).catch((e) => log(`读取配置失败:${e.message}`));
|
||||||
|
api
|
||||||
|
.login()
|
||||||
|
.then((s) => {
|
||||||
|
setLogin(s);
|
||||||
|
log(s.isLogin ? `已登录:${s.uname}` : '未登录(B 站只放出 480P 及以下)');
|
||||||
|
})
|
||||||
|
.catch(() => setLogin({ isLogin: false, uname: '' }));
|
||||||
|
api
|
||||||
|
.job()
|
||||||
|
.then((j) => {
|
||||||
|
if (j?.state) setJob(j);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [log]);
|
||||||
|
|
||||||
|
// ---------------- 服务端事件 ----------------
|
||||||
|
useEffect(() => {
|
||||||
|
return subscribe({
|
||||||
|
onJob: (j) => {
|
||||||
|
setJob(j);
|
||||||
|
if (j.state === 'done') {
|
||||||
|
log(`✔ 完成:${j.outputPath}(${formatBytes(j.size)},用时 ${Math.round(j.elapsed ?? 0)}s)`);
|
||||||
|
setSpeedHistory([]);
|
||||||
|
} else if (j.state === 'error') {
|
||||||
|
log(`✖ 失败:${j.error}`);
|
||||||
|
} else if (j.state === 'cancelled') {
|
||||||
|
log('已取消,未完成的文件已清理');
|
||||||
|
setSpeedHistory([]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStage: (s) => log(`▶ ${s.stage}`),
|
||||||
|
onProgress: (p) => {
|
||||||
|
const prog = jobRef.current?.progress;
|
||||||
|
const otherSpeed = p.which === 'video' ? (prog?.audio?.speed ?? 0) : (prog?.video?.speed ?? 0);
|
||||||
|
setSpeedHistory((prev) => [...prev.slice(-59), p.speed + otherSpeed]);
|
||||||
|
const mark = Math.floor(p.percent / 10) * 10;
|
||||||
|
if (mark > lastMilestone.current && mark < 100) {
|
||||||
|
lastMilestone.current = mark;
|
||||||
|
log(`${p.label} ${mark}%(${formatBytes(p.downloaded)}/${formatBytes(p.total)})`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [log]);
|
||||||
|
|
||||||
|
// ---------------- 清晰度 ----------------
|
||||||
|
const loadPlay = useCallback(
|
||||||
|
async (bvid: string, cid: number) => {
|
||||||
|
setLoadingPlay(true);
|
||||||
|
setPlay(null);
|
||||||
|
setQuality(null);
|
||||||
|
try {
|
||||||
|
const p = await api.playUrl(bvid, cid);
|
||||||
|
setPlay(p);
|
||||||
|
if (p.qualities.length) {
|
||||||
|
setQuality(p.qualities[0].quality);
|
||||||
|
log(
|
||||||
|
`可用清晰度 ${p.qualities.length} 档:` +
|
||||||
|
p.qualities.map((q) => q.label).join(' / ') +
|
||||||
|
(p.maxQuality < 80 ? '(未登录,被限制在 480P 及以下)' : ''),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log('该视频没有返回可用流');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log(`解析清晰度失败:${(e as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
setLoadingPlay(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[log],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------- 搜索 / 解析 ----------------
|
||||||
|
const runSearch = useCallback(
|
||||||
|
async (raw: string, nextPage = 1) => {
|
||||||
|
const text = raw.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
const bvid = extractBvid(text);
|
||||||
|
if (bvid) {
|
||||||
|
setSearchError(null);
|
||||||
|
setSearching(true);
|
||||||
|
try {
|
||||||
|
const detail = await api.video(bvid);
|
||||||
|
setVideo(detail);
|
||||||
|
setPageIndex(0);
|
||||||
|
log(`解析视频:${detail.title}`);
|
||||||
|
await loadPlay(bvid, detail.pages[0].cid);
|
||||||
|
} catch (e) {
|
||||||
|
setSearchError(`解析失败:${(e as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearching(true);
|
||||||
|
setSearchError(null);
|
||||||
|
try {
|
||||||
|
const res = await api.search(text, nextPage);
|
||||||
|
setItems((prev) => (nextPage === 1 ? res.items : [...prev, ...res.items]));
|
||||||
|
setPage(res.page);
|
||||||
|
setHasMore(res.hasMore);
|
||||||
|
setSearched(true);
|
||||||
|
if (nextPage === 1) log(`搜索「${text}」:${res.items.length} 条结果`);
|
||||||
|
} catch (e) {
|
||||||
|
setSearchError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[log, loadPlay],
|
||||||
|
);
|
||||||
|
|
||||||
|
const pickItem = useCallback(
|
||||||
|
async (item: SearchItem) => {
|
||||||
|
try {
|
||||||
|
const detail = await api.video(item.bvid);
|
||||||
|
setVideo(detail);
|
||||||
|
setPageIndex(0);
|
||||||
|
log(`选中:${detail.title}`);
|
||||||
|
await loadPlay(item.bvid, detail.pages[0].cid);
|
||||||
|
} catch (e) {
|
||||||
|
log(`加载视频失败:${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[log, loadPlay],
|
||||||
|
);
|
||||||
|
|
||||||
|
const changePage = useCallback(
|
||||||
|
async (i: number) => {
|
||||||
|
if (!video) return;
|
||||||
|
setPageIndex(i);
|
||||||
|
await loadPlay(video.bvid, video.pages[i].cid);
|
||||||
|
},
|
||||||
|
[video, loadPlay],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------- 下载 ----------------
|
||||||
|
const startDownload = useCallback(async () => {
|
||||||
|
if (!video || quality == null) return;
|
||||||
|
lastMilestone.current = 0;
|
||||||
|
setSpeedHistory([]);
|
||||||
|
try {
|
||||||
|
const j = await api.download({
|
||||||
|
bvid: video.bvid,
|
||||||
|
cid: video.pages[pageIndex].cid,
|
||||||
|
quality,
|
||||||
|
title: video.title,
|
||||||
|
});
|
||||||
|
setJob(j);
|
||||||
|
log(`开始下载:${video.title}`);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = (e as Error).message;
|
||||||
|
log(`无法开始下载:${msg}`);
|
||||||
|
setJob({ state: 'error', error: msg });
|
||||||
|
}
|
||||||
|
}, [video, quality, pageIndex, log]);
|
||||||
|
|
||||||
|
const cancel = useCallback(async () => {
|
||||||
|
await api.cancel().catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openFolder = useCallback(
|
||||||
|
(path?: string) => {
|
||||||
|
api.openFolder(path ?? config?.outputDir).catch(() => {});
|
||||||
|
},
|
||||||
|
[config],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background text-foreground flex h-screen flex-col overflow-hidden">
|
||||||
|
{/* ---------------- 顶栏 ---------------- */}
|
||||||
|
<header className="flex h-14 shrink-0 items-center gap-4 border-b px-5">
|
||||||
|
<div className="flex shrink-0 items-center gap-2.5">
|
||||||
|
<div className="bg-primary text-primary-foreground flex size-7 items-center justify-center rounded-md">
|
||||||
|
<CloudDownload className="size-4" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold tracking-tight">BiliDownloader</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="relative ml-2 max-w-[620px] flex-1"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void runSearch(query, 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="搜索站内视频,或粘贴视频链接 / BV 号"
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
{searching && (
|
||||||
|
<Loader2 className="text-muted-foreground absolute top-1/2 right-2.5 size-4 -translate-y-1/2 animate-spin" />
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||||
|
<Badge variant={login?.isLogin ? 'secondary' : 'outline'} className="gap-1.5 font-normal">
|
||||||
|
<UserRound className="size-3" />
|
||||||
|
{login?.isLogin ? login.uname : '未登录'}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title={dark ? '切换到浅色' : '切换到深色'}
|
||||||
|
onClick={() => setDark((d) => !d)}
|
||||||
|
>
|
||||||
|
{dark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" title="设置" onClick={() => setSettingsOpen(true)}>
|
||||||
|
<Settings2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ---------------- 主体 ---------------- */}
|
||||||
|
<main className="grid min-h-0 flex-1 grid-cols-[minmax(400px,0.95fr)_minmax(520px,1.05fr)]">
|
||||||
|
<section className="min-h-0 border-r p-4">
|
||||||
|
<SearchResults
|
||||||
|
items={items}
|
||||||
|
loading={searching && items.length === 0}
|
||||||
|
loadingMore={loadingMore}
|
||||||
|
hasMore={hasMore}
|
||||||
|
searched={searched}
|
||||||
|
error={searchError}
|
||||||
|
selectedBvid={video?.bvid ?? null}
|
||||||
|
onPick={pickItem}
|
||||||
|
onLoadMore={async () => {
|
||||||
|
setLoadingMore(true);
|
||||||
|
await runSearch(query, page + 1);
|
||||||
|
setLoadingMore(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="min-h-0 p-4">
|
||||||
|
<DownloadPanel
|
||||||
|
video={video}
|
||||||
|
play={play}
|
||||||
|
loadingPlay={loadingPlay}
|
||||||
|
quality={quality}
|
||||||
|
onQualityChange={setQuality}
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
onPageChange={changePage}
|
||||||
|
job={job}
|
||||||
|
logs={logs}
|
||||||
|
speedHistory={speedHistory}
|
||||||
|
loggedIn={!!login?.isLogin}
|
||||||
|
outputDir={config?.outputDir ?? ''}
|
||||||
|
onStart={startDownload}
|
||||||
|
onCancel={cancel}
|
||||||
|
onOpenFolder={openFolder}
|
||||||
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<SettingsDialog
|
||||||
|
open={settingsOpen}
|
||||||
|
onOpenChange={setSettingsOpen}
|
||||||
|
config={config}
|
||||||
|
onSaved={(c) => {
|
||||||
|
setConfig(c);
|
||||||
|
log('配置已保存');
|
||||||
|
}}
|
||||||
|
onLoginChanged={(s) => {
|
||||||
|
setLogin(s);
|
||||||
|
log(s.isLogin ? `已登录:${s.uname}` : '未登录 / SESSDATA 无效');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
FolderOpen,
|
||||||
|
Gauge,
|
||||||
|
Loader2,
|
||||||
|
LockKeyhole,
|
||||||
|
Play,
|
||||||
|
Square,
|
||||||
|
Timer,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { formatBytes, formatCount, formatDuration, formatEta, formatSpeed, shortPath } from '@/lib/format';
|
||||||
|
import type { Job, PlayUrl, Quality, StreamProgress, VideoDetail } from '@/lib/api';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
video: VideoDetail | null;
|
||||||
|
play: PlayUrl | null;
|
||||||
|
loadingPlay: boolean;
|
||||||
|
quality: number | null;
|
||||||
|
onQualityChange: (q: number) => void;
|
||||||
|
pageIndex: number;
|
||||||
|
onPageChange: (i: number) => void;
|
||||||
|
job: Job | null;
|
||||||
|
logs: string[];
|
||||||
|
speedHistory: number[];
|
||||||
|
loggedIn: boolean;
|
||||||
|
outputDir: string;
|
||||||
|
onStart: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onOpenFolder: (path?: string) => void;
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Sparkline({ data }: { data: number[] }) {
|
||||||
|
const W = 240;
|
||||||
|
const H = 34;
|
||||||
|
if (data.length < 2) {
|
||||||
|
return <div className="text-muted-foreground/60 h-[34px] text-[11px] leading-[34px]">速度曲线(下载中显示)</div>;
|
||||||
|
}
|
||||||
|
const max = Math.max(...data, 1);
|
||||||
|
const pts = data.map((v, i) => {
|
||||||
|
const x = (i / (data.length - 1)) * W;
|
||||||
|
const y = H - (v / max) * (H - 4) - 2;
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
});
|
||||||
|
const area = `0,${H} ${pts.join(' ')} ${W},${H}`;
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="h-[34px] w-full" aria-hidden>
|
||||||
|
<polygon points={area} className="fill-primary/15" />
|
||||||
|
<polyline
|
||||||
|
points={pts.join(' ')}
|
||||||
|
fill="none"
|
||||||
|
className="stroke-primary"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StreamBar({ label, info }: { label: string; info: StreamProgress | null | undefined }) {
|
||||||
|
const percent = info?.percent ?? 0;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[52px_1fr_auto] items-center gap-3">
|
||||||
|
<span className="text-muted-foreground text-xs">{label}</span>
|
||||||
|
<Progress value={percent} className="h-1.5" />
|
||||||
|
<span className="text-muted-foreground w-[190px] text-right font-mono text-[11px] tabular-nums">
|
||||||
|
{info
|
||||||
|
? `${percent.toFixed(1)}% ${formatBytes(info.downloaded)}/${formatBytes(info.total)} ${formatSpeed(info.speed)}`
|
||||||
|
: '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DownloadPanel({
|
||||||
|
video,
|
||||||
|
play,
|
||||||
|
loadingPlay,
|
||||||
|
quality,
|
||||||
|
onQualityChange,
|
||||||
|
pageIndex,
|
||||||
|
onPageChange,
|
||||||
|
job,
|
||||||
|
logs,
|
||||||
|
speedHistory,
|
||||||
|
loggedIn,
|
||||||
|
outputDir,
|
||||||
|
onStart,
|
||||||
|
onCancel,
|
||||||
|
onOpenFolder,
|
||||||
|
onOpenSettings,
|
||||||
|
}: Props) {
|
||||||
|
const logRef = useRef<HTMLDivElement>(null);
|
||||||
|
const running = job?.state === 'running';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
const videoP = job?.progress?.video;
|
||||||
|
const audioP = job?.progress?.audio;
|
||||||
|
const totalDown = (videoP?.downloaded ?? 0) + (audioP?.downloaded ?? 0);
|
||||||
|
const totalAll = (videoP?.total ?? 0) + (audioP?.total ?? 0);
|
||||||
|
const totalPercent = totalAll > 0 ? (totalDown * 100) / totalAll : 0;
|
||||||
|
const totalSpeed = (videoP?.speed ?? 0) + (audioP?.speed ?? 0);
|
||||||
|
const eta = formatEta({ downloaded: totalDown, total: totalAll, speed: totalSpeed });
|
||||||
|
|
||||||
|
const limitedByLogin = !!play && play.maxQuality > 0 && play.maxQuality < 80;
|
||||||
|
const currentQ: Quality | undefined = play?.qualities.find((q) => q.quality === quality);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-4">
|
||||||
|
{/* ---------------- 已选视频 ---------------- */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
{!video ? (
|
||||||
|
<div className="text-muted-foreground flex h-[104px] flex-col items-center justify-center gap-2 rounded-xl border border-dashed text-sm">
|
||||||
|
<span>还没有选择视频</span>
|
||||||
|
<span className="text-xs opacity-70">在左侧点选一个结果,播放器地址会自动解析</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="bg-muted aspect-video w-[112px] shrink-0 overflow-hidden rounded-md border">
|
||||||
|
{video.pic && (
|
||||||
|
<img
|
||||||
|
src={video.pic}
|
||||||
|
alt={video.title}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
className="size-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<div className="line-clamp-2 text-sm leading-snug font-medium">{video.title}</div>
|
||||||
|
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs">
|
||||||
|
<span>{video.owner}</span>
|
||||||
|
<span className="opacity-40">·</span>
|
||||||
|
<span className="tabular-nums">{formatDuration(video.duration)}</span>
|
||||||
|
{video.stat?.view != null && (
|
||||||
|
<>
|
||||||
|
<span className="opacity-40">·</span>
|
||||||
|
<span className="tabular-nums">{formatCount(video.stat.view)}播放</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="opacity-40">·</span>
|
||||||
|
<span className="font-mono">{video.bvid}</span>
|
||||||
|
</div>
|
||||||
|
{video.pages.length > 1 && (
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground text-xs">分P</span>
|
||||||
|
<Select value={String(pageIndex)} onValueChange={(v) => onPageChange(Number(v))}>
|
||||||
|
<SelectTrigger size="sm" className="w-[260px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{video.pages.map((p, i) => (
|
||||||
|
<SelectItem key={p.cid} value={String(i)}>
|
||||||
|
P{p.page} · {p.part}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* ---------------- 参数 + 动作 ---------------- */}
|
||||||
|
<div className="shrink-0 space-y-3">
|
||||||
|
<div className="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-2">
|
||||||
|
<span className="text-muted-foreground text-xs">清晰度</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{loadingPlay ? (
|
||||||
|
<div className="text-muted-foreground flex h-9 items-center gap-2 text-xs">
|
||||||
|
<Loader2 className="size-3.5 animate-spin" /> 正在解析可用清晰度…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={quality != null ? String(quality) : undefined}
|
||||||
|
onValueChange={(v) => onQualityChange(Number(v))}
|
||||||
|
disabled={!play || play.qualities.length === 0 || running}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[300px]">
|
||||||
|
<SelectValue placeholder="选择清晰度" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{play?.qualities.map((q) => (
|
||||||
|
<SelectItem key={q.quality} value={String(q.quality)}>
|
||||||
|
<span className="flex w-full items-center gap-2">
|
||||||
|
<span>{q.label}</span>
|
||||||
|
{q.width && q.height && (
|
||||||
|
<span className="text-muted-foreground font-mono text-[11px] tabular-nums">
|
||||||
|
{q.width}×{q.height}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{q.codecs && (
|
||||||
|
<span className="text-muted-foreground font-mono text-[11px]">
|
||||||
|
{q.codecs.startsWith('avc') ? 'H.264' : q.codecs.split('.')[0].toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
{currentQ?.bandwidth ? (
|
||||||
|
<span className="text-muted-foreground font-mono text-[11px] tabular-nums">
|
||||||
|
{Math.round(currentQ.bandwidth / 1000)} kbps
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-muted-foreground text-xs">保存到</span>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="text-muted-foreground truncate font-mono text-[11px]" title={outputDir}>
|
||||||
|
{shortPath(outputDir)}
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" size="sm" className="shrink-0" onClick={onOpenSettings}>
|
||||||
|
更改
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{limitedByLogin && (
|
||||||
|
<div className="border-chart-4/40 bg-chart-4/5 text-foreground/90 flex items-start gap-2 rounded-lg border px-3 py-2 text-xs">
|
||||||
|
<LockKeyhole className="text-chart-4 mt-0.5 size-3.5 shrink-0" />
|
||||||
|
<div className="leading-relaxed">
|
||||||
|
当前未登录(或 SESSDATA 已失效),B 站只放出 <b>{play?.qualities[0]?.label ?? '480P'}</b> 及以下。
|
||||||
|
该视频标称支持到 <b>{play?.advertisedQuality === 120 ? '4K' : `${play?.advertisedQuality}P`}</b>。
|
||||||
|
想下高清请在
|
||||||
|
<button className="text-primary mx-1 underline underline-offset-2" onClick={onOpenSettings}>
|
||||||
|
设置
|
||||||
|
</button>
|
||||||
|
里填一个有效的 SESSDATA。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{running ? (
|
||||||
|
<Button variant="destructive" onClick={onCancel} className="min-w-[132px]">
|
||||||
|
<Square className="size-3.5" />
|
||||||
|
取消下载
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={onStart}
|
||||||
|
disabled={!video || !play || quality == null || play.qualities.length === 0}
|
||||||
|
className="min-w-[132px]"
|
||||||
|
>
|
||||||
|
<Play className="size-3.5" />
|
||||||
|
开始下载
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{job?.state === 'done' && (
|
||||||
|
<Button variant="outline" onClick={() => onOpenFolder(job.outputPath)}>
|
||||||
|
<FolderOpen className="size-3.5" />
|
||||||
|
打开文件夹
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="text-muted-foreground ml-auto flex items-center gap-3 font-mono text-[11px] tabular-nums">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Gauge className="size-3" />
|
||||||
|
{job?.threads ?? 16} 线程
|
||||||
|
</span>
|
||||||
|
{(videoP?.threads ?? 0) > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Timer className="size-3" />
|
||||||
|
ETA {eta}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* ---------------- 进度 ---------------- */}
|
||||||
|
<div className="shrink-0 space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<span className="text-xs font-medium">{job?.stage ?? '等待开始'}</span>
|
||||||
|
<span className="font-mono text-sm tabular-nums">{totalPercent.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={totalPercent} className="h-2.5" />
|
||||||
|
<div className="text-muted-foreground flex justify-between font-mono text-[11px] tabular-nums">
|
||||||
|
<span>
|
||||||
|
{formatBytes(totalDown)} / {formatBytes(totalAll)}
|
||||||
|
</span>
|
||||||
|
<span>{formatSpeed(totalSpeed)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<StreamBar label="视频流" info={videoP} />
|
||||||
|
<StreamBar label="音频流" info={audioP} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Sparkline data={speedHistory} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---------------- 结果提示 ---------------- */}
|
||||||
|
{job?.state === 'done' && (
|
||||||
|
<div className="border-primary/30 bg-primary/5 flex items-start gap-2 rounded-lg border px-3 py-2 text-xs">
|
||||||
|
<CheckCircle2 className="text-primary mt-0.5 size-3.5 shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium">
|
||||||
|
下载完成 · {formatBytes(job.size)} · 用时 {formatDuration(job.elapsed)}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground truncate font-mono text-[11px]" title={job.outputPath}>
|
||||||
|
{job.outputPath}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{job?.state === 'error' && (
|
||||||
|
<div className="border-destructive/40 bg-destructive/5 flex items-start gap-2 rounded-lg border px-3 py-2 text-xs">
|
||||||
|
<AlertCircle className="text-destructive mt-0.5 size-3.5 shrink-0" />
|
||||||
|
<div className="leading-relaxed break-all">{job.error}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{job?.state === 'cancelled' && (
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2 rounded-lg border px-3 py-2 text-xs">
|
||||||
|
<X className="size-3.5" />
|
||||||
|
已取消,未完成的文件已清理
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---------------- 日志 ---------------- */}
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-1.5">
|
||||||
|
<span className="text-muted-foreground text-xs">日志</span>
|
||||||
|
<div
|
||||||
|
ref={logRef}
|
||||||
|
className={cn(
|
||||||
|
'bg-muted/40 min-h-[72px] flex-1 overflow-auto rounded-lg border p-2.5',
|
||||||
|
'font-mono text-[11px] leading-relaxed whitespace-pre-wrap',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{logs.length ? logs.join('\n') : <span className="text-muted-foreground/60">(暂无)</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Download, Loader2, Play, SearchX } from 'lucide-react';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { formatCount } from '@/lib/format';
|
||||||
|
import type { SearchItem } from '@/lib/api';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
items: SearchItem[];
|
||||||
|
loading: boolean;
|
||||||
|
loadingMore: boolean;
|
||||||
|
hasMore: boolean;
|
||||||
|
searched: boolean;
|
||||||
|
error: string | null;
|
||||||
|
selectedBvid: string | null;
|
||||||
|
onPick: (item: SearchItem) => void;
|
||||||
|
onLoadMore: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Cover({ src, alt }: { src: string; alt: string }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className="bg-muted relative aspect-video w-[124px] shrink-0 overflow-hidden rounded-md border">
|
||||||
|
{src && !failed ? (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
className="size-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground flex size-full items-center justify-center">
|
||||||
|
<Play className="size-5 opacity-40" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchResults({
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
loadingMore,
|
||||||
|
hasMore,
|
||||||
|
searched,
|
||||||
|
error,
|
||||||
|
selectedBvid,
|
||||||
|
onPick,
|
||||||
|
onLoadMore,
|
||||||
|
}: Props) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 p-1">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex gap-3">
|
||||||
|
<Skeleton className="aspect-video w-[124px] rounded-md" />
|
||||||
|
<div className="flex-1 space-y-2 py-1">
|
||||||
|
<Skeleton className="h-4 w-[85%]" />
|
||||||
|
<Skeleton className="h-3 w-[45%]" />
|
||||||
|
<Skeleton className="h-3 w-[65%]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="text-destructive flex flex-col items-center gap-2 py-16 text-center text-sm">
|
||||||
|
<SearchX className="size-6 opacity-70" />
|
||||||
|
<div className="max-w-[46ch] px-4 leading-relaxed">{error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-3 py-16 text-center">
|
||||||
|
<SearchX className="size-7 opacity-30" />
|
||||||
|
<div className="text-sm">{searched ? '没有找到相关视频' : '搜索站内视频,或直接粘贴视频链接 / BV 号'}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="space-y-1 pr-2">
|
||||||
|
{items.map((item) => {
|
||||||
|
const active = item.bvid === selectedBvid;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.bvid}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick(item)}
|
||||||
|
className={cn(
|
||||||
|
'group hover:bg-accent/60 focus-visible:ring-ring/50 flex w-full items-start gap-3 rounded-lg border border-transparent p-2 text-left transition-colors outline-none focus-visible:ring-[3px]',
|
||||||
|
active && 'bg-accent border-border',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Cover src={item.pic} alt={item.title} />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<div className="line-clamp-2 text-[13px] leading-snug font-medium">{item.title}</div>
|
||||||
|
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||||
|
<span className="max-w-[12ch] truncate">{item.author}</span>
|
||||||
|
<span className="opacity-40">·</span>
|
||||||
|
<span className="tabular-nums">{item.duration}</span>
|
||||||
|
<span className="opacity-40">·</span>
|
||||||
|
<span className="tabular-nums">{formatCount(item.play)}播放</span>
|
||||||
|
</div>
|
||||||
|
{item.description && (
|
||||||
|
<div className="text-muted-foreground/70 line-clamp-1 text-xs">{item.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={active ? 'default' : 'secondary'}
|
||||||
|
className={cn('mt-1 shrink-0 opacity-0 transition-opacity', 'group-hover:opacity-100', active && 'opacity-100')}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPick(item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download className="size-3.5" />
|
||||||
|
下载
|
||||||
|
</Button>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<div className="flex justify-center py-3">
|
||||||
|
<Button variant="ghost" size="sm" disabled={loadingMore} onClick={onLoadMore}>
|
||||||
|
{loadingMore && <Loader2 className="size-3.5 animate-spin" />}
|
||||||
|
加载更多
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { CheckCircle2, FolderOpen, Loader2, ShieldAlert, XCircle } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { api, type AppConfig, type LoginStatus } from '@/lib/api';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
config: AppConfig | null;
|
||||||
|
onSaved: (cfg: AppConfig) => void;
|
||||||
|
onLoginChanged: (s: LoginStatus) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SettingsDialog({ open, onOpenChange, config, onSaved, onLoginChanged }: Props) {
|
||||||
|
const [draft, setDraft] = useState<Partial<AppConfig>>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [login, setLogin] = useState<LoginStatus | null>(null);
|
||||||
|
const [note, setNote] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && config) {
|
||||||
|
setDraft({
|
||||||
|
sessdata: config.sessdata,
|
||||||
|
outputDir: config.outputDir,
|
||||||
|
threads: config.threads,
|
||||||
|
preferAvc: config.preferAvc,
|
||||||
|
keepParts: config.keepParts,
|
||||||
|
});
|
||||||
|
setNote(null);
|
||||||
|
setLogin(null);
|
||||||
|
}
|
||||||
|
}, [open, config]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setNote(null);
|
||||||
|
try {
|
||||||
|
const saved = await api.saveConfig(draft);
|
||||||
|
onSaved(saved);
|
||||||
|
setNote('已保存');
|
||||||
|
} catch (e) {
|
||||||
|
setNote(`保存失败:${(e as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testLogin = async () => {
|
||||||
|
setTesting(true);
|
||||||
|
try {
|
||||||
|
await api.saveConfig(draft); // 先落盘,再测
|
||||||
|
const s = await api.login();
|
||||||
|
setLogin(s);
|
||||||
|
onLoginChanged(s);
|
||||||
|
} catch (e) {
|
||||||
|
setLogin({ isLogin: false, uname: '', code: -1 });
|
||||||
|
setNote(`测试失败:${(e as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[560px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>设置</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
保存后立刻写回 <span className="font-mono text-[11px]">{config?.configPath}</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-1">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="sessdata">SESSDATA</Label>
|
||||||
|
<Input
|
||||||
|
id="sessdata"
|
||||||
|
value={draft.sessdata ?? ''}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, sessdata: e.target.value }))}
|
||||||
|
placeholder="浏览器登录 B 站后从 Cookie 里取出 SESSDATA 的值"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<div className="text-muted-foreground flex items-start gap-1.5 text-[11px] leading-relaxed">
|
||||||
|
<ShieldAlert className="mt-0.5 size-3 shrink-0" />
|
||||||
|
<span>
|
||||||
|
未登录时 B 站只放出 480P 及以下;填了有效 SESSDATA 才能下 1080P / 4K。
|
||||||
|
该值等同于账号登录凭据,保存在本机 <span className="font-mono">config.json</span> 里,别分享给他人。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={testLogin} disabled={testing}>
|
||||||
|
{testing ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||||
|
测试登录状态
|
||||||
|
</Button>
|
||||||
|
{login &&
|
||||||
|
(login.isLogin ? (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-[--chart-2]">
|
||||||
|
<CheckCircle2 className="size-3.5" />
|
||||||
|
已登录:{login.uname}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-destructive flex items-center gap-1 text-xs">
|
||||||
|
<XCircle className="size-3.5" />
|
||||||
|
未登录 / SESSDATA 无效(只能下 480P)
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="outdir">输出目录</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="outdir"
|
||||||
|
value={draft.outputDir ?? ''}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, outputDir: e.target.value }))}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
title="在资源管理器中打开"
|
||||||
|
onClick={() => api.openFolder(draft.outputDir)}
|
||||||
|
>
|
||||||
|
<FolderOpen className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="threads">并发线程数</Label>
|
||||||
|
<Input
|
||||||
|
id="threads"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={64}
|
||||||
|
value={draft.threads ?? 16}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, threads: Number(e.target.value) }))}
|
||||||
|
className="w-[120px] font-mono"
|
||||||
|
/>
|
||||||
|
<div className="text-muted-foreground text-[11px]">
|
||||||
|
建议 16;实测 16 线程约 4.1MB/s,32 线程约 6.8MB/s。过高可能触发 CDN 限流。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['preferAvc', '优先 H.264(AVC)编码', '兼容性最好;关掉则可能选到更高码率的 HEVC/AV1'],
|
||||||
|
['keepParts', '保留中间文件', '保留下载的 .video.m4s / .audio.m4s,便于排查问题'],
|
||||||
|
] as const
|
||||||
|
).map(([key, label, hint]) => (
|
||||||
|
<label key={key} className="flex cursor-pointer items-start gap-2.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(draft[key])}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, [key]: e.target.checked }))}
|
||||||
|
className="accent-primary mt-0.5 size-4"
|
||||||
|
/>
|
||||||
|
<span className="space-y-0.5">
|
||||||
|
<span className="block text-sm">{label}</span>
|
||||||
|
<span className="text-muted-foreground block text-[11px]">{hint}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground font-mono text-[11px]">
|
||||||
|
ffmpeg:{config?.ffmpeg || '未找到(合并会失败,请把 ffmpeg 加入 PATH)'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="items-center">
|
||||||
|
{note && <span className="text-muted-foreground mr-auto text-xs">{note}</span>}
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
<Button onClick={save} disabled={saving}>
|
||||||
|
{saving && <Loader2 className="size-3.5 animate-spin" />}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
className={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image data-slot="avatar-image" className={cn('aspect-square size-full', className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn('bg-muted flex size-full items-center justify-center rounded-full text-xs', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback };
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none transition-[color,box-shadow] overflow-hidden',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-transparent bg-primary text-primary-foreground',
|
||||||
|
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||||
|
destructive: 'border-transparent bg-destructive text-white',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'default' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : 'span';
|
||||||
|
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||||
|
outline:
|
||||||
|
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||||
|
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||||
|
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||||
|
icon: 'size-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'default', size: 'default' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'button'> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn('bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="card-header" className={cn('flex flex-col gap-1.5 px-6', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="card-title" className={cn('leading-none font-semibold', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="card-description" className={cn('text-muted-foreground text-sm', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="card-footer" className={cn('flex items-center px-6', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { XIcon } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Dialog(props: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
|
}
|
||||||
|
function DialogTrigger(props: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
function DialogPortal(props: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
|
}
|
||||||
|
function DialogClose(props: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/60 backdrop-blur-[1px]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & { showCloseButton?: boolean }) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">关闭</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="dialog-header" className={cn('flex flex-col gap-2 text-center sm:text-left', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return <DialogPrimitive.Title data-slot="dialog-title" className={cn('text-lg leading-none font-semibold', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn('text-muted-foreground text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
};
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function DropdownMenu(props: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||||
|
}
|
||||||
|
function DropdownMenuTrigger(props: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = 'default',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { inset?: boolean; variant?: 'default' | 'destructive' }) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function DropdownMenuRadioGroup(props: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||||
|
}
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function DropdownMenuSub(props: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||||
|
}
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||||
|
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||||
|
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 text-sm leading-none font-medium select-none',
|
||||||
|
'group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50',
|
||||||
|
'peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label };
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
indicatorClassName,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ProgressPrimitive.Root> & { indicatorClassName?: string }) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn('bg-primary/20 relative h-2 w-full overflow-hidden rounded-full', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className={cn('bg-primary h-full w-full flex-1 transition-transform duration-300 ease-out', indicatorClassName)}
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress };
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function ScrollArea({ className, children, ...props }: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root data-slot="scroll-area" className={cn('relative', className)} {...props}>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px]"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = 'vertical',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'flex touch-none p-px transition-colors select-none',
|
||||||
|
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
|
||||||
|
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="bg-border relative flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar };
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Select(props: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue(props: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = 'default',
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { size?: 'sm' | 'default' }) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||||
|
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
|
||||||
|
'dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent',
|
||||||
|
'px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none',
|
||||||
|
'focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
"data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = 'popper',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
'data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
'relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin)',
|
||||||
|
'overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||||
|
position === 'popper' &&
|
||||||
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
'p-1',
|
||||||
|
position === 'popper' &&
|
||||||
|
'h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||||
|
'relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none',
|
||||||
|
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||||
|
"[&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue };
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = 'horizontal',
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="skeleton" className={cn('bg-accent animate-pulse rounded-md', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton };
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function TooltipProvider({ delayDuration = 200, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip(props: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger(props: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/** 与本地 Python 服务通信的类型化客户端。 */
|
||||||
|
|
||||||
|
export type SearchItem = {
|
||||||
|
bvid: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
duration: string;
|
||||||
|
play: number;
|
||||||
|
danmaku: number;
|
||||||
|
pic: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchResult = {
|
||||||
|
items: SearchItem[];
|
||||||
|
page: number;
|
||||||
|
numResults: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VideoPage = { cid: number; page: number; part: string; duration: number };
|
||||||
|
|
||||||
|
export type VideoDetail = {
|
||||||
|
bvid: string;
|
||||||
|
title: string;
|
||||||
|
pic: string;
|
||||||
|
duration: number;
|
||||||
|
desc: string;
|
||||||
|
owner: string;
|
||||||
|
stat: { view?: number; danmaku?: number; like?: number; favorite?: number };
|
||||||
|
pages: VideoPage[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Quality = {
|
||||||
|
quality: number;
|
||||||
|
label: string;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
codecs: string | null;
|
||||||
|
bandwidth: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlayUrl = {
|
||||||
|
qualities: Quality[];
|
||||||
|
maxQuality: number;
|
||||||
|
advertisedQuality: number;
|
||||||
|
duration: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StreamProgress = {
|
||||||
|
label: string;
|
||||||
|
downloaded: number;
|
||||||
|
total: number;
|
||||||
|
percent: number;
|
||||||
|
speed: number;
|
||||||
|
threads: number;
|
||||||
|
resumed: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Job = {
|
||||||
|
state?: 'running' | 'done' | 'cancelled' | 'error';
|
||||||
|
stage?: string;
|
||||||
|
title?: string;
|
||||||
|
quality?: number;
|
||||||
|
outputPath?: string;
|
||||||
|
size?: number;
|
||||||
|
elapsed?: number;
|
||||||
|
threads?: number;
|
||||||
|
resumed?: number;
|
||||||
|
error?: string | null;
|
||||||
|
progress?: { video: StreamProgress | null; audio: StreamProgress | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppConfig = {
|
||||||
|
sessdata: string;
|
||||||
|
outputDir: string;
|
||||||
|
threads: number;
|
||||||
|
preferAvc: boolean;
|
||||||
|
keepParts: boolean;
|
||||||
|
configPath?: string;
|
||||||
|
ffmpeg?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginStatus = { isLogin: boolean; uname: string; code?: number };
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
throw new Error(`服务返回了非 JSON 内容(HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
const obj = payload as { ok?: boolean; error?: string };
|
||||||
|
if (!res.ok || obj?.error) throw new Error(obj?.error || `HTTP ${res.status}`);
|
||||||
|
return payload as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
config: () => request<AppConfig>('/api/config'),
|
||||||
|
saveConfig: (cfg: Partial<AppConfig>) =>
|
||||||
|
request<AppConfig>('/api/config', { method: 'POST', body: JSON.stringify(cfg) }),
|
||||||
|
login: () => request<LoginStatus>('/api/login'),
|
||||||
|
search: (q: string, page = 1) =>
|
||||||
|
request<SearchResult>(`/api/search?q=${encodeURIComponent(q)}&page=${page}`),
|
||||||
|
video: (bvid: string) => request<VideoDetail>(`/api/video?bvid=${encodeURIComponent(bvid)}`),
|
||||||
|
playUrl: (bvid: string, cid: number) =>
|
||||||
|
request<PlayUrl>(`/api/playurl?bvid=${encodeURIComponent(bvid)}&cid=${cid}`),
|
||||||
|
job: () => request<Job>('/api/job'),
|
||||||
|
download: (payload: { bvid: string; cid: number; quality: number; title: string }) =>
|
||||||
|
request<Job>('/api/download', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
cancel: () => request<{ ok: boolean }>('/api/cancel', { method: 'POST', body: '{}' }),
|
||||||
|
openFolder: (path?: string) =>
|
||||||
|
request<{ ok: boolean }>('/api/open-folder', { method: 'POST', body: JSON.stringify({ path }) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 订阅服务端事件。返回取消订阅函数。 */
|
||||||
|
export function subscribe(handlers: {
|
||||||
|
onJob?: (job: Job) => void;
|
||||||
|
onProgress?: (p: StreamProgress & { which: 'video' | 'audio'; stage: string }) => void;
|
||||||
|
onStage?: (s: { stage: string }) => void;
|
||||||
|
}): () => void {
|
||||||
|
const es = new EventSource('/api/events');
|
||||||
|
es.addEventListener('job', (e) => handlers.onJob?.(JSON.parse((e as MessageEvent).data)));
|
||||||
|
es.addEventListener('progress', (e) => handlers.onProgress?.(JSON.parse((e as MessageEvent).data)));
|
||||||
|
es.addEventListener('stage', (e) => handlers.onStage?.(JSON.parse((e as MessageEvent).data)));
|
||||||
|
es.onerror = () => {
|
||||||
|
/* EventSource 会自动重连 */
|
||||||
|
};
|
||||||
|
return () => es.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从任意输入里抠出 BV 号(粘贴链接或直接粘 BV 号都行)。 */
|
||||||
|
export function extractBvid(input: string): string | null {
|
||||||
|
const m = input.match(/BV[0-9A-Za-z]{10}/);
|
||||||
|
return m ? m[0] : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/** 统一格式化。数字一律用等宽 + tabular-nums 呈现,保证读数对齐。 */
|
||||||
|
|
||||||
|
export function formatBytes(n?: number | null): string {
|
||||||
|
if (!n || n <= 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let v = n;
|
||||||
|
let i = 0;
|
||||||
|
while (v >= 1024 && i < units.length - 1) {
|
||||||
|
v /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSpeed(bytesPerSec?: number | null): string {
|
||||||
|
if (!bytesPerSec || bytesPerSec <= 0) return '—';
|
||||||
|
return `${formatBytes(bytesPerSec)}/s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 秒 -> 1:23:45 / 12:34 */
|
||||||
|
export function formatDuration(seconds?: number | null): string {
|
||||||
|
if (!seconds || seconds <= 0) return '—';
|
||||||
|
const s = Math.floor(seconds % 60);
|
||||||
|
const m = Math.floor((seconds / 60) % 60);
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 播放量:1.2万 / 3.4亿 */
|
||||||
|
export function formatCount(n?: number | null): string {
|
||||||
|
if (!n || n <= 0) return '0';
|
||||||
|
if (n >= 100000000) return `${(n / 100000000).toFixed(1)}亿`;
|
||||||
|
if (n >= 10000) return `${(n / 10000).toFixed(1)}万`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatEta(info?: { downloaded: number; total: number; speed: number } | null): string {
|
||||||
|
if (!info || !info.speed || info.speed <= 0) return '—';
|
||||||
|
const remain = Math.max(0, info.total - info.downloaded) / info.speed;
|
||||||
|
if (!Number.isFinite(remain) || remain < 0) return '—';
|
||||||
|
return formatDuration(remain);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shortPath(p?: string, max = 46): string {
|
||||||
|
if (!p) return '';
|
||||||
|
const normalized = p.replace(/\\/g, '/');
|
||||||
|
if (normalized.length <= max) return p;
|
||||||
|
const parts = normalized.split('/');
|
||||||
|
const tail = parts.slice(-2).join('/');
|
||||||
|
return `…/${tail.length > max ? tail.slice(-max) : tail}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 相对时间:今天显示时分,今年显示月日,更早显示年月日 */
|
||||||
|
export function formatDate(raw?: string | null): string {
|
||||||
|
if (!raw) return '';
|
||||||
|
const d = new Date(raw);
|
||||||
|
if (Number.isNaN(d.getTime())) return String(raw).slice(0, 16);
|
||||||
|
const now = new Date();
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
if (d.toDateString() === now.toDateString()) return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
if (d.getFullYear() === now.getFullYear()) return `${d.getMonth() + 1}月${d.getDate()}日`;
|
||||||
|
return `${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFullDate(raw?: string | null): string {
|
||||||
|
if (!raw) return '';
|
||||||
|
const d = new Date(raw);
|
||||||
|
if (Number.isNaN(d.getTime())) return String(raw);
|
||||||
|
return d.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSize(bytes?: number): string {
|
||||||
|
if (!bytes) return '';
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取显示名首字,用于头像占位 */
|
||||||
|
export function initials(name: string, address: string): string {
|
||||||
|
const source = (name || address || '?').trim();
|
||||||
|
const first = source.replace(/["'<>]/g, '').trim()[0];
|
||||||
|
return (first || '?').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由地址生成稳定的头像底色(色相散开,饱和度/亮度固定,避免花哨) */
|
||||||
|
export function avatarHue(seed: string): string {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) % 360;
|
||||||
|
return `hsl(${h} 45% 42%)`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
|
import App from './App';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<TooltipProvider>
|
||||||
|
<App />
|
||||||
|
</TooltipProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
/* shadcn/ui 默认(new-york / neutral)主题 token,原样实例化。
|
||||||
|
light 与 dark 两套都保留;默认深色(这个项目的主人偏好深色),可切换并记忆。 */
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
|
--border: oklch(1 0 0 / 12%);
|
||||||
|
--input: oklch(1 0 0 / 16%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 12%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
|
||||||
|
/* 中文界面用系统字体栈(中文字库太大,不引入 webfont);
|
||||||
|
数字/速度/日志一律走等宽,保证读数纵向对齐。 */
|
||||||
|
--font-sans: "Segoe UI", "Microsoft YaHei UI", "PingFang SC", "Noto Sans SC", system-ui, sans-serif;
|
||||||
|
--font-mono: ui-monospace, "Cascadia Mono", "Consolas", "Microsoft YaHei Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
/* 桌面客户端不该出现页面级滚动条:滚动只发生在三栏内部 */
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
/* 界面全是读数:默认开启等宽数字,避免进度跳动时宽度抖动 */
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
/* 日志与所有读数 */
|
||||||
|
.log-pane {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条(桌面客户端观感)+ 三栏内部的滚动容器 */
|
||||||
|
@layer utilities {
|
||||||
|
.scroll-pane {
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
/* 关键:flex 子项默认 min-height:auto 会按内容撑高,导致「列表没滚、整页滚」 */
|
||||||
|
min-height: 0;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
.scrollbar-thin::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
|
background-color: color-mix(in oklab, var(--foreground) 18%, transparent);
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: color-mix(in oklab, var(--foreground) 30%, transparent);
|
||||||
|
}
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": false,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["./src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// 构建产物交给本地 Node 服务托管(server/index.js 会优先发 ui/dist),
|
||||||
|
// 所以 base 用相对路径,避免路径耦合。
|
||||||
|
export default defineConfig({
|
||||||
|
base: './',
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': path.resolve(__dirname, './src') },
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
sourcemap: false,
|
||||||
|
chunkSizeWarningLimit: 1200,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
// 开发时把 API 代理到本地邮件服务,便于热更新调试
|
||||||
|
proxy: { '/api': 'http://127.0.0.1:8788' },
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user