hs-m3u8 0.1.6__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,58 +3,45 @@ 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 urllib.parse import urljoin, urlparse
15
- from zipfile import ZipFile
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
18
+ from hssp.network.response import Response
20
19
  from hssp.utils import crypto
21
20
  from loguru import logger
22
21
 
23
22
 
24
- def get_ffmpeg():
25
- """
26
- 根据平台不同获取不同的ffmpeg可执行文件
27
- :return: FFmpeg 的可执行文件路径
28
- """
29
- current_os = platform.system()
30
- if current_os != "Windows":
31
- return "ffmpeg"
32
-
33
- with resources.as_file(resources.files("hs_m3u8.res")) as package_dir:
34
- ffmpeg_bin = package_dir / "ffmpeg_win.exe"
35
- if ffmpeg_bin.exists():
36
- return str(ffmpeg_bin)
37
-
38
- # ZIP 文件
39
- ffmpeg_bin_zip = package_dir / "ffmpeg_win.exe.zip"
40
- if ffmpeg_bin_zip.exists():
41
- with ZipFile(ffmpeg_bin_zip, "r") as zip_ref:
42
- zip_ref.extractall(ffmpeg_bin.parent)
43
-
44
- return ffmpeg_bin
45
-
46
-
47
23
  class M3u8Key:
48
24
  """
49
25
  M3u8key
50
26
  """
51
27
 
52
- def __init__(self, key: bytes, iv: str = None):
28
+ def __init__(self, key: bytes, iv: str | bytes | None = None):
53
29
  """
54
- :param key: 密钥
55
- :param iv: 偏移
30
+ Args:
31
+ key: 密钥
32
+ iv: 偏移,可以是十六进制字符串(0x开头)、bytes或None
56
33
  """
57
- iv = bytes.fromhex(iv[2:]) if iv and iv.startswith("0x") else iv
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
+
58
45
  self.key = key
59
46
  self.iv = iv
60
47
 
@@ -64,40 +51,38 @@ class M3u8Downloader:
64
51
  M3u8 异步下载器,并保留hls文件
65
52
  """
66
53
 
67
- retry_count: int = 0
68
- retry_max_count: int = 50
69
- ts_url_list: list = []
70
- ts_path_list: list = []
71
- ts_key: M3u8Key = None
72
- mp4_head_hrl: str = None
73
- m3u8_md5 = ""
74
-
75
54
  def __init__(
76
55
  self,
77
56
  m3u8_url: str,
78
57
  save_path: str,
79
- decrypt=False,
80
- max_workers=None,
81
- headers=None,
82
- key: M3u8Key = None,
83
- get_m3u8_func: Callable = None,
84
- m3u8_request_before: Callable[[RequestModel], RequestModel] = None,
85
- key_request_before: Callable[[RequestModel], RequestModel] = None,
86
- ts_request_before: Callable[[RequestModel], RequestModel] = 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,
87
69
  ):
88
70
  """
89
71
 
90
72
  Args:
91
73
  m3u8_url: m3u8 地址
92
74
  save_path: 保存路径
93
- decrypt: 如果ts被加密,是否解密ts
75
+ is_decrypt: 如果ts被加密,是否解密ts
94
76
  max_workers: 最大并发数
95
77
  headers: 情求头
96
78
  get_m3u8_func: 处理m3u8情求的回调函数。适用于m3u8地址不是真正的地址,
97
79
  而是包含m3u8内容的情求,会把m3u8_url的响应传递给get_m3u8_func,要求返回真正的m3u8内容
98
80
  m3u8_request_before: m3u8请求前的回调函数
81
+ m3u8_response_after: m3u8响应后的回调函数
99
82
  key_request_before: key请求前的回调函数
