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