m3u8-cli 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/PKG-INFO +52 -15
  2. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/README.md +51 -14
  3. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/chrome-extension/README.md +25 -9
  4. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/chrome-extension/background.js +109 -28
  5. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/chrome-extension/content.js +109 -1
  6. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/chrome-extension/lib.js +72 -1
  7. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/chrome-extension/lib.test.js +74 -0
  8. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/chrome-extension/manifest.json +1 -1
  9. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/chrome-extension/popup.css +12 -0
  10. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/chrome-extension/popup.html +11 -0
  11. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/chrome-extension/popup.js +79 -7
  12. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/pyproject.toml +1 -1
  13. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/pyproject.toml.orig +1 -1
  14. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/src/m3u8_cli/__init__.py +1 -1
  15. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/src/m3u8_cli/cli.py +13 -0
  16. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/src/m3u8_cli/download.py +114 -9
  17. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/src/m3u8_cli/downloader.py +3 -1
  18. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/src/m3u8_cli/hls_export.py +2 -2
  19. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/src/m3u8_cli/merge.py +6 -3
  20. m3u8_cli-0.2.0/src/m3u8_cli/paths.py +98 -0
  21. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/src/m3u8_cli/playlist.py +6 -5
  22. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/src/m3u8_cli/serve.py +22 -7
  23. m3u8_cli-0.1.0/src/m3u8_cli/paths.py +0 -30
  24. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/LICENSE +0 -0
  25. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/scripts/build_extension.py +0 -0
  26. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/src/m3u8_cli/__main__.py +0 -0
  27. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/src/m3u8_cli/convert.py +0 -0
  28. {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/src/m3u8_cli/errors.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: m3u8-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: HLS 视频下载与格式转换工具
5
5
  Author: r1a
6
6
  Author-email: r1a <m@zcq100.com>
@@ -101,10 +101,16 @@ python -m m3u8_cli download "<m3u8_url>"
101
101
  python -m m3u8_cli convert movie.mp4 -o out
102
102
  ```
103
103
 
104
+ 查看版本:
105
+
106
+ ```bash
107
+ m3u8-cli -v # 或 --version
108
+ ```
109
+
104
110
  ### 常用示例
105
111
 
106
112
  ```bash
107
- # 默认输出到 output/output.mp4
113
+ # 默认输出到当前目录的 output.mp4
108
114
  m3u8-cli download "https://example.com/playlist.m3u8"
109
115
 
110
116
  # 指定输出文件名与并发数
@@ -170,6 +176,7 @@ HLS 输出固定为 `playlist.m3u8` 加 `ts/000000.ts` 起的分片;WebM 重
170
176
  | `--cookie` | 无 | 自定义 `Cookie` 请求头 |
171
177
  | `--user-agent` | `Mozilla/5.0` | 自定义 `User-Agent` 请求头 |
172
178
  | `--overwrite` | 关闭 | 覆盖已存在的最终文件 |
179
+ | `-k, --insecure` | 关闭 | 跳过 TLS 证书校验,仅用于自签名或内网服务 |
173
180
 
174
181
  ### 保留 HLS(不合并)
175
182
 
@@ -183,6 +190,8 @@ m3u8-cli download "<m3u8_url>" -f hls -o movie
183
190
  - 源流为 AES-128 加密时保持密文,同时把密钥下载为 `key.bin`(多个不同密钥时为 `key_000.bin`、`key_001.bin`),播放列表的 `EXT-X-KEY` URI 重写为本地文件。
184
191
  - 分片、`playlist.m3u8` 与密钥文件位于同一目录;`--overwrite` 只清理本工具生成的条目。
185
192
 
193
+ `--insecure` 会关闭播放列表、分片与密钥请求的证书和主机名校验,只应在信任的网络上对自签名服务使用;它不会影响元数据中记录的其他信息。
194
+
186
195
  ### 启动下载服务
187
196
 
188
197
  ```bash
@@ -200,6 +209,7 @@ curl -X POST http://127.0.0.1:8787/api/tasks \
200
209
  "url": "https://example.com/playlist.m3u8",
201
210
  "format": "hls",
202
211
  "output": "movie",
212
+ "insecure": false,
203
213
  "headers": {"Referer": "https://example.com/"}
204
214
  }'
205
215
 
@@ -223,6 +233,7 @@ curl -X DELETE http://127.0.0.1:8787/api/tasks/<id> \
223
233
  `chrome-extension/` 提供一个 Manifest V3 扩展,用于识别页面中的 m3u8 链接:
224
234
 
225
235
  - 监听页面网络请求并结合 DOM 扫描,列出当前标签页的 m3u8 地址;
236
+ - 采集页面标题(`og:title`、Twitter Card、JSON-LD、`document.title`)作为默认下载文件名,也可在设置中改用 URL 短 hash;
226
237
  - 复制链接,或生成带 `Referer`/`Cookie`/`User-Agent` 的 `m3u8-cli download` 命令;
227
238
  - 一键提交到本地 `m3u8-cli serve` 下载,并在弹窗中查看进度、取消任务。
228
239
 
@@ -245,28 +256,53 @@ uv run python scripts/build_extension.py
245
256
 
246
257
  ## 下载输出结构
247
258
 
248
- 以 `-o movie.mp4` 为例:
259
+ ### MP4(默认,合并封装)
260
+
261
+ 以 `-o movie.mp4` 为例,输出目录下只有最终产物和元数据:
262
+
263
+ ```
264
+ movie.mp4
265
+ movie.mp4.meta.json # ffprobe 的 format/streams + source 来源信息
266
+ movie.partial.mp4 # FFmpeg 临时输出(仅在合并过程中存在)
267
+ ```
268
+
269
+ 分片缓存不落在输出目录,而是位于系统临时目录:
270
+
271
+ ```
272
+ <TMP>/m3u8-cli-<uid>/<最终路径哈希>/
273
+ ├── state.json # 播放列表指纹,用于缓存失效判断
274
+ ├── segment_000000.bin # 已解密的媒体分片
275
+ ├── map_0000.bin # fMP4 初始化片段(仅 fMP4 流)
276
+ └── combined.ts|mp4 # 本地合并的中间文件
277
+ ```
278
+
279
+ - 合并成功后整个缓存目录会被删除;失败或取消时保留,便于续传。
280
+ - 同一 `-o` 对应同一个缓存目录;播放列表变化时 `state.json` 指纹会触发旧分片清理。
281
+ - 元数据文件由 ffprobe 读取产物生成:顶层 `format`/`streams` 为 `ffprobe -show_format -show_streams -of json` 的输出(已去掉 `filename` 路径),`source` 记录 URL、格式、分片数、指纹、时间与是否校验 TLS;不包含 `output` 字段,也不包含 `Referer`/`Cookie`/`User-Agent`。
282
+ - 未安装 ffprobe 时会在 stderr 打印警告,元数据仅保留 `source` 部分,下载仍继续进行。
283
+
284
+ ### HLS(保留分片)
285
+
286
+ 以 `-o movie` 为例:
249
287
 
250
288
  ```
251
289
  movie/
252
- ├── movie.mp4 # 最终产物
253
- └── cache/
254
- ├── state.json # 播放列表指纹,用于缓存失效判断
255
- ├── segment_000000.bin # 已解密的媒体分片
256
- ├── map_0000.bin # fMP4 初始化片段(仅 fMP4 流)
257
- ├── combined.ts|mp4 # 本地合并的中间文件
258
- └── movie.partial.mp4 # FFmpeg 临时输出
290
+ ├── playlist.m3u8 # 本地播放列表
291
+ ├── 000000.ts # 分片(fMP4 为 .m4s,另有 init.mp4)
292
+ ├── key.bin # 存在 AES-128 密钥时
293
+ └── meta.json # ffprobe 媒体信息 + source 来源信息
259
294
  ```
260
295
 
261
- - 任务目录由输出文件名自动派生;若输出路径已经是 `movie/movie.mp4` 形式,会复用该目录,不会重复嵌套。
262
- - 最终文件生成成功后,缓存仍会保留在任务目录中。
296
+ - 分片先下载到系统临时目录,导出成功后清理;输出目录只保留最终产物和元数据。
297
+ - 导出失败或取消时缓存保留,便于用相同 `-o` 续传。
263
298
 
264
299
  ## 缓存与续传
265
300
 
266
- - 使用相同 `-o` 且任务目录仍在时,已存在且非空的分片会被直接复用。
267
- - `cache/state.json` 记录播放列表原文的 SHA-256 指纹;播放列表变化时会清除旧分片。
301
+ - 两种格式的分片缓存都位于 `<系统临时目录>/m3u8-cli-<uid>/<最终路径 SHA-256 前 16 位>/`;成功下载后删除,失败/取消时保留。
302
+ - 使用相同 `-o` 且缓存仍在时,已存在且非空的分片会被直接复用。
303
+ - 缓存中的 `state.json` 记录播放列表原文的 SHA-256 指纹;播放列表变化时会清除旧分片。
268
304
  - 下载时最终文件已存在且未指定 `--overwrite`,程序会直接退出。
269
- - 需要彻底清理时,删除整个任务目录即可。
305
+ - 操作系统清理临时目录后,失败任务需要重新下载;需要彻底清理时删除对应的临时缓存目录即可。
270
306
 
271
307
  ## 已知限制
272
308
 
@@ -281,6 +317,7 @@ movie/
281
317
  - FFmpeg 的 HLS muxer 会写入固定 `IV=0`,所有分片共用同一密钥与 IV。
282
318
  - 不支持自定义密钥、密钥 URI 或 IV。
283
319
  - serve 不做 SSRF 内网地址屏蔽,依赖 token 与局域网部署。
320
+ - Windows 默认 `MAX_PATH=260`:serve 会按输出目录长度截短过长的文件名(保留扩展名),任务响应中返回实际名字;CLI 的 `-o` 不做改写。
284
321
  - serve 不提供下载产物接口,文件留在服务器。
285
322
  - serve 重启后任务标记为 `interrupted`,不会自动恢复。
286
323
  - 请求头不落盘,重启后无法重放任务。
@@ -88,10 +88,16 @@ python -m m3u8_cli download "<m3u8_url>"
88
88
  python -m m3u8_cli convert movie.mp4 -o out
89
89
  ```
90
90
 
91
+ 查看版本:
92
+
93
+ ```bash
94
+ m3u8-cli -v # 或 --version
95
+ ```
96
+
91
97
  ### 常用示例
92
98
 
93
99
  ```bash
94
- # 默认输出到 output/output.mp4
100
+ # 默认输出到当前目录的 output.mp4
95
101
  m3u8-cli download "https://example.com/playlist.m3u8"
96
102
 
97
103
  # 指定输出文件名与并发数
@@ -157,6 +163,7 @@ HLS 输出固定为 `playlist.m3u8` 加 `ts/000000.ts` 起的分片;WebM 重
157
163
  | `--cookie` | 无 | 自定义 `Cookie` 请求头 |
158
164
  | `--user-agent` | `Mozilla/5.0` | 自定义 `User-Agent` 请求头 |
159
165
  | `--overwrite` | 关闭 | 覆盖已存在的最终文件 |
166
+ | `-k, --insecure` | 关闭 | 跳过 TLS 证书校验,仅用于自签名或内网服务 |
160
167
 
161
168
  ### 保留 HLS(不合并)
162
169
 
@@ -170,6 +177,8 @@ m3u8-cli download "<m3u8_url>" -f hls -o movie
170
177
  - 源流为 AES-128 加密时保持密文,同时把密钥下载为 `key.bin`(多个不同密钥时为 `key_000.bin`、`key_001.bin`),播放列表的 `EXT-X-KEY` URI 重写为本地文件。
171
178
  - 分片、`playlist.m3u8` 与密钥文件位于同一目录;`--overwrite` 只清理本工具生成的条目。
172
179
 
180
+ `--insecure` 会关闭播放列表、分片与密钥请求的证书和主机名校验,只应在信任的网络上对自签名服务使用;它不会影响元数据中记录的其他信息。
181
+
173
182
  ### 启动下载服务
174
183
 
175
184
  ```bash
@@ -187,6 +196,7 @@ curl -X POST http://127.0.0.1:8787/api/tasks \
187
196
  "url": "https://example.com/playlist.m3u8",
188
197
  "format": "hls",
189
198
  "output": "movie",
199
+ "insecure": false,
190
200
  "headers": {"Referer": "https://example.com/"}
191
201
  }'