100
- ts_request_before: ts 请求前的回调函数
83
+ key_response_after: key响应后的回调函数
84
+ ts_request_before: ts请求前的回调函数
85
+ ts_response_after: ts响应后的回调函数
101
86
  """
102
87
 
103
88
  sem = asyncio.Semaphore(max_workers) if max_workers else None
@@ -107,18 +92,24 @@ class M3u8Downloader:
107
92
  self.m3u8_net = Net(sem=sem)
108
93
  if m3u8_request_before:
109
94
  self.m3u8_net.request_before_signal.connect(m3u8_request_before)
95
+ if m3u8_response_after:
96
+ self.m3u8_net.response_after_signal.connect(m3u8_response_after)
110
97
 
111
98
  # 加密key的请求器
112
99
  self.key_net = Net()
113
100
  if key_request_before:
114
101
  self.key_net.request_before_signal.connect(key_request_before)
102
+ if key_response_after:
103
+ self.key_net.response_after_signal.connect(key_response_after)
115
104
 
116
105
  # ts内容的请求器
117
106
  self.ts_net = Net()
118
107
  if ts_request_before:
119
108
  self.ts_net.request_before_signal.connect(ts_request_before)
109
+ if ts_response_after:
110
+ self.ts_net.response_after_signal.connect(ts_response_after)
120
111
 
121
- self.decrypt = decrypt
112
+ self.is_decrypt = is_decrypt
122
113
  self.m3u8_url = urlparse(m3u8_url)
123
114
  self.get_m3u8_func = get_m3u8_func
124
115
  self.save_dir = Path(save_path) / "hls"
@@ -126,6 +117,15 @@ class M3u8Downloader:
126
117
  self.key_path = self.save_dir / "key.key"
127
118
  self.custom_key = key
128
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
+
129
129
  if not self.save_dir.exists():
130
130
  self.save_dir.mkdir(parents=True)
131
131
 
@@ -141,21 +141,25 @@ class M3u8Downloader:
141
141
  async def start(self, merge=True, del_hls=False):
142
142
  """
143
143
  下载器启动函数
144
- :param merge: ts下载完后是否合并,默认合并
145
- :param del_hls: 是否删除hls系列文件,包括.m3u8文件、*.ts、.key文件
146
- :return:
144
+ Args:
145
+ merge: ts下载完后是否合并,默认合并
146
+ del_hls: 是否删除hls系列文件,包括.m3u8文件、*.ts、.key文件
147
+
148
+ Returns:
149
+
147
150
  """
148
151
  mp4_path = self.save_dir.parent / f"{self.save_name}.mp4"
149
- if Path(mp4_path).exists():
152
+ mp4_path = mp4_path.absolute()
153
+ if mp4_path.exists():
150
154
  self.logger.info(f"{mp4_path}已存在")
151
- if del_hls:
155
+ if del_hls and self.save_dir.exists():
152
156
  shutil.rmtree(str(self.save_dir))
153
157
  return True
154
158
 
155
159
  self.logger.info(
156
160
  f"开始下载: 合并ts为mp4={merge}, "
157
161
  f"删除hls信息={del_hls}, "
158
- f"下载地址为:{self.m3u8_url.geturl()}. 保存路径为:{self.save_dir}"
162
+ f"下载地址为:{self.m3u8_url.geturl()}. 保存路径为:{self.save_dir.absolute()}"
159
163
  )
160
164
 
161
165
  await self._download()
@@ -165,7 +169,7 @@ class M3u8Downloader:
165
169
  self.logger.info(f"TS应下载数量为:{count_1}, 实际下载数量为:{count_2}")
166
170
  if count_1 == 0 or count_2 == 0:
167
171
  self.logger.error("ts数量为0,请检查!!!")
168
- return
172
+ return None
169
173
 
170
174
  if count_2 != count_1:
171
175
  self.logger.error(f"ts下载数量与实际数量不符合!!!应该下载数量为:{count_1}, 实际下载数量为:{count_2}")
@@ -174,7 +178,7 @@ class M3u8Downloader:
174
178
  if self.retry_count < self.retry_max_count:
175
179
  self.retry_count += 1
176
180
  self.logger.error(f"正在进行重试:{self.retry_count}/{self.retry_max_count}")
177
- return self.start(merge, del_hls)
181
+ return await self.start(merge, del_hls)
178
182
  return False
179
183
 
180
184
  if not merge:
@@ -187,24 +191,30 @@ class M3u8Downloader:
187
191
  f"mp4合并失败. ts应该下载数量为:{count_1}, 实际下载数量为:{count_2}. 保存路径为:{self.save_dir}"
188
192
  )
189
193
  return False
194
+
190
195
  if del_hls:
191
196
  shutil.rmtree(str(self.save_dir))
197
+
192
198
  return True
193
199
 
194
200
  async def _download(self):
195
201
  """
