hs-m3u8 0.1.7__tar.gz → 0.1.8__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hs-m3u8
3
- Version: 0.1.7
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hs-m3u8"
3
- version = "0.1.7"
3
+ version = "0.1.8"
4
4
  description = "m3u8 下载器"
5
5
  authors = [
6
6
  { name = "昊色居士", email = "xhrtxh@gmail.com" }
@@ -29,6 +29,12 @@ dependencies = [
29
29
  "hssp>=0.4.18",
30
30
  ]
31
31
 
32
+ [dependency-groups]
33
+ dev = [
34
+ "pre-commit>=4.2.0",
35
+ "twine>=6.1.0",
36
+ ]
37
+
32
38
  [project.urls]
33
39
  homepage = "https://github.com/x-haose/hs-m3u8"
34
40
  repository = "https://github.com/x-haose/hs-m3u8"
@@ -48,10 +54,6 @@ packages = ["src/hs_m3u8"]
48
54
 
49
55
  [tool.bandit]
50
56
  skips = [
51
- "B404",
52
- "B602",
53
- "B501",
54
- "B113"
55
57
  ]
56
58
 
57
59
  [tool.ruff]
@@ -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 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
@@ -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
- :param key: 密钥
56
- :param iv: 偏移
30
+ Args:
31
+ key: 密钥
32
+ iv: 偏移,可以是十六进制字符串(0x开头)、bytes或None
57
33
  """
58
- 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
+
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
- decrypt=False,
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
- decrypt: 如果ts被加密,是否解密ts
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.decrypt = decrypt
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
- :param merge: ts下载完后是否合并,默认合并
158
- :param del_hls: 是否删除hls系列文件,包括.m3u8文件、*.ts、.key文件
159
- :return:
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 Path(mp4_path).exists():
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
- :return:
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
- :param url:
221
- :return:
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.mp4_head_hrl = prefix + m3u8_obj.segment_map[0].uri
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
- :param ts_item: ts 数据
285
- :return:
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
- if Path(ts_path).exists():
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.decrypt:
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
- :return:
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.mp4_head_hrl:
326
- 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)
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
- big_ts_file = big_ts_path.open("ab+")
333
- big_ts_file.write(mp4_head_data)
334
- for path in self.ts_path_list:
335
- with open(path, "rb") as ts_file:
336
- data = ts_file.read()
337
- if self.ts_key:
338
- data = crypto.decrypt_aes_256_cbc(data, self.ts_key.key, self.ts_key.iv)
339
- big_ts_file.write(data)
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
- ffmpeg_bin = get_ffmpeg()
345
- command = (
346
- f'{ffmpeg_bin} -i "{big_ts_path}" '
347
- f'-c copy -map 0:v -map 0:a? -bsf:a aac_adtstoasc -threads 32 "{mp4_path}" -y'
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
- if Path(mp4_path).exists():
355
- big_ts_path.unlink()
356
- return Path(mp4_path).exists()
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
- :param content: 内容
363
- :param filepath: 文件路径
364
- :return:
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
File without changes
File without changes
File without changes