192
202
 
@@ -210,6 +220,7 @@ curl -X DELETE http://127.0.0.1:8787/api/tasks/<id> \
210
220
  `chrome-extension/` 提供一个 Manifest V3 扩展,用于识别页面中的 m3u8 链接:
211
221
 
212
222
  - 监听页面网络请求并结合 DOM 扫描,列出当前标签页的 m3u8 地址;
223
+ - 采集页面标题(`og:title`、Twitter Card、JSON-LD、`document.title`)作为默认下载文件名,也可在设置中改用 URL 短 hash;
213
224
  - 复制链接,或生成带 `Referer`/`Cookie`/`User-Agent` 的 `m3u8-cli download` 命令;
214
225
  - 一键提交到本地 `m3u8-cli serve` 下载,并在弹窗中查看进度、取消任务。
215
226
 
@@ -232,28 +243,53 @@ uv run python scripts/build_extension.py
232
243
 
233
244
  ## 下载输出结构
234
245
 
235
- 以 `-o movie.mp4` 为例:
246
+ ### MP4(默认,合并封装)
247
+
248
+ 以 `-o movie.mp4` 为例,输出目录下只有最终产物和元数据:
249
+
250
+ ```
251
+ movie.mp4
252
+ movie.mp4.meta.json # ffprobe 的 format/streams + source 来源信息
253
+ movie.partial.mp4 # FFmpeg 临时输出(仅在合并过程中存在)
254
+ ```
255
+
256
+ 分片缓存不落在输出目录,而是位于系统临时目录:
257
+
258
+ ```
259
+ <TMP>/m3u8-cli-<uid>/<最终路径哈希>/
260
+ ├── state.json # 播放列表指纹,用于缓存失效判断
261
+ ├── segment_000000.bin # 已解密的媒体分片
262
+ ├── map_0000.bin # fMP4 初始化片段(仅 fMP4 流)
263
+ └── combined.ts|mp4 # 本地合并的中间文件
264
+ ```
265
+
266
+ - 合并成功后整个缓存目录会被删除;失败或取消时保留,便于续传。
267
+ - 同一 `-o` 对应同一个缓存目录;播放列表变化时 `state.json` 指纹会触发旧分片清理。
268
+ - 元数据文件由 ffprobe 读取产物生成:顶层 `format`/`streams` 为 `ffprobe -show_format -show_streams -of json` 的输出(已去掉 `filename` 路径),`source` 记录 URL、格式、分片数、指纹、时间与是否校验 TLS;不包含 `output` 字段,也不包含 `Referer`/`Cookie`/`User-Agent`。
269
+ - 未安装 ffprobe 时会在 stderr 打印警告,元数据仅保留 `source` 部分,下载仍继续进行。
270
+
271
+ ### HLS(保留分片)
272
+
273
+ 以 `-o movie` 为例:
236
274
 
