resdownloader开发日志-1

网络上有一个非常强大的工具叫做 res-downloader,它能够把很多现存网站上的视频下载下来。但是,我在下载某破站的视频时,发现出现了一个小错误:一个完整的视频被拆分成了好几个 m4s 的包。于是,我就让我的 Agent 帮我处理这个问题。以下是我跟 Agent 对话的节选,我们可以从中看到是如何解决这个问题的,以及我们在这个过程中学到了什么。

1. 先理解你当时看到的"37KB"到底是什么

最开始我们看到类似:

text 复制代码
bilivideo.com
37.27 KB

mountaintoys.cn
1.15 KB
1.30 KB
1.41 KB

直觉很容易认为:

"软件只抓到了几个垃圾文件,没有抓到真正的视频。"

实际上不是。

浏览器确实已经在下载真正的视频,只不过不是一次把完整视频下载下来


2. 破站现在播放视频,大量使用 DASH

传统的视频可能是:

text 复制代码
video.mp4

里面同时包含:

text 复制代码
视频轨
+
音频轨

浏览器请求一次:

text 复制代码
GET video.mp4

服务器返回:

text 复制代码
200 OK
Content-Length: 50000000

这种情况非常容易理解。


但 破站常见的是 DASH:

text 复制代码
破站一个视频
│
├── video.m4s     ← 只有画面
│
└── audio.m4s     ← 只有声音

而且还不只是两条。

可能同时有:

text 复制代码
video 360P
video 480P
video 720P
video 1080P
video HEVC
video AV1

audio 64K
audio 128K
audio 高码率

所以你打开一个 破站页面之后,抓包程序可能一下看到很多:

text 复制代码
xxx-30032.m4s
xxx-30033.m4s
xxx-30280.m4s
...

这不是重复垃圾。

它们可能是:

不同清晰度、不同编码、音频和视频轨。


3. 更麻烦的是:浏览器连一个 .m4s 都不会一次下载完

假设完整视频轨大小:

text 复制代码
14,383,614 bytes

浏览器不一定发送:

http 复制代码
GET /video.m4s

要求全部文件。

它可能发送:

http 复制代码
GET /video.m4s
Range: bytes=2794479-3535414

意思是:

"我现在只要这个文件从第 2,794,479 个字节到第 3,535,414 个字节。"

服务器于是返回:

http 复制代码
HTTP/1.1 206 Partial Content

Content-Range:
bytes 2794479-3535414/14383614

这一行是整个 Bug 的关键。


4. Content-Range 到底是什么意思

把它拆开:

text 复制代码
bytes 2794479-3535414/14383614
      └──────┬──────┘ └───┬───┘
          本次返回范围       完整文件总大小

所以:

text 复制代码
2794479

是本次起始字节。

text 复制代码
3535414

是本次结束字节。

而:

text 复制代码
14383614

才是:

这个 .m4s 完整媒体文件的逻辑总大小。


5. 原程序错在哪里

原来的思路大致相当于:

go 复制代码
size := response.ContentLength

对于普通:

http 复制代码
200 OK

这通常没什么问题。

比如:

http 复制代码
Content-Length: 14383614

那就说明:

text 复制代码
资源大小 = 14383614

但到了:

http 复制代码
206 Partial Content

Content-Length 表示的是:

本次返回的这一块有多大。

不是:

完整 .m4s 有多大。

于是原来的程序逻辑变成:

text 复制代码
浏览器请求了一小块

服务器:
这次我给你几十 KB

res-downloader:
好的,这个视频只有几十 KB

所以你才看到了:

text 复制代码
1.15 KB
37.27 KB

6. 我们怎么修

新的逻辑变成:

go 复制代码
if statusCode == 206 {
    parse Content-Range
}

类似:

go 复制代码
Content-Range:
bytes 2794479-3535414/14383614

解析:

go 复制代码
start = 2794479
end   = 3535414
total = 14383614

然后:

go 复制代码
MediaInfo.Size = total

也就是:

