# -*- 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"]*>|", "", 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