237
275
  ```
238
276
  movie/
239
- ├── movie.mp4 # 最终产物
240
- └── cache/
241
- ├── state.json # 播放列表指纹,用于缓存失效判断
242
- ├── segment_000000.bin # 已解密的媒体分片
243
- ├── map_0000.bin # fMP4 初始化片段(仅 fMP4 流)
244
- ├── combined.ts|mp4 # 本地合并的中间文件
245
- └── movie.partial.mp4 # FFmpeg 临时输出
277
+ ├── playlist.m3u8 # 本地播放列表
278
+ ├── 000000.ts # 分片(fMP4 为 .m4s,另有 init.mp4)
279
+ ├── key.bin # 存在 AES-128 密钥时
280
+ └── meta.json # ffprobe 媒体信息 + source 来源信息
246
281
  ```
247
282
 
248
- - 任务目录由输出文件名自动派生;若输出路径已经是 `movie/movie.mp4` 形式,会复用该目录,不会重复嵌套。
249
- - 最终文件生成成功后,缓存仍会保留在任务目录中。
283
+ - 分片先下载到系统临时目录,导出成功后清理;输出目录只保留最终产物和元数据。
284
+ - 导出失败或取消时缓存保留,便于用相同 `-o` 续传。
250
285
 
251
286
  ## 缓存与续传