text 复制代码
MediaInfo.Size = 14383614

而不是:

text 复制代码
MediaInfo.Size = 本次几十 KB

所以 Hotfix 后你重新真实测试时,列表就变成了:

text 复制代码
39.84 MB
157.53 MB
12.99 MB
...

这说明现在显示的是:

完整媒体轨的逻辑大小。

而不再是:

浏览器这一次碰巧请求了多少字节。


7. 这里特别重要:我们没有假装 206 是 200

代码不是简单:

go 复制代码
if 206 {
    Size = 随便一个大值
}

而是明确区分两个概念:

text 复制代码
Response Size

和:

text 复制代码
Logical Resource Size

可以把它理解成操作系统里的:

text 复制代码
一页内存大小

和:

text 复制代码
整个虚拟地址空间大小

不是同一个层级。


8. 为什么同一个视频会出现很多 Range 请求

比如浏览器播放:

text 复制代码
00:00 → 00:10

可能请求:

text 复制代码
Range A

然后你拖到:

text 复制代码
05:30

浏览器可能马上请求:

text 复制代码
Range B

再继续播放:

text 复制代码
Range C
Range D
...

所以网络层可能看到:

text 复制代码
同一个 URL
+
Range 0-xxxxx

同一个 URL
+
Range yyyyy-zzzzz

同一个 URL
+
Range aaaaa-bbbbb

这些实际上都是:

text 复制代码
同一个媒体对象

9. 所以第二处修复是"资源合并"

不能:

text 复制代码
Range A → Resource 1
Range B → Resource 2
Range C → Resource 3

否则列表会越来越长。

我们之前已经有:

text 复制代码
UrlSign

和中央:

text 复制代码
Resource Registry

于是新的处理逻辑更像:

text 复制代码
收到网络响应
      ↓
计算稳定 Asset identity
      ↓
有没有这个媒体?
      │
   ┌──┴──┐
   │     │
  没有   已有
   │     │
新建    更新 metadata
         │
         └── Size 取可靠 total

于是:

text 复制代码
第一次:

Content-Range:
bytes .../.../14383614

Size = 14383614

第二次可能只有一小块:

text 复制代码
Content-Length = 2048

也不能再把:

text 复制代码
14383614

覆盖成:

text 复制代码
2048

10. 为什么我们还修了下载器的 Range

这也是一个比较典型的网络编程坑。

抓包时,我们保存了一些原始 Request Headers。

里面可能包括:

http 复制代码
Range: bytes=2794479-3535414
If-Range: ...
If-Modified-Since: ...
If-None-Match: ...

这些 Header 是:

浏览器当时播放这个视频时使用的条件。

但是之后用户点:

text 复制代码
下载

res-downloader 本身又有自己的下载器。

如果我们把浏览器的:

http 复制代码
Range: bytes=2794479-3535414

原封不动带过去,就会发生:

text 复制代码
用户:下载整个视频

Downloader:
好的。

但是 HTTP Header:
我只要 2794479-3535414

服务器:
206,给你这一段。

Downloader:
下载完成!

于是最后真的只保存了:

text 复制代码
一个残缺 m4s

11. 所以下载时现在会过滤这些 Header

类似:

text 复制代码
Range
If-Range
If-Match
If-None-Match
If-Modified-Since
If-Unmodified-Since

不会把浏览器当时的 Range 请求条件拿来当作完整下载请求。

下载器需要分片时:

由下载器自己产生 Range。

例如:

text 复制代码
Downloader
├─ bytes 0-4MB
├─ bytes 4MB-8MB
├─ bytes 8MB-12MB
└─ bytes 12MB-end

这才是受控的并发分段下载。


12. 但为什么我们后来还是没有让 res-downloader 自己拼 破站视频?

因为即使解决 Range,还有另一个问题:

text 复制代码
一个 m4s ≠ 一个最终视频

例如:

text 复制代码
video.m4s

可能:

text 复制代码
有画面
没声音

而:

text 复制代码
audio.m4s

可能:

text 复制代码
有声音
没画面

你最终需要的是:

text 复制代码
video.m4s
+
audio.m4s
↓
FFmpeg
↓
video.mp4

13. 所以架构最后变成了两层

res-downloader

负责:

text 复制代码
网络观察层

即:

text 复制代码
这个页面出现了什么资源?
这个资源有多大?
它来自什么页面?
这个页面是哪一个 破站 BV/P?

BiliMind

负责:

text 复制代码
平台理解层

即:

text 复制代码
BV 是什么?
P 是多少?
CID 是多少?
有哪些画质?
哪个是 video-only?
哪个是 audio-only?
当前账号允许下载什么?

然后:

text 复制代码
yt-dlp
↓
选择 video
+
选择 audio
↓
完整下载
↓
FFmpeg -c copy
↓
video.mp4

14. 这就是为什么我们后来增加了"破站完整下载"

以前:

text 复制代码
res-downloader

抓到 m4s
↓
点下载

现在:

text 复制代码
res-downloader

发现:
BV1xxxx P2
↓
生成 ContentRef:

bilibili:BV1xxxx:p2
↓
点击:
破站完整下载
↓
HTTP API
↓
BiliMind
↓
yt-dlp
↓
DASH视频轨
+
DASH音频轨
↓
FFmpeg mux
↓
video.mp4

这里网络抓包只负责:

发现。

平台 Downloader 负责:

正确下载。

这是软件架构上很关键的一次拆分。


15. 还有一个你当时同时遇到的问题:为什么 BiliMind 一直是 -

这个和 37KB 是两个不同 Bug

当时我们的 Source 是:

text 复制代码
https://www.bilibili.com/

而不是:

text 复制代码
https://www.bilibili.com/video/BVxxxx

所以 Resolver 看见:

text 复制代码
platform=bilibili

但 URL:

text 复制代码
https://www.bilibili.com/

于是:

text 复制代码
Reject

这是正确的。

因为否则首页同时推荐:

text 复制代码
视频 A
视频 B
视频 C
视频 D

我们根本不知道你抓到的 m4s 属于哪个视频。


16. 为什么 破站媒体请求的 Referer 只有首页

我们真实 Chrome DevTools 已经验证了:

text 复制代码
Referer:
https://www.bilibili.com/

而不是具体 BV。

所以不能:

go 复制代码
source := request.Header.Get("Referer")

然后期待得到:

text 复制代码
/video/BVxxx

浏览器/播放器不会总给你这个信息。


17. 所以后来又加了 Bilibili Page Context Capture

我们在真正打开:

text 复制代码
www.bilibili.com/video/BVxxxx

这个 HTML 页面时,记录:

text 复制代码
ContentKey
CanonicalURL
BVID
P

例如:

text 复制代码
ContentKey:
bilibili:BV123:p2

同时页面内通过:

text 复制代码
PerformanceObserver

观察它实际加载了哪些:

text 复制代码
bilivideo.com
mountaintoys.cn

资源 URL。

于是建立:

text 复制代码
当前页面 BV123 P2
        │
        ├── 实际观察到 m4s A
        ├── 实际观察到 m4s B
        └── 实际观察到 m4s C

比:

text 复制代码
Referer == 破站首页

可靠得多。


18. 为什么不能简单使用"最近打开的 BV"

假设:

text 复制代码
Tab A
BVAAA

Tab B
BVBBB

如果代码只有:

go 复制代码
lastBilibiliVideo = BVBBB

那么 Tab A 后续加载一个 m4s:

text 复制代码
A 的视频

可能被错误绑定:

text 复制代码
BVBBB

这种 Bug 比"不识别"危险得多。

所以我们采用原则:

无法证明的时候宁可不绑定。

这叫:

text 复制代码
fail-closed

而不是:

text 复制代码
尽量猜一个

19. 把整个 Bug 修复画成一张图

原来:

text 复制代码
Chrome
  │
  │ Range request
  ▼
m4s 局部分片
  │
  │ Content-Length = 几 KB
  ▼