196
202
  下载ts文件、m3u8文件、key文件
197
- :return:
203
+ Returns:
204
+
198
205
  """
199
206
  self.ts_url_list = await self.get_ts_list(self.m3u8_url)
200
207
  self.ts_path_list = [None] * len(self.ts_url_list)
201
208
  await asyncio.gather(*[self._download_ts(url) for url in self.ts_url_list])
202
209
 
203
- async def get_ts_list(self, url) -> list[dict]:
210
+ async def get_ts_list(self, url: ParseResult) -> list[dict]:
204
211
  """
205
212
  解析m3u8并保存至列表
206
- :param url:
207
- :return:
213
+ Args:
214
+ url: m3u8地址
215
+
216
+ Returns:
217
+
208
218
  """
209
219
  resp = await self.m3u8_net.get(url.geturl(), headers=self.headers)
210
220
  m3u8_text = self.get_m3u8_func(resp.text) if self.get_m3u8_func else resp.text
@@ -231,7 +241,7 @@ class M3u8Downloader:
231
241
  if segment_map_count > 0:
232
242
  if segment_map_count > 1:
233
243
  raise ValueError("暂不支持segment_map有多个的情况,请提交issues,并告知m3u8的地址,方便做适配")
234
- self.mp4_head_hrl = prefix + m3u8_obj.segment_map[0].uri
244
+ self.mp4_head_url = prefix + m3u8_obj.segment_map[0].uri
235
245
  m3u8_obj.segment_map[0].uri = "head.mp4"
236
246
 
237
247
  # 遍历ts文件
@@ -267,32 +277,40 @@ class M3u8Downloader:
267
277
  async def _download_ts(self, ts_item: dict):
268
278
  """
269
279
  下载ts
270
- :param ts_item: ts 数据
271
- :return:
280
+ Args:
281
+ ts_item: ts数据
282
+
283
+ Returns:
284
+
272
285
  """
273
286
  index = ts_item["index"]
274
287
  ts_uri = ts_item["uri"]
275
288
  ts_path = self.save_dir / f"{index}.ts"
276
- if Path(ts_path).exists():
289
+
290
+ if ts_path.exists():
277
291
  self.ts_path_list[index] = str(ts_path)
278
292
  return
293
+
279
294
  resp = await self.ts_net.get(ts_item["uri"], self.headers)
280
295
  ts_content = resp.content
281
296
  if ts_content is None:
282
297
  return
283
298
 
284
- if self.ts_key and self.decrypt:
285
- ts_content = crypto.decrypt_aes_256_cbc_pad7(ts_content, self.ts_key.key, self.ts_key.iv)
299
+ if self.ts_key and self.is_decrypt:
300
+ ts_content = crypto.decrypt_aes_256_cbc(ts_content, self.ts_key.key, self.ts_key.iv)
286
301
 
287
302
  self.save_file(ts_content, ts_path)
288
303
  self.logger.info(f"{ts_uri}下载成功")
289
304
  self.ts_path_list[index] = str(ts_path)
290
305
 
291
- async def merge(self):
306
+ async def merge(self) -> bool:
292
307
  """
293
308
  合并ts文件为mp4文件