252
287
 
253
- - 使用相同 `-o` 且任务目录仍在时,已存在且非空的分片会被直接复用。
254
- - `cache/state.json` 记录播放列表原文的 SHA-256 指纹;播放列表变化时会清除旧分片。
288
+ - 两种格式的分片缓存都位于 `<系统临时目录>/m3u8-cli-<uid>/<最终路径 SHA-256 前 16 位>/`;成功下载后删除,失败/取消时保留。
289
+ - 使用相同 `-o` 且缓存仍在时,已存在且非空的分片会被直接复用。
290
+ - 缓存中的 `state.json` 记录播放列表原文的 SHA-256 指纹;播放列表变化时会清除旧分片。
255
291
  - 下载时最终文件已存在且未指定 `--overwrite`,程序会直接退出。
256
- - 需要彻底清理时,删除整个任务目录即可。
292
+ - 操作系统清理临时目录后,失败任务需要重新下载;需要彻底清理时删除对应的临时缓存目录即可。
257
293
 
258
294
  ## 已知限制
259
295
 
@@ -268,6 +304,7 @@ movie/
268
304
  - FFmpeg 的 HLS muxer 会写入固定 `IV=0`,所有分片共用同一密钥与 IV。
269
305
  - 不支持自定义密钥、密钥 URI 或 IV。
270
306
  - serve 不做 SSRF 内网地址屏蔽,依赖 token 与局域网部署。
307
+ - Windows 默认 `MAX_PATH=260`:serve 会按输出目录长度截短过长的文件名(保留扩展名),任务响应中返回实际名字;CLI 的 `-o` 不做改写。
271
308
  - serve 不提供下载产物接口,文件留在服务器。
272
309
  - serve 重启后任务标记为 `interrupted`,不会自动恢复。
273
310
  - 请求头不落盘,重启后无法重放任务。
@@ -4,6 +4,7 @@
4
4
 
5
5
  - 无构建步骤:原生 JavaScript,Chrome 直接加载目录即可。
6
6
  - 网络请求 + DOM 扫描双来源识别,支持 iframe 中的播放器。
7
+ - 自动采集页面标题(`og:title`、Twitter Card、JSON-LD、`document.title`)作为默认下载文件名,标题缺失时回退到「host-文件名」;也可在设置中改为 URL 短 hash(12 位十六进制)。
7
8
  - 一键下载时自动携带该 m3u8 请求自己的 `Referer`、`Cookie`、`User-Agent`。
8
9
  - 弹窗内查看任务进度、取消任务;popup 关闭后仍由后台轮询。
9
10
 
@@ -49,18 +50,20 @@ uv run m3u8-cli serve --host 127.0.0.1 --port 8787 --token mytoken --output-dir
49
50
  | 服务地址 | 默认 `http://127.0.0.1:8787`,需与 `--host/--port` 一致 |
50
51
  | Token | 与 `--token` 一致;点击“测试连接”确认 |
51
52
  | 下载格式 | `mp4`(合并封装)或 `hls`(保留分片) |
53
+ | 文件名 | `页面标题`(默认)或 `URL 短 hash`(12 位十六进制;URL 带临时 token 时每次可能不同) |
52
54
  | 覆盖已存在的文件 | 对应 serve 的 `overwrite` 字段 |
53
55
  | 发送捕获到的 Cookie | 默认开启,仅发送给上面配置的服务地址 |