res-downloader
  │
  ├── Size = 几 KB        ❌
  └── Referer = 首页      ❌
          │
          ▼
      无 ContentRef
          │
          ▼
      BiliMind = -

现在:

text 复制代码
Chrome
  │
  ├──── /video/BVxxx 页面
  │            │
  │            ▼
  │      Page Context Capture
  │            │
  │            ▼
  │      ContentRef
  │      BVxxx / P
  │
  └──── m4s Range Request
               │
               ▼
        206 Partial Content
               │
               ▼
         Content-Range
               │
               ▼
      total = 完整媒体大小
               │
               ▼
        MediaInfo.Size
               │
               ▼
           正常显示 MB

完整下载则另走:

text 复制代码
ContentRef
    │
    ▼
BiliMind API
    │
    ▼
yt-dlp
    │
 ┌──┴──┐
 ▼     ▼
video  audio
m4s    m4s
 └──┬──┘
    ▼
 FFmpeg
 -c copy
    ▼
 video.mp4

20. 从计算机专业角度,这次你可以记住 5 个知识点

第一,HTTP Response 大小不一定等于资源大小

尤其:

text 复制代码
206 Partial Content

必须看:

text 复制代码
Content-Range

第二,网络请求和逻辑资源是两个层级

text 复制代码
HTTP Request

是网络事件。

text 复制代码
Media Asset

是逻辑对象。

它们可以是:

text 复制代码
N : 1

也就是:

多个 HTTP Range Request 对应一个媒体文件。


第三,资源文件和内容作品又是两个层级

text 复制代码
video.m4s
audio.m4s
cover.jpg

都是 Asset。

但它们可能属于:

text 复制代码
BV123 P2

这个 Content。

所以:

text 复制代码
Asset ≠ Content

第四,不要把浏览器行为直接重放成下载行为

浏览器 Playback Request:

text 复制代码
Range
If-Range
...

是为了:

流媒体播放。

Downloader Request:

是为了:

获取完整文件。

二者不能直接复用全部 Header。


第五,成熟平台最好用平台解析器,不要单靠抓包

普通网页:

text 复制代码
抓 URL
→ 下载

可能够用。

但 破站:

text 复制代码
多P
CID
DASH
video-only
audio-only
会员权限
Cookie
多编码

已经属于:

平台协议。

这时更合理的架构就是:

text 复制代码
res-downloader
负责发现

BiliMind / yt-dlp
负责理解和下载

这也是为什么我们最终没有继续把 res-downloader 做成一个巨大的"万能 破站解析器"。

相关推荐
梁辰兴3 小时前
软件工程:结构化程序设计
软件工程·设计原则·设计工具·设计方法·梁辰兴·结构化程序设计·三种基本结构
「、皓子~4 小时前
海狸IM 2.1 正式发布
flutter·微服务·golang·electron·开源软件·im·海狸im
梁辰兴6 小时前
软件工程:程序设计风格
软件工程·代码重构·命名规范·程序设计风格·代码格式·注释规范·良好习惯
xierui12312319 小时前
OpenAI 拟停止向 Cursor供模:如何设计不绑供应商的AIAgent架构
人工智能·系统架构·软件工程
智造ERP规划1 天前
装备制造 ERP↔WMS 集成深度拆解:大型物料、项目专属库与齐套管理的 6 个核心场景
系统架构·软件工程·制造
郝学胜-神的一滴2 天前
Effective Python 条款4:字符串格式化大乱斗
开发语言·网络·python·程序人生·软件工程
梦梦代码精2 天前
《LikeShop全产品技术硬核拆解:ThinkPHP8+Vue3+UniApp架构,私有化部署与二次开发实战指南》
java·低代码·系统架构·php·开源软件
zhonyu鱼2 天前
Shotcut:免费开源的专业级视频剪辑软件
音视频·开源软件
zhonyu鱼2 天前
Drawpile:多人实时协作绘画,一起在同一块画布上画画
笔记·pdf·开源·开源软件