hs-m3u8 0.1.7__py3-none-any.whl → 0.1.8__py3-none-any.whl
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.
hs_m3u8/main.py
CHANGED
|
@@ -3,17 +3,15 @@ M3U8 下载器
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
-
import platform
|
|
7
6
|
import posixpath
|
|
8
7
|
import shutil
|
|
9
|
-
import subprocess
|
|
10
8
|
from collections.abc import Callable
|
|
11
9
|
from hashlib import md5
|
|
12
|
-
from importlib import resources
|
|
13
10
|
from pathlib import Path
|
|
14
|
-
from
|
|
15
|
-
from
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import ParseResult, urljoin, urlparse
|
|
16
13
|
|
|
14
|
+
import av
|
|
17
15
|
import m3u8
|
|
18
16
|
from hssp import Net
|
|
19
17
|
from hssp.models.net import RequestModel
|
|
@@ -22,40 +20,28 @@ from hssp.utils import crypto
|
|
|
22
20
|
from loguru import logger
|
|
23
21
|
|
|
24
22
|
|
|
25
|
-
def get_ffmpeg():
|
|
26
|
-
"""
|
|
27
|
-
根据平台不同获取不同的ffmpeg可执行文件
|
|
28
|
-
:return: FFmpeg 的可执行文件路径
|
|
29
|
-
"""
|
|
30
|
-
current_os = platform.system()
|
|
31
|
-
if current_os != "Windows":
|
|
32
|
-
return "ffmpeg"
|
|
33
|
-
|
|
34
|
-
with resources.as_file(resources.files("hs_m3u8.res")) as package_dir:
|
|
35
|
-
ffmpeg_bin = package_dir / "ffmpeg_win.exe"
|
|
36
|
-
if ffmpeg_bin.exists():
|
|
37
|
-
return str(ffmpeg_bin)
|
|
38
|
-
|
|
39
|
-
# ZIP 文件
|
|
40
|
-
ffmpeg_bin_zip = package_dir / "ffmpeg_win.exe.zip"
|
|
41
|
-
if ffmpeg_bin_zip.exists():
|
|
42
|
-
with ZipFile(ffmpeg_bin_zip, "r") as zip_ref:
|
|
43
|
-
zip_ref.extractall(ffmpeg_bin.parent)
|
|
44
|
-
|
|
45
|
-
return ffmpeg_bin
|
|
46
|
-
|
|
47
|
-
|
|
48
23
|
class M3u8Key:
|
|
49
24
|
"""
|
|
50
25
|
M3u8key
|
|
51
26
|
"""
|
|
52
27
|
|
|
53
|
-
def __init__(self, key: bytes, iv: str = None):
|
|
28
|
+
def __init__(self, key: bytes, iv: str | bytes | None = None):
|
|
54
29
|
"""
|
|
55
|
-
:
|
|
56
|
-
|
|
30
|
+
Args:
|
|
31
|
+
key: 密钥
|
|
32
|
+
iv: 偏移,可以是十六进制字符串(0x开头)、bytes或None
|
|
57
33
|
"""
|
|
58
|
-
|
|
34
|
+
try:
|
|
35
|
+
if isinstance(iv, str) and iv.startswith("0x"):
|
|
36
|
+
iv = bytes.fromhex(iv[2:])
|
|
37
|
+
elif isinstance(iv, str):
|
|
38
|
+
iv = bytes.fromhex(iv)
|
|
39
|
+
except ValueError as e:
|
|
40
|
+
raise ValueError(f"iv {iv!r} 值不对: {e}") from e
|
|
41
|
+
|
|
42
|
+
if iv and len(iv) != 16:
|
|
43
|
+
raise ValueError(f"iv {iv} 长度不等于16")
|
|
44
|
+
|
|
59
45
|
self.key = key
|
|
60
46
|
self.iv = iv
|
|
61
47
|
|
|
@@ -65,36 +51,28 @@ class M3u8Downloader:
|
|
|
65
51
|
M3u8 异步下载器,并保留hls文件
|
|
66
52
|
"""
|
|
67
53
|
|
|
68
|
-
retry_count: int = 0
|
|
69
|
-
retry_max_count: int = 50
|
|
70
|
-
ts_url_list: list = []
|
|
71
|
-
ts_path_list: list = []
|
|
72
|
-
ts_key: M3u8Key = None
|
|
73
|
-
mp4_head_hrl: str = None
|
|
74
|
-
m3u8_md5 = ""
|
|
75
|
-
|
|
76
54
|
def __init__(
|
|
77
55
|
self,
|
|
78
56
|
m3u8_url: str,
|
|
79
57
|
save_path: str,
|
|
80
|
-
|
|
81
|
-
max_workers=None,
|
|
82
|
-
headers=None,
|
|
83
|
-
key: M3u8Key = None,
|
|
84
|
-
get_m3u8_func: Callable = None,
|
|
85
|
-
m3u8_request_before: Callable[[RequestModel], RequestModel] = None,
|
|
86
|
-
m3u8_response_after: Callable[[Response], Response] = None,
|
|
87
|
-
key_request_before: Callable[[RequestModel], RequestModel] = None,
|
|
88
|
-
key_response_after: Callable[[Response], Response] = None,
|
|
89
|
-
ts_request_before: Callable[[RequestModel], RequestModel] = None,
|
|
90
|
-
ts_response_after: Callable[[Response], Response] = None,
|
|
58
|
+
is_decrypt: bool = False,
|
|
59
|
+
max_workers: int | None = None,
|
|
60
|
+
headers: dict[str, Any] | None = None,
|
|
61
|
+
key: M3u8Key | None = None,
|
|
62
|
+
get_m3u8_func: Callable | None = None,
|
|
63
|
+
m3u8_request_before: Callable[[RequestModel], RequestModel] | None = None,
|
|
64
|
+
m3u8_response_after: Callable[[Response], Response] | None = None,
|
|
65
|
+
key_request_before: Callable[[RequestModel], RequestModel] | None = None,
|
|
66
|
+
key_response_after: Callable[[Response], Response] | None = None,
|
|
67
|
+
ts_request_before: Callable[[RequestModel], RequestModel] | None = None,
|
|
68
|
+
ts_response_after: Callable[[Response], Response] | None = None,
|
|
91
69
|
):
|
|
92
70
|
"""
|
|
93
71
|
|
|
94
72
|
Args:
|
|
95
73
|
m3u8_url: m3u8 地址
|
|
96
74
|
save_path: 保存路径
|
|
97
|
-
|
|
75
|
+
is_decrypt: 如果ts被加密,是否解密ts
|
|
98
76
|
max_workers: 最大并发数
|
|
99
77
|
headers: 情求头
|
|
100
78
|
get_m3u8_func: 处理m3u8情求的回调函数。适用于m3u8地址不是真正的地址,
|
|
@@ -131,7 +109,7 @@ class M3u8Downloader:
|
|
|
131
109
|
if ts_response_after:
|
|
132
110
|
self.ts_net.response_after_signal.connect(ts_response_after)
|
|
133
111
|
|
|
134
|
-
self.
|
|
112
|
+
self.is_decrypt = is_decrypt
|
|
135
113
|
self.m3u8_url = urlparse(m3u8_url)
|
|
136
114
|
self.get_m3u8_func = get_m3u8_func
|
|
137
115
|
self.save_dir = Path(save_path) / "hls"
|
|
@@ -139,6 +117,15 @@ class M3u8Downloader:
|
|
|
139
117
|
self.key_path = self.save_dir / "key.key"
|
|
140
118
|
self.custom_key = key
|
|
141
119
|
|
|
120
|
+
# 实例变量初始化
|
|
121
|
+
self.retry_count: int = 0
|
|
122
|
+
self.retry_max_count: int = 50
|
|
123
|
+
self.ts_url_list: list = []
|
|
124
|
+
self.ts_path_list: list = []
|
|
125
|
+
self.ts_key: M3u8Key | None = None
|
|
126
|
+
self.mp4_head_url: str | None = None
|
|
127
|
+
self.m3u8_md5: str = ""
|
|
128
|
+
|
|
142
129
|
if not self.save_dir.exists():
|
|
143
130
|
self.save_dir.mkdir(parents=True)
|
|
144
131
|
|
|
@@ -154,15 +141,18 @@ class M3u8Downloader:
|
|
|
154
141
|
async def start(self, merge=True, del_hls=False):
|
|
155
142
|
"""
|
|
156
143
|
下载器启动函数
|
|
157
|
-
:
|
|
158
|
-
|
|
159
|
-
|
|
144
|
+
Args:
|
|
145
|
+
merge: ts下载完后是否合并,默认合并
|
|
146
|
+
del_hls: 是否删除hls系列文件,包括.m3u8文件、*.ts、.key文件
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
|
|
160
150
|
"""
|
|
161
151
|
mp4_path = self.save_dir.parent / f"{self.save_name}.mp4"
|
|
162
152
|
mp4_path = mp4_path.absolute()
|
|
163
|
-
if
|
|
153
|
+
if mp4_path.exists():
|
|
164
154
|
self.logger.info(f"{mp4_path}已存在")
|
|
165
|
-
if del_hls:
|
|
155
|
+
if del_hls and self.save_dir.exists():
|
|
166
156
|
shutil.rmtree(str(self.save_dir))
|
|
167
157
|
return True
|
|
168
158
|
|
|
@@ -188,7 +178,7 @@ class M3u8Downloader:
|
|
|
188
178
|
if self.retry_count < self.retry_max_count:
|
|
189
179
|
self.retry_count += 1
|
|
190
180
|
self.logger.error(f"正在进行重试:{self.retry_count}/{self.retry_max_count}")
|
|
191
|
-
return self.start(merge, del_hls)
|
|
181
|
+
return await self.start(merge, del_hls)
|
|
192
182
|
return False
|
|
193
183
|
|
|
194
184
|
if not merge:
|
|
@@ -201,24 +191,30 @@ class M3u8Downloader:
|
|
|
201
191
|
f"mp4合并失败. ts应该下载数量为:{count_1}, 实际下载数量为:{count_2}. 保存路径为:{self.save_dir}"
|
|
202
192
|
)
|
|
203
193
|
return False
|
|
194
|
+
|
|
204
195
|
if del_hls:
|
|
205
196
|
shutil.rmtree(str(self.save_dir))
|
|
197
|
+
|
|
206
198
|
return True
|
|
207
199
|
|
|
208
200
|
async def _download(self):
|
|
209
201
|
"""
|
|
210
202
|
下载ts文件、m3u8文件、key文件
|
|
211
|
-
:
|
|
203
|
+
Returns:
|
|
204
|
+
|
|
212
205
|
"""
|
|
213
206
|
self.ts_url_list = await self.get_ts_list(self.m3u8_url)
|
|
214
207
|
self.ts_path_list = [None] * len(self.ts_url_list)
|
|
215
208
|
await asyncio.gather(*[self._download_ts(url) for url in self.ts_url_list])
|
|
216
209
|
|
|
217
|
-
async def get_ts_list(self, url) -> list[dict]:
|
|
210
|
+
async def get_ts_list(self, url: ParseResult) -> list[dict]:
|
|
218
211
|
"""
|
|
219
212
|
解析m3u8并保存至列表
|
|
220
|
-
:
|
|
221
|
-
|
|
213
|
+
Args:
|
|
214
|
+
url: m3u8地址
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
|
|
222
218
|
"""
|
|
223
219
|
resp = await self.m3u8_net.get(url.geturl(), headers=self.headers)
|
|
224
220
|
m3u8_text = self.get_m3u8_func(resp.text) if self.get_m3u8_func else resp.text
|
|
@@ -245,7 +241,7 @@ class M3u8Downloader:
|
|
|
245
241
|
if segment_map_count > 0:
|
|
246
242
|
if segment_map_count > 1:
|
|
247
243
|
raise ValueError("暂不支持segment_map有多个的情况,请提交issues,并告知m3u8的地址,方便做适配")
|
|
248
|
-
self.
|
|
244
|
+
self.mp4_head_url = prefix + m3u8_obj.segment_map[0].uri
|
|
249
245
|
m3u8_obj.segment_map[0].uri = "head.mp4"
|
|
250
246
|
|
|
251
247
|
# 遍历ts文件
|
|
@@ -281,32 +277,40 @@ class M3u8Downloader:
|
|
|
281
277
|
async def _download_ts(self, ts_item: dict):
|
|
282
278
|
"""
|
|
283
279
|
下载ts
|
|
284
|
-
:
|
|
285
|
-
|
|
280
|
+
Args:
|
|
281
|
+
ts_item: ts数据
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
|
|
286
285
|
"""
|
|
287
286
|
index = ts_item["index"]
|
|
288
287
|
ts_uri = ts_item["uri"]
|
|
289
288
|
ts_path = self.save_dir / f"{index}.ts"
|
|
290
|
-
|
|
289
|
+
|
|
290
|
+
if ts_path.exists():
|
|
291
291
|
self.ts_path_list[index] = str(ts_path)
|
|
292
292
|
return
|
|
293
|
+
|
|
293
294
|
resp = await self.ts_net.get(ts_item["uri"], self.headers)
|
|
294
295
|
ts_content = resp.content
|
|
295
296
|
if ts_content is None:
|
|
296
297
|
return
|
|
297
298
|
|
|
298
|
-
if self.ts_key and self.
|
|
299
|
+
if self.ts_key and self.is_decrypt:
|
|
299
300
|
ts_content = crypto.decrypt_aes_256_cbc(ts_content, self.ts_key.key, self.ts_key.iv)
|
|
300
301
|
|
|
301
302
|
self.save_file(ts_content, ts_path)
|
|
302
303
|
self.logger.info(f"{ts_uri}下载成功")
|
|
303
304
|
self.ts_path_list[index] = str(ts_path)
|
|
304
305
|
|
|
305
|
-
async def merge(self):
|
|
306
|
+
async def merge(self) -> bool:
|
|
306
307
|
"""
|
|
307
308
|
合并ts文件为mp4文件
|
|
308
|
-
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
返回是否合并成功
|
|
309
312
|
"""
|
|
313
|
+
|
|
310
314
|
self.logger.info("开始合并mp4")
|
|
311
315
|
if len(self.ts_path_list) != len(self.ts_url_list):
|
|
312
316
|
self.logger.error("数量不足拒绝合并!")
|
|
@@ -322,47 +326,99 @@ class M3u8Downloader:
|
|
|
322
326
|
|
|
323
327
|
# 如果有mp4的头,则把ts放到后面
|
|
324
328
|
mp4_head_data = b""
|
|
325
|
-
if self.
|
|
326
|
-
resp = await self.ts_net.get(self.
|
|
329
|
+
if self.mp4_head_url:
|
|
330
|
+
resp = await self.ts_net.get(self.mp4_head_url)
|
|
327
331
|
mp4_head_data = resp.content
|
|
328
332
|
mp4_head_file = self.save_dir / "head.mp4"
|
|
329
333
|
mp4_head_file.write_bytes(mp4_head_data)
|
|
330
334
|
|
|
331
335
|
# 把ts文件整合到一起
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
big_ts_file.close()
|
|
336
|
+
with big_ts_path.open("ab+") as big_ts_file:
|
|
337
|
+
big_ts_file.write(mp4_head_data)
|
|
338
|
+
for path in self.ts_path_list:
|
|
339
|
+
with open(path, "rb") as ts_file:
|
|
340
|
+
data = ts_file.read()
|
|
341
|
+
if self.ts_key:
|
|
342
|
+
data = crypto.decrypt_aes_256_cbc(data, self.ts_key.key, self.ts_key.iv)
|
|
343
|
+
big_ts_file.write(data)
|
|
341
344
|
self.logger.info("ts文件整合完毕")
|
|
342
345
|
|
|
343
346
|
# 把大的ts文件转换成mp4文件
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
self.logger.info(f"ts整合成功,开始转为mp4。 command:{command}")
|
|
350
|
-
result = subprocess.run(command, shell=True, capture_output=True, text=True)
|
|
351
|
-
if result.returncode != 0:
|
|
352
|
-
logger.error(f"命令执行失败: {result.stderr or result.stdout}")
|
|
347
|
+
self.logger.info(f"ts整合成功,开始转为mp4。 ts路径:{big_ts_path} mp4路径:{mp4_path}")
|
|
348
|
+
result = self.ts_to_mp4(big_ts_path, mp4_path)
|
|
349
|
+
if not result:
|
|
350
|
+
self.logger.error("ts转mp4失败!")
|
|
351
|
+
return False
|
|
353
352
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
return
|
|
353
|
+
# 删除完整ts文件
|
|
354
|
+
big_ts_path.unlink()
|
|
355
|
+
return True
|
|
357
356
|
|
|
358
357
|
@staticmethod
|
|
359
|
-
def save_file(content: bytes | str, filepath):
|
|
358
|
+
def save_file(content: bytes | str, filepath: Path | str):
|
|
360
359
|
"""
|
|
361
360
|
保存内容到文件
|
|
362
|
-
:
|
|
363
|
-
|
|
364
|
-
|
|
361
|
+
Args:
|
|
362
|
+
content: 内容
|
|
363
|
+
filepath: 文件路径
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
|
|
365
367
|
"""
|
|
366
368
|
mode = "wb" if isinstance(content, bytes) else "w"
|
|
367
369
|
with open(file=filepath, mode=mode) as file:
|
|
368
370
|
file.write(content)
|
|
371
|
+
|
|
372
|
+
@staticmethod
|
|
373
|
+
def ts_to_mp4(ts_path: Path, mp4_path: Path) -> bool:
|
|
374
|
+
"""
|
|
375
|
+
将 TS 转为 MP4 (stream copy,不重编码)
|
|
376
|
+
Args:
|
|
377
|
+
ts_path: ts 视频文件路径
|
|
378
|
+
mp4_path: mp4 视频文件路径
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
返回是否转换成功:mp4路径存在并且是一个文件并且大小大于0
|
|
382
|
+
"""
|
|
383
|
+
if not ts_path.exists():
|
|
384
|
+
raise FileNotFoundError("ts文件不存在")
|
|
385
|
+
|
|
386
|
+
if not mp4_path.parent.exists():
|
|
387
|
+
mp4_path.parent.mkdir(parents=True)
|
|
388
|
+
|
|
389
|
+
with av.open(str(ts_path)) as input_container, av.open(str(mp4_path), "w") as output_container:
|
|
390
|
+
# 映射视频流
|
|
391
|
+
out_stream = None
|
|
392
|
+
if input_container.streams.video:
|
|
393
|
+
in_stream = input_container.streams.video[0]
|
|
394
|
+
out_stream = output_container.add_stream(in_stream.codec_context.name)
|
|
395
|
+
out_stream.width = in_stream.codec_context.width
|
|
396
|
+
out_stream.height = in_stream.codec_context.height
|
|
397
|
+
out_stream.pix_fmt = in_stream.codec_context.pix_fmt
|
|
398
|
+
if in_stream.average_rate:
|
|
399
|
+
out_stream.rate = in_stream.average_rate
|
|
400
|
+
|
|
401
|
+
# 映射音频流 (如果存在)
|
|
402
|
+
out_audio = None
|
|
403
|
+
if input_container.streams.audio:
|
|
404
|
+
in_audio = input_container.streams.audio[0]
|
|
405
|
+
out_audio = output_container.add_stream(in_audio.codec_context.name)
|
|
406
|
+
out_audio.rate = in_audio.codec_context.sample_rate # type: ignore
|
|
407
|
+
if in_audio.codec_context.layout:
|
|
408
|
+
out_audio.layout = in_audio.codec_context.layout # type: ignore
|
|
409
|
+
if in_audio.codec_context.format:
|
|
410
|
+
out_audio.format = in_audio.codec_context.format # type: ignore
|
|
411
|
+
|
|
412
|
+
# Stream copy - 直接复制数据包,不重编码
|
|
413
|
+
for packet in input_container.demux():
|
|
414
|
+
if packet.dts is None:
|
|
415
|
+
continue
|
|
416
|
+
|
|
417
|
+
if packet.stream.type == "video" and out_stream:
|
|
418
|
+
packet.stream = out_stream
|
|
419
|
+
output_container.mux(packet)
|
|
420
|
+
elif packet.stream.type == "audio" and out_audio:
|
|
421
|
+
packet.stream = out_audio
|
|
422
|
+
output_container.mux(packet)
|
|
423
|
+
|
|
424
|
+
return mp4_path.exists() and mp4_path.is_file() and mp4_path.stat().st_size > 0
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
hs_m3u8/__init__.py,sha256=_AhYioHAgwPXE10FXGZ3ZKj1urwFYR0L9xzBn8pQPJw,72
|
|
2
|
+
hs_m3u8/main.py,sha256=rrT_JCg357c5I6VJkCEjNVKOzStCG4F-VhjXzwY1KtE,15634
|
|
3
|
+
hs_m3u8-0.1.8.dist-info/METADATA,sha256=5IpAsn-5Qzpelpzj_lk7f6R7_C8ygepGoxQdQw3GM_s,2205
|
|
4
|
+
hs_m3u8-0.1.8.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
5
|
+
hs_m3u8-0.1.8.dist-info/RECORD,,
|
hs_m3u8/res/ffmpeg_win.exe.zip
DELETED
|
Binary file
|
hs_m3u8-0.1.7.dist-info/RECORD
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
hs_m3u8/__init__.py,sha256=_AhYioHAgwPXE10FXGZ3ZKj1urwFYR0L9xzBn8pQPJw,72
|
|
2
|
-
hs_m3u8/main.py,sha256=dyRy7ciRMbvrrjAE01mhjuw1Wj4mHG_DZiJNHHVtJ9Y,13403
|
|
3
|
-
hs_m3u8/res/ffmpeg_win.exe.zip,sha256=x_7Fa9N3hzN1d7Ph9ZwOiwpuRfLEVnhNL8tPjuZZMe0,60131319
|
|
4
|
-
hs_m3u8-0.1.7.dist-info/METADATA,sha256=w3wygMm94rIns6-RxhisG85tS7myzNSconLpH7sJPus,2205
|
|
5
|
-
hs_m3u8-0.1.7.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
6
|
-
hs_m3u8-0.1.7.dist-info/RECORD,,
|
|
File without changes
|