54
56
  | 复制 CLI 命令时包含 Cookie | 默认关闭;开启后命令会包含 Cookie 原文,注意 shell 历史 |
57
+ | 跳过 TLS 证书校验 | 默认关闭;开启后 serve 请求源站时不校验证书与主机名,仅用于自签名/内网服务 |
55
58
 
56
59
  ## 使用
57
60
 
58
61
  1. 打开包含视频的页面并开始播放,或等待页面加载完成。
59
62
  2. 扩展图标上的 badge 显示当前标签页识别到的 m3u8 数量。
60
- 3. 点击扩展图标:
63
+ 3. 点击扩展图标,候选条目会显示页面标题和 m3u8 URL:
61
64
  - **复制**:复制 m3u8 URL。
62
- - **复制 CLI**:生成 `m3u8-cli download ...` 命令,按设置决定是否包含 `--referer`、`--cookie`、`--user-agent`。
63
- - **下载**:提交到本地 serve 服务;若提示文件名冲突,可点击“换名重试”自动加时间戳。
65
+ - **复制 CLI**:生成 `m3u8-cli download ...` 命令,按设置决定是否包含 `--referer`、`--cookie`、`--user-agent`;`-o` 默认使用页面标题。
66
+ - **下载**:提交到本地 serve 服务,默认文件名同样来自页面标题;若提示文件名冲突,可点击“换名重试”自动加时间戳。
64
67
  4. “下载任务”区域显示排队中/下载中/已完成/失败/已取消/已中断等状态和分片进度;运行中的任务可以取消。
65
68
  5. “重新扫描”会请求当前页面的所有 frame 重新执行 DOM 扫描;“清空”只会清除本标签页的识别结果。
66
69
 
@@ -71,6 +74,13 @@ uv run m3u8-cli serve --host 127.0.0.1 --port 8787 --token mytoken --output-dir
71
74
 
72
75
  同一 URL 同时来自两个来源时会合并为一条,并优先保留网络请求捕获的请求头。
73
76
 
77
+ 标题采集:
78
+
79
+ - content script 按 `og:title` → `twitter:title` → 视频类 JSON-LD → `document.title` 的顺序取标题;iframe 中的候选优先使用顶层标签页标题。
80
+ - 网络请求捕获的候选通过 `chrome.tabs.get()` 取标签页标题(`<all_urls>` host permission 已覆盖,无需 `tabs` 权限)。
81
+ - 标题会清洗控制字符、合并空白,输出名再经过非法字符替换和 200 字符截断;拿不到标题时回退到「host-文件名」。
82
+ - 选择「URL 短 hash」时直接对 m3u8 URL 做 12 位哈希,不依赖页面标题,适合标题过长或不想在文件名中暴露标题的场景。
83
+
74
84
  ## 权限说明
75
85
 
76
86
  | 权限 | 用途 |
@@ -92,6 +102,7 @@ uv run m3u8-cli serve --host 127.0.0.1 --port 8787 --token mytoken --output-dir
92
102
  - Token 保存在 `storage.local`,请勿在不信任的共享 Chrome 配置中使用。
93
103
  - serve 端请求头只保留在内存,不落盘、不在任务响应中返回;详见仓库 README。
94
104
  - 扩展不会自动提交下载,必须由用户点击,避免页面借扩展触发 serve 请求。
105
+ - 「跳过 TLS 证书校验」会关闭 serve 到源站的证书与主机名校验,可能暴露下载内容给中间人;仅在确认源站可信(如自签名内网服务)时开启。
95
106
  - CORS `*` 不是安全边界,真正的访问控制是 Bearer token。
96
107
 
97
108
  ## 排障
@@ -99,10 +110,13 @@ uv run m3u8-cli serve --host 127.0.0.1 --port 8787 --token mytoken --output-dir
99
110
  | 现象 | 处理 |
100
111
  | --- | --- |
101
112
  | 弹窗提示“未检测到 m3u8” | 确认视频已开始播放;刚安装/重载扩展后刷新页面;尝试“重新扫描” |
102
- | “测试连接”报 `Failed to fetch` | 确认 serve 已启动、地址与端口正确、协议为 `http/https` |
103
- | 401 / “未授权” | 检查 Token;serve 未指定 `--token` 时每次启动都会生成新 token |
113
+ | 提示“未配置 Token” | 在扩展设置中填写 serve 的 `--token` |
114
+ | “无法连接 http://...(Failed to fetch)” | serve 未启动、地址/端口错误,或协议不是 `http/https` |
115
+ | 提示“连接 ... 超时(8 秒)” | 服务可达性差或进程卡住;确认 serve 正常运行并检查网络/防火墙 |
116
+ | 401 / “未授权” | Token 与服务端不一致;serve 未指定 `--token` 时每次启动都会生成新 token |
104
117
  | 409 / 目标文件已存在 | 点击“换名重试”,或在设置中开启“覆盖已存在的文件” |
105
118
  | 下载失败,提示 403 | 源站可能校验 `Referer`/`Cookie`/`User-Agent`;确认候选来自“网络”且页面仍处于登录状态 |
