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.
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/PKG-INFO +52 -15
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/README.md +51 -14
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/chrome-extension/README.md +25 -9
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/chrome-extension/background.js +109 -28
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/chrome-extension/content.js +109 -1
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/chrome-extension/lib.js +72 -1
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/chrome-extension/lib.test.js +74 -0
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/chrome-extension/manifest.json +1 -1
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/chrome-extension/popup.css +12 -0
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/chrome-extension/popup.html +11 -0
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/chrome-extension/popup.js +79 -7
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/pyproject.toml +1 -1
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/pyproject.toml.orig +1 -1
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/src/m3u8_cli/__init__.py +1 -1
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/src/m3u8_cli/cli.py +13 -0
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/src/m3u8_cli/download.py +114 -9
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/src/m3u8_cli/downloader.py +3 -1
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/src/m3u8_cli/hls_export.py +2 -2
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/src/m3u8_cli/merge.py +6 -3
- m3u8_cli-0.2.0/src/m3u8_cli/paths.py +98 -0
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/src/m3u8_cli/playlist.py +6 -5
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/src/m3u8_cli/serve.py +22 -7
- m3u8_cli-0.1.0/src/m3u8_cli/paths.py +0 -30
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/LICENSE +0 -0
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/scripts/build_extension.py +0 -0
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/src/m3u8_cli/__main__.py +0 -0
- {m3u8_cli-0.1.0 → m3u8_cli-0.2.0}/src/m3u8_cli/convert.py +0 -0
- {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.
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
├──
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
-
|
|
262
|
-
-
|
|
296
|
+
- 分片先下载到系统临时目录,导出成功后清理;输出目录只保留最终产物和元数据。
|
|
297
|
+
- 导出失败或取消时缓存保留,便于用相同 `-o` 续传。
|
|
263
298
|
|
|
264
299
|
## 缓存与续传
|
|
265
300
|
|
|
266
|
-
-
|
|
267
|
-
- `
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
├──
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
-
|
|
249
|
-
-
|
|
283
|
+
- 分片先下载到系统临时目录,导出成功后清理;输出目录只保留最终产物和元数据。
|
|
284
|
+
- 导出失败或取消时缓存保留,便于用相同 `-o` 续传。
|
|
250
285
|
|
|
251
286
|
## 缓存与续传
|
|
252
287
|
|
|
253
|
-
-
|
|
254
|
-
- `
|
|
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
|
-
|
|
|
103
|
-
|
|
|
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
|
|
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
|
|
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
|
-
|
|
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] ||
|
|
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] ||
|
|
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, ...
|
|
147
|
+
item = { ...existing, ...patch, id: existing.id };
|
|
136
148
|
} else {
|
|
137
149
|
item = {
|
|
138
150
|
id: crypto.randomUUID(),
|
|
139
151
|
capturedAt: Date.now(),
|
|
140
|
-
...
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
|
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
|