294
- :return:
309
+
310
+ Returns:
311
+ 返回是否合并成功
295
312
  """
313
+
296
314
  self.logger.info("开始合并mp4")
297
315
  if len(self.ts_path_list) != len(self.ts_url_list):
298
316
  self.logger.error("数量不足拒绝合并!")
@@ -308,47 +326,99 @@ class M3u8Downloader:
308
326
 
309
327
  # 如果有mp4的头,则把ts放到后面
310
328
  mp4_head_data = b""
311
- if self.mp4_head_hrl:
312
- resp = await self.ts_net.get(self.mp4_head_hrl)
329
+ if self.mp4_head_url:
330
+ resp = await self.ts_net.get(self.mp4_head_url)
313
331
  mp4_head_data = resp.content
314
332
  mp4_head_file = self.save_dir / "head.mp4"
315
333
  mp4_head_file.write_bytes(mp4_head_data)
316
334
 
317
335
  # 把ts文件整合到一起
318
- big_ts_file = big_ts_path.open("ab+")
319
- big_ts_file.write(mp4_head_data)
320
- for path in self.ts_path_list:
321
- with open(path, "rb") as ts_file:
322
- data = ts_file.read()
323
- if self.ts_key:
324
- data = crypto.decrypt_aes_256_cbc_pad7(data, self.ts_key.key, self.ts_key.iv)
325
- big_ts_file.write(data)
326
- 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)
327
344
  self.logger.info("ts文件整合完毕")
328
345
 
329
346
  # 把大的ts文件转换成mp4文件
330
- ffmpeg_bin = get_ffmpeg()
331
- command = (
332
- f'{ffmpeg_bin} -i "{big_ts_path}" '
333
- f'-c copy -map 0:v -map 0:a? -bsf:a aac_adtstoasc -threads 32 "{mp4_path}" -y'
334
- )
335
- self.logger.info(f"ts整合成功,开始转为mp4。 command:{command}")
336
- result = subprocess.run(command, shell=True, capture_output=True, text=True)
337
- if result.returncode != 0:
338
- 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
339
352
 
340
- if Path(mp4_path).exists():
341
- big_ts_path.unlink()
342
- return Path(mp4_path).exists()
353
+ # 删除完整ts文件
354
+ big_ts_path.unlink()
355
+ return True
343
356
 
344
357
  @staticmethod
345
- def save_file(content: bytes | str, filepath):
358
+ def save_file(content: bytes | str, filepath: Path | str):
346
359
  """
347
360
  保存内容到文件
348
- :param content: 内容
349
- :param filepath: 文件路径
350
- :return:
361
+ Args:
362
+ content: 内容
363
+ filepath: 文件路径
364
+
365
+ Returns:
366
+
351
367
  """
352
368
  mode = "wb" if isinstance(content, bytes) else "w"
353
369
  with open(file=filepath, mode=mode) as file:
354
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hs-m3u8
3
- Version: 0.1.6
3
+ Version: 0.1.8
4
4
  Summary: m3u8 下载器
5
5
  Project-URL: homepage, https://github.com/x-haose/hs-m3u8
6
6
  Project-URL: repository, https://github.com/x-haose/hs-m3u8
@@ -20,7 +20,7 @@ Classifier: Programming Language :: Python :: 3.12
20
20
  Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
22
  Requires-Python: >=3.10
23
- Requires-Dist: hssp>=0.4.4
23
+ Requires-Dist: hssp>=0.4.18
24
24
  Requires-Dist: m3u8>=6.0.0
25
25
  Description-Content-Type: text/markdown
26
26
 
@@ -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,,
Binary file
@@ -1,6 +0,0 @@
1
- hs_m3u8/__init__.py,sha256=_AhYioHAgwPXE10FXGZ3ZKj1urwFYR0L9xzBn8pQPJw,72
2
- hs_m3u8/main.py,sha256=dickli-bmyAoNB7N3pmx2UU_VUDGSISU913BB-VwMbk,12620
3
- hs_m3u8/res/ffmpeg_win.exe.zip,sha256=x_7Fa9N3hzN1d7Ph9ZwOiwpuRfLEVnhNL8tPjuZZMe0,60131319
4
- hs_m3u8-0.1.6.dist-info/METADATA,sha256=ujcGSSJnM5o_uWCgXDnfmweC0t4iWwqqCaMNvticSzI,2204
5
- hs_m3u8-0.1.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
6
- hs_m3u8-0.1.6.dist-info/RECORD,,