119
+ | 失败原因是 `CERTIFICATE_VERIFY_FAILED` / Hostname mismatch | 源站证书不匹配(自签名或内网域名);确认可信后开启「跳过 TLS 证书校验」 |
106
120
  | 扩展重载后页面无反应 | 刷新页面,让 content script 重新注入 |
107
121
  | badge 数量未变化 | 网络识别只在页面产生新请求时更新;静态链接由 DOM 扫描或“重新扫描”补充 |
108
122
 
@@ -127,7 +141,7 @@ uv run python scripts/build_extension.py
127
141
  | 文件 | 职责 |
128
142
  | --- | --- |
129
143
  | `manifest.json` | MV3 配置、权限、content script 注入 |
130
- | `lib.js` | 三端共享的纯函数:URL 判断、MIME 判断、输出名清洗、shell 转义、CLI 命令 |
144
+ | `lib.js` | 三端共享的纯函数:URL 判断、MIME 判断、页面标题清洗、输出名清洗与派生、shell 转义、CLI 命令 |
131
145
  | `lib.test.js` | `lib.js` 的单元测试 |
132
146
  | `background.js` | 网络检测、候选与会话存储、badge、serve API 调用、任务轮询、消息路由 |
133
147
  | `content.js` | DOM 扫描、MutationObserver、增量上报 |
@@ -135,18 +149,20 @@ uv run python scripts/build_extension.py
135
149
 
136
150
  ### 数据与消息
137
151
 
138
- - `storage.session["tab:<tabId>"]`:`{pageUrl, items:[{id,url,source,hasCookie,...}]}`,最多 30 条,标签页关闭或导航时清理。
152
+ - `storage.session["tab:<tabId>"]`:`{pageUrl, pageTitle, items:[{id,url,source,pageTitle,hasCookie,...}]}`,最多 30 条,标签页关闭或导航时清理。
139
153
  - `storage.session["hdr:<candidateId>"]`:`{cookie, referer, userAgent}`,仅 background 读取。
140
- - `storage.local["settings"]`:服务地址、token、格式等设置。
154
+ - `storage.local["settings"]`:服务地址、token、格式、`naming`(`title`/`hash`)、`insecure`(跳过源站 TLS 校验)等设置。
141
155
  - `storage.local["tasks"]`:最近的任务摘要(最多 20 条,优先保留活动任务),不含请求头。
142
156
  - popup 通过 `chrome.runtime.sendMessage` 发送 `download:submit`、`task:cancel`、`tasks:refresh`、`settings:test`、`cli:build`、`tab:clear`、`dom:rescan`。
143
- - content script 通过 `dom:candidates` 上报 URL,用 `sender.tab.id` 归属标签页。
157
+ - content script 通过 `dom:candidates` 上报 URL 与 `title`(附 `frameTop` 标记是否顶层 frame),用 `sender.tab.id` 归属标签页。
144
158
 
145
159
  ## 已知限制
146
160
 
147
161
  - popup 关闭后任务轮询依赖 `chrome.alarms`,最短周期为 30 秒(旧版 Chrome 为 1 分钟);popup 打开时每 2 秒刷新一次。
148
162
  - 只做网络请求与 DOM 扫描,不注入 MAIN world 去 hook `fetch`/`XMLHttpRequest`;缓存命中或没有实际请求的地址可能只由 DOM 扫描发现。
149
163
  - 不支持 Shadow DOM 内的元素;iframe 支持依赖 `all_frames` 和网络层。
164
+ - 标题依赖页面元数据:SPA 或动态改写的标题可能滞后,拿不到时回退到 URL 派生名;标题超过 200 字符会被截断,标签页标题可能带站点后缀(如「- 示例站」),需要时可手动改名。
165
+ - serve 还会按输出目录长度进一步截短文件名(Windows `MAX_PATH` 或 POSIX 255 字节限制),任务响应里的 `output` 是实际使用的名字;「换名重试」会使用较短的名字,避免时间戳被截掉。
150
166
  - SPA 路由切换不会自动清空候选,使用“清空”或刷新页面。
151
167
  - 每个标签页最多保留 30 条候选,任务历史最多 20 条。
152
168
  - 下载能力受 `m3u8-cli` 本身限制(AES-128 identity、无 DRM、不支持 BYTERANGE 等),详见根 README。
@@ -10,7 +10,8 @@ const {
10
10
  looksLikePlaylistUrl,
11
11
  isHlsContentType,
12
12
  normalizeCandidateUrl,
13
- deriveOutputName,
13
+ cleanPageTitle,
14
+ buildOutputName,
14
15
  buildCliCommand,
15
16
  } = M3U8Lib;
16
17
 
@@ -41,9 +42,11 @@ const DEFAULT_SETTINGS = {
41
42
  serverUrl: "http://127.0.0.1:8787",
42
43
  token: "",
43
44
  format: "mp4",
45
+ naming: "title",
44
46
  overwrite: false,
45
47
  sendCookie: true,
46
48
  copyCookie: false,
49
+ insecure: false,
47
50
  };
48
51
 
