Files
bili-downloader-web/engine.py
T

514 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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