49
52
  /** requestId -> 白名单请求头快照;service worker 休眠后丢失,可接受。 */
@@ -69,10 +72,14 @@ function headerKey(candidateId) {
69
72
  return `hdr:${candidateId}`;
70
73
  }
71
74
 
75
+ function emptyTabState() {
76
+ return { pageUrl: "", pageTitle: "", updatedAt: 0, items: [] };
77
+ }
78
+
72
79
  async function readTabState(tabId) {
73
80
  const key = tabKey(tabId);
74
81
  const data = await chrome.storage.session.get(key);
75
- return data[key] || { pageUrl: "", updatedAt: 0, items: [] };
82
+ return data[key] || emptyTabState();
76
83
  }
77
84
 
78
85
  async function removeHeaders(ids) {
@@ -123,21 +130,26 @@ function addCandidate(tabId, candidate, headers) {
123
130
  return enqueueWrite(async () => {
124
131
  const key = tabKey(tabId);
125
132
  const data = await chrome.storage.session.get(key);
126
- const state = data[key] || { pageUrl: "", updatedAt: 0, items: [] };
133
+ const state = data[key] || emptyTabState();
127
134
  const existingIndex = state.items.findIndex(
128
135
  (item) => item.url === candidate.url,
129
136
  );
137
+ // 空标题不覆盖已有标题:网络候选可能晚于 DOM 候选、拿不到标题。
138
+ const patch = { ...candidate };
139
+ if (!patch.pageTitle) {
140
+ delete patch.pageTitle;
141
+ }
130
142
 
131
143
  let item;
132
144
  if (existingIndex >= 0) {
133
145
  const existing = state.items[existingIndex];
134
146
  state.items.splice(existingIndex, 1);
135
- item = { ...existing, ...candidate, id: existing.id };
147
+ item = { ...existing, ...patch, id: existing.id };
136
148
  } else {
137
149
  item = {
138
150
  id: crypto.randomUUID(),
139
151
  capturedAt: Date.now(),
140
- ...candidate,
152
+ ...patch,
141
153
  };
142
154
  }
143
155
  item.hasCookie = Boolean(headers && headers.cookie);
@@ -150,6 +162,9 @@ function addCandidate(tabId, candidate, headers) {
150
162
  if (!state.pageUrl && candidate.pageUrl) {
151
163
  state.pageUrl = candidate.pageUrl;
152
164
  }
165
+ if (patch.pageTitle) {
166
+ state.pageTitle = patch.pageTitle;
167
+ }
153
168
  state.updatedAt = Date.now();
154
169
 
155
170
  const updates = { [key]: state };
@@ -202,6 +217,16 @@ function trimPendingHeaders() {
202
217
  }
203
218
  }
204
219
 
220
+ /** 标签页标题;host permission 已覆盖所有 http/https 页面。 */
221
+ async function getTabTitle(tabId) {
222
+ try {
223
+ const tab = await chrome.tabs.get(tabId);
224
+ return tab && typeof tab.title === "string" ? tab.title : "";
225
+ } catch (_) {
226
+ return "";
227
+ }
228
+ }
229
+
205
230
  chrome.webRequest.onSendHeaders.addListener(
206
231
  (details) => {
207
232
  if (details.tabId < 0 || !TRACKED_TYPE_SET.has(details.type)) {
@@ -251,15 +276,21 @@ chrome.webRequest.onHeadersReceived.addListener(
251
276
  refererHost: referer ? safeHost(referer) : "",
252
277
  pageUrl,
253
278
  };
254
- addCandidate(
255
- details.tabId,
256
- candidate,
257
- {
258
- cookie: captured.cookie || "",
259
- referer,
260
- userAgent: captured.userAgent || "",
261
- },
262
- ).catch(() => {});
279
+ const headers = {
280
+ cookie: captured.cookie || "",
281
+ referer,
282
+ userAgent: captured.userAgent || "",
283
+ };
284
+ getTabTitle(details.tabId)
285
+ .then((rawTitle) => {
286
+ const pageTitle = cleanPageTitle(rawTitle);
287
+ return addCandidate(
288
+ details.tabId,
289
+ pageTitle ? { ...candidate, pageTitle } : candidate,
290
+ headers,
291
+ );
292
+ })
293
+ .catch(() => {});
263
294
  },
264
295
  { urls: ["<all_urls>"], types: TRACKED_TYPES },
265
296
  ["responseHeaders"],
@@ -302,6 +333,12 @@ async function handleDomCandidates(message, sender) {
302
333
  const base = message.pageUrl || pageUrl;
303
334
  const urls = Array.isArray(message.urls) ? message.urls.slice(0, 100) : [];
304
335
  const seen = new Set();
336
+ const tabTitle = cleanPageTitle((sender.tab && sender.tab.title) || "");
337
+ // iframe 里的 document.title 往往只是播放器自己的标题,优先用顶层标签页标题。
338
+ const pageTitle =
339
+ message.frameTop === true
340
+ ? cleanPageTitle(message.title) || tabTitle
341
+ : tabTitle || cleanPageTitle(message.title);
305
342
 
306
343
  for (const raw of urls) {
307
344
  const url = normalizeCandidateUrl(raw, base);
@@ -309,17 +346,17 @@ async function handleDomCandidates(message, sender) {
309
346
  continue;
310
347
  }
311
348
  seen.add(url);
312
- await addCandidate(
313
- tabId,
314
- {
315
- url,
316
- source: "dom",
317
- contentType: "",
318
- refererHost: "",
319
- pageUrl,
320
- },
321
- null,
322
- );
349
+ const candidate = {
350
+ url,
351
+ source: "dom",
352
+ contentType: "",
353
+ refererHost: "",
354
+ pageUrl,
355
+ };
356
+ if (pageTitle) {
357
+ candidate.pageTitle = pageTitle;
358
+ }
359
+ await addCandidate(tabId, candidate, null);
323
360
  }
324
361
  }
325
362
 
@@ -366,6 +403,13 @@ function resolveFormat(requested, fallback) {
366
403
  return fallback === "hls" ? "hls" : "mp4";
367
404
  }
368
405
 
406
+ function resolveNaming(requested, fallback) {
407
+ if (requested === "hash" || requested === "title") {
408
+ return requested;
409
+ }
410
+ return fallback === "hash" ? "hash" : "title";
411
+ }
412
+
369
413
  async function apiFetch(path, options) {
370
414
  const settings = await getSettings();
371
415
  const base = normalizeServerUrl(settings.serverUrl);
@@ -376,7 +420,21 @@ async function apiFetch(path, options) {
376
420
  };
377
421
  init.headers.Authorization = `Bearer ${settings.token}`;
378
422
 
379
- const response = await fetch(base + path, init);
423
+ let response;
424
+ try {
425
+ response = await fetch(base + path, init);
426
+ } catch (error) {
427
+ const name = error && error.name;
428
+ if (name === "TimeoutError" || name === "AbortError") {
429
+ throw new Error(
430
+ `连接 ${base} 超时(8 秒):请确认服务已启动且地址可达`,
431
+ );
432
+ }
433
+ const reason = (error && error.message) || String(error);
434
+ throw new Error(
435
+ `无法连接 ${base}(${reason}):请确认 m3u8-cli serve 已启动、地址和端口正确`,
436
+ );
437
+ }
380
438
  let payload = null;
381
439
  try {
382
440
  payload = await response.json();
@@ -393,12 +451,29 @@ async function apiFetch(path, options) {
393
451
  return payload;
394
452
  }
395
453
 
454
+ /** 按命名策略生成输出名;标题缺失时回退到 URL 派生名。 */
455
+ function candidateOutputName(candidate, state, format, naming) {
456
+ const title = candidate.pageTitle || (state && state.pageTitle) || "";
457
+ return buildOutputName({
458
+ title,
459
+ url: candidate.url,
460
+ format,
461
+ naming,
462
+ });
463
+ }
464
+
396
465
  async function submitDownload(message) {
397
466
  const tabId = message.tabId;
398
467
  if (!Number.isInteger(tabId) || tabId < 0) {
399
468
  return { ok: false, error: "无效的标签页" };
400
469
  }
401
470
  const settings = await getSettings();
471
+ if (!settings.token) {
472
+ return {
473
+ ok: false,
474
+ error: "未配置 Token:请在扩展设置中填写 serve 的 token",
475
+ };
476
+ }
402
477
  const state = await readTabState(tabId);
403
478
  const candidate = state.items.find((item) => item.id === message.candidateId);
404
479
  if (!candidate) {
@@ -408,8 +483,10 @@ async function submitDownload(message) {
408
483
  const key = headerKey(candidate.id);
409
484
  const captured = (await chrome.storage.session.get(key))[key] || {};
410
485
  const format = resolveFormat(message.format, settings.format);
486
+ const naming = resolveNaming(message.naming, settings.naming);
411
487
  const output =
412
- message.output || deriveOutputName(candidate.url, format);
488
+ message.output ||
489
+ candidateOutputName(candidate, state, format, naming);
413
490
  const overwrite =
414
491
  message.overwrite === undefined
415
492
  ? Boolean(settings.overwrite)
@@ -435,6 +512,7 @@ async function submitDownload(message) {
435
512
  format,
436
513
  output,
437
514
  overwrite,
515
+ insecure: Boolean(settings.insecure),
438
516
  headers,
439
517
  }),
440
518
  });
@@ -471,7 +549,10 @@ async function buildCliMessage(message) {
471
549
  const key = headerKey(candidate.id);
472
550
  const captured = (await chrome.storage.session.get(key))[key] || {};
473
551
  const format = resolveFormat(message.format, settings.format);
474
- const output = message.output || deriveOutputName(candidate.url, format);
552
+ const naming = resolveNaming(message.naming, settings.naming);
553
+ const output =
554
+ message.output ||
555
+ candidateOutputName(candidate, state, format, naming);
475
556
  const includeCookie = Boolean(message.includeCookie);
476
557
  const overwrite =
477
558
  message.overwrite === undefined