hs-m3u8 0.1.2__tar.gz → 0.1.4__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,12 +1,12 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: hs-m3u8
3
- Version: 0.1.2
3
+ Version: 0.1.4
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
7
7
  Project-URL: documentation, https://github.com/x-haose/hs-m3u8
8
8
  Author-email: 昊色居士 <xhrtxh@gmail.com>
9
- License: MIT
9
+ License-Expression: MIT
10
10
  Classifier: Development Status :: 4 - Beta
11
11
  Classifier: Intended Audience :: Developers
12
12
  Classifier: License :: OSI Approved :: MIT License
@@ -36,6 +36,13 @@ m3u8 视频下载工具。支持大部分的m3u8视频下载。后续增加UI界
36
36
  - 可选择保留ts文件
37
37
  - 内置Windows平台ffmpeg可执行文件(由于Linux及Mac下权限问题,需自行安装ffmpeg文件)
38
38
 
39
+ ## 计划
40
+
41
+ - 增加cli功能,通过终端执行命令去下载
42
+ - 增加支持curl参数功能。直接在curl里面读取请求头及cookie
43
+ - 编写详细文档
44
+ - 选择一个合适的技术栈,增加UI界面
45
+
39
46
  ## 安装
40
47
 
41
48
  ### pip包安装
@@ -10,6 +10,13 @@ m3u8 视频下载工具。支持大部分的m3u8视频下载。后续增加UI界
10
10
  - 可选择保留ts文件
11
11
  - 内置Windows平台ffmpeg可执行文件(由于Linux及Mac下权限问题,需自行安装ffmpeg文件)
12
12
 
13
+ ## 计划
14
+
15
+ - 增加cli功能,通过终端执行命令去下载
16
+ - 增加支持curl参数功能。直接在curl里面读取请求头及cookie
17
+ - 编写详细文档
18
+ - 选择一个合适的技术栈,增加UI界面
19
+
13
20
  ## 安装
14
21
 
15
22
  ### pip包安装
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hs-m3u8"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "m3u8 下载器"
5
5
  authors = [
6
6
  { name = "昊色居士", email = "xhrtxh@gmail.com" }
@@ -42,6 +42,7 @@ build-backend = "hatchling.build"
42
42
  managed = true
43
43
  dev-dependencies = [
44
44
  "pre-commit>=4.0.1",
45
+ "twine>=6.0.1",
45
46
  ]
46
47
  include = [
47
48
  "src/hs_m3u8/"
@@ -136,8 +137,8 @@ unfixable = []
136
137
 
137
138
 
138
139
  [tool.rye.scripts]
139
- publish_testpypi = { cmd = "rye publish --repository testpypi --repository-url https://test.pypi.org/legacy/" }
140
- publish_pypi = { cmd = "rye publish" }
140
+ publish_testpypi = { cmd = "rye run twine upload -r testpypi dist/*" }
141
+ publish_pypi = { cmd = "rye run twine upload dist/*" }
141
142
  sb = { cmd = "rye build --clean" }
142
143
  spt = { chain = ["sb", "publish_testpypi"] }
143
144
  sp = { chain = ["sb", "publish_pypi"] }
@@ -46,6 +46,8 @@ datarecorder==3.6.2
46
46
  # via downloadkit
47
47
  distlib==0.3.9
48
48
  # via virtualenv
49
+ docutils==0.21.2
50
+ # via readme-renderer
49
51
  downloadkit==2.0.5
50
52
  # via drissionpage
51
53
  drissionpage==4.1.0.12
@@ -84,8 +86,16 @@ idna==3.10
84
86
  # via requests
85
87
  # via tldextract
86
88
  # via yarl
89
+ jaraco-classes==3.4.0
90
+ # via keyring
91
+ jaraco-context==6.0.1
92
+ # via keyring
93
+ jaraco-functools==4.1.0
94
+ # via keyring
87
95
  jmespath==1.0.1
88
96
  # via parsel
97
+ keyring==25.6.0
98
+ # via twine
89
99
  loguru==0.7.2
90
100
  # via hssp
91
101
  lxml==5.3.0
@@ -93,9 +103,18 @@ lxml==5.3.0
93
103
  # via parsel
94
104
  m3u8==6.0.0
95
105
  # via hs-m3u8
106
+ markdown-it-py==3.0.0
107
+ # via rich
108
+ mdurl==0.1.2
109
+ # via markdown-it-py
110
+ more-itertools==10.6.0
111
+ # via jaraco-classes
112
+ # via jaraco-functools
96
113
  multidict==6.1.0
97
114
  # via aiohttp
98
115
  # via yarl
116
+ nh3==0.2.20
117
+ # via readme-renderer
99
118
  nodeenv==1.9.1
100
119
  # via pre-commit
101
120
  openpyxl==3.1.5
@@ -104,8 +123,11 @@ orderedmultidict==1.0.1
104
123
  # via furl
105
124
  packaging==24.2
106
125
  # via parsel
126
+ # via twine
107
127
  parsel==1.9.1
108
128
  # via hssp
129
+ pkginfo==1.12.0
130
+ # via twine
109
131
  platformdirs==4.3.6
110
132
  # via virtualenv
111
133
  pre-commit==4.0.1
@@ -124,6 +146,9 @@ pydantic-core==2.23.4
124
146
  # via pydantic
125
147
  pydantic-settings==2.6.1
126
148
  # via hssp
149
+ pygments==2.19.1
150
+ # via readme-renderer
151
+ # via rich
127
152
  python-dotenv==1.0.1
128
153
  # via pydantic-settings
129
154
  pytz==2024.2
@@ -131,14 +156,24 @@ pytz==2024.2
131
156
  pyyaml==6.0.2
132
157
  # via pre-commit
133
158
  # via pydantic-settings
159
+ readme-renderer==44.0
160
+ # via twine
134
161
  requests==2.32.3
135
162
  # via downloadkit
136
163
  # via drissionpage
137
164
  # via hssp
138
165
  # via requests-file
166
+ # via requests-toolbelt
139
167
  # via tldextract
168
+ # via twine
140
169
  requests-file==2.1.0
141
170
  # via tldextract
171
+ requests-toolbelt==1.0.0
172
+ # via twine
173
+ rfc3986==2.0.0
174
+ # via twine
175
+ rich==13.9.4
176
+ # via twine
142
177
  six==1.16.0
143
178
  # via apscheduler
144
179
  # via furl
@@ -152,6 +187,7 @@ tldextract==5.1.3
152
187
  # via drissionpage
153
188
  tomli==2.1.0
154
189
  # via pydantic-settings
190
+ twine==6.0.1
155
191
  typing-extensions==4.12.2
156
192
  # via curl-cffi
157
193
  # via pydantic
@@ -160,6 +196,7 @@ tzlocal==5.2
160
196
  # via apscheduler
161
197
  urllib3==2.2.3
162
198
  # via requests
199
+ # via twine
163
200
  uvloop==0.21.0
164
201
  # via hssp
165
202
  virtualenv==20.27.1
@@ -0,0 +1,41 @@
1
+ import asyncio
2
+
3
+ from hs_m3u8 import M3u8Downloader, M3u8Key
4
+
5
+
6
+ async def main():
7
+ url = "https://r1-ndr-private.ykt.cbern.com.cn/edu_product/esp/assets/68b6bed7-d093-7c8c-9133-95cf8205d21d.t/zh-CN/1712805650284/transcode/videos/68b6bed7-d093-7c8c-9133-95cf8205d21d-1920x1080-true-47fe81c5c8d91daf25e9fffd7082f934-eed6db5a85074b6dbbe5fa71f1243b26.m3u8"
8
+ name = "ykt"
9
+ headers = {
10
+ "Accept": "*/*",
11
+ "Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7",
12
+ "Cache-Control": "no-cache",
13
+ "Connection": "keep-alive",
14
+ "Origin": "https://basic.smartedu.cn",
15
+ "Pragma": "no-cache",
16
+ "Referer": "https://basic.smartedu.cn/",
17
+ "Sec-Fetch-Dest": "empty",
18
+ "Sec-Fetch-Mode": "cors",
19
+ "Sec-Fetch-Site": "cross-site",
20
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
21
+ "Chrome/126.0.0.0 Safari/537.36",
22
+ "X-ND-AUTH": 'MAC id="7F938B205F876FC3C7550081F114A1A4028222C3BFB978FD9B439192D004CB8EEB65E66BB'
23
+ 'C63E66FED6DD51F34F99411A6039E623E9A9D05",nonce="1742462574752:Z4IGAAV6"'
24
+ ',mac="EUr56dXrCO1YGd3Ub1fj9MyJY9NxQPi7ZI14N/GFwpQ="',
25
+ "sec-ch-ua": '"Not/A)Brand";v="8", "Chromium";v="126", "Google Chrome";v="126"',
26
+ "sec-ch-ua-mobile": "?0",
27
+ "sec-ch-ua-platform": '"Windows"',
28
+ }
29
+ key = M3u8Key(key=bytes.fromhex("34623235336163353939353834643437"))
30
+ dl = M3u8Downloader(
31
+ m3u8_url=url,
32
+ save_path=f"../../downloads/{name}",
33
+ max_workers=64,
34
+ headers=headers,
35
+ key=key,
36
+ )
37
+ await dl.run(del_hls=False, merge=True)
38
+
39
+
40
+ if __name__ == "__main__":
41
+ asyncio.run(main())
@@ -0,0 +1,14 @@
1
+ import asyncio
2
+
3
+ from hs_m3u8 import M3u8Downloader
4
+
5
+
6
+ async def main():
7
+ url = "http://1251107588.vod2.myqcloud.com/40f34e4dvodtransgzp1251107588/722ec94f387702303749471522/voddrm.token.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9~eyJ0eXBlIjoiRHJtVG9rZW4iLCJhcHBJZCI6MTI1MTEwNzU4OCwiZmlsZUlkIjoiMzg3NzAyMzAzNzQ5NDcxNTIyIiwiY3VycmVudFRpbWVTdGFtcCI6MCwiZXhwaXJlVGltZVN0YW1wIjoyMTQ3NDgzNjQ3LCJyYW5kb20iOjAsIm92ZXJsYXlLZXkiOiIiLCJvdmVybGF5SXYiOiIiLCJjaXBoZXJlZE92ZXJsYXlLZXkiOiI0NTQ2NzliYjY5Zjg1N2M3NGZjY2YxOGM1ZTk4ZjQyMjk4YTMxMzhkYjgyMTNkZThmYjA4ZDM2MzY0MjQ0NjQ3OWFhYmZlYjk0MmYzZTE1MWNkZjM2OGQ5NGEwMzhlNjI4YjlmMTM1OGM4ODkwMjlkMzcwMjZiYzQ3MTY0MGViMWI5ODExYTg5MWU1ZmYxODk3MjliNGIyOWU4ZWExZjNkZWNkZWJmYTVjZmY0N2U4YzBjYjU4OGE2MWUxZmMzNzNmZWQxYWZhOGU5MmJmMGQ4YjFmNjIwMjM4YWJjMzYyM2FlYWVjYjlkNDI3MmI2ZmMzMmRmNjBlN2VmYjc1NjkzIiwiY2lwaGVyZWRPdmVybGF5SXYiOiIwMTYxOTE2YmI5NWRjOWJlZDgyNmI5NmE0MTY1OThkM2IyZmYyYzJmM2JhZDVmNzg2NDEyMWFlYjJiMWVmNjQwNzg0NmNmZmI5YjkyN2E1YzFlMTFhMGE0MDNlMzg5MWE4Y2VkMTJhMTYyNWRlZDFlYWYwMmJkNTI2ZGFjZWE2OGRjNjc1NTE5ZmE3MjBhYTcxNGU3MGE2MjdhM2IwM2I0YzFjMWJjNjJmODYxMDAyN2M3ZjhlYzc4MTg1MDhlNDc0YTJiZmM3NTZjOTVmMWY3NGMxYWQ5NTkyNjBhZTM0NDczNDA4OWZlMGY5YWIzZmYwYzQxZTFlNGNjYWE4YjcxIiwia2V5SWQiOjEsInN0cmljdE1vZGUiOjAsInBlcnNpc3RlbnQiOiIiLCJyZW50YWxEdXJhdGlvbiI6MCwiZm9yY2VMMVRyYWNrVHlwZXMiOm51bGx9~OvCFF2x41XbwQK_C1fUE6Orr77BeCttlt6-H5Uj8izg.video_1407500_1.m3u8?encdomain=cmv1&sign=b574e853145eeb93fcec3b26d28b9665&t=7fffffff"
8
+ name = "siyuanren"
9
+ dl = M3u8Downloader(m3u8_url=url, save_path=f"../../downloads/{name}", max_workers=64)
10
+ await dl.run(del_hls=False, merge=True)
11
+
12
+
13
+ if __name__ == "__main__":
14
+ asyncio.run(main())
@@ -0,0 +1,16 @@
1
+ import asyncio
2
+
3
+ from hs_m3u8 import M3u8Downloader
4
+
5
+
6
+ async def main():
7
+ url = (
8
+ "https://video.twimg.com/ext_tw_video/1879556885663342592/pu/pl/Vcvv0UK9lOhezJt1.m3u8?variant_version=1&tag=12"
9
+ )
10
+ name = "x"
11
+ dl = M3u8Downloader(m3u8_url=url, save_path=f"../../downloads/{name}", max_workers=64)
12
+ await dl.run(del_hls=False, merge=True)
13
+
14
+
15
+ if __name__ == "__main__":
16
+ asyncio.run(main())
@@ -0,0 +1,3 @@
1
+ from hs_m3u8.main import M3u8Downloader, M3u8Key
2
+
3
+ __version__ = "0.1.4"
@@ -8,10 +8,8 @@ import posixpath
8
8
  import shutil
9
9
  import subprocess
10
10
  from collections.abc import Callable
11
- from enum import Enum, auto
12
11
  from hashlib import md5
13
12
  from pathlib import Path
14
- from typing import Any
15
13
  from urllib.parse import urljoin, urlparse
16
14
  from zipfile import ZipFile
17
15
 
@@ -46,16 +44,6 @@ def get_ffmpeg():
46
44
  return ffmpeg_bin
47
45
 
48
46
 
49
- class ContentType(Enum):
50
- """
51
- 获取URL数据的,类型枚举
52
- """
53
-
54
- Text = auto()
55
- Json = auto()
56
- Bytes = auto()
57
-
58
-
59
47
  class M3u8Key:
60
48
  """
61
49
  M3u8key
@@ -66,8 +54,9 @@ class M3u8Key:
66
54
  :param key: 密钥
67
55
  :param iv: 偏移
68
56
  """
57
+ iv = bytes.fromhex(iv[2:]) if iv and iv.startswith("0x") else iv
69
58
  self.key = key
70
- self.iv = iv or key
59
+ self.iv = iv
71
60
 
72
61
 
73
62
  class M3u8Downloader:
@@ -80,6 +69,7 @@ class M3u8Downloader:
80
69
  ts_url_list: list = []
81
70
  ts_path_list: list = []
82
71
  ts_key: M3u8Key = None
72
+ mp4_head_hrl: str = None
83
73
  m3u8_md5 = ""
84
74
 
85
75
  def __init__(
@@ -89,6 +79,7 @@ class M3u8Downloader:
89
79
  decrypt=False,
90
80
  max_workers=None,
91
81
  headers=None,
82
+ key: M3u8Key = None,
92
83
  get_m3u8_func: Callable = None,
93
84
  ):
94
85
  """
@@ -112,6 +103,7 @@ class M3u8Downloader:
112
103
  self.save_dir = Path(save_path) / "hls"
113
104
  self.save_name = Path(save_path).name
114
105
  self.key_path = self.save_dir / "key.key"
106
+ self.custom_key = key
115
107
 
116
108
  if not self.save_dir.exists():
117
109
  self.save_dir.mkdir(parents=True)
@@ -165,7 +157,7 @@ class M3u8Downloader:
165
157
  if not merge:
166
158
  return True
167
159
 
168
- if self.merge():
160
+ if await self.merge():
169
161
  self.logger.info("合并成功")
170
162
  else:
171
163
  self.logger.error(
@@ -185,30 +177,6 @@ class M3u8Downloader:
185
177
  self.ts_path_list = [None] * len(self.ts_url_list)
186
178
  await asyncio.gather(*[self._download_ts(url) for url in self.ts_url_list])
187
179
 
188
- async def get_url_content(self, url: str, content_type: ContentType) -> bytes | str | Any:
189
- """
190
- 按照类型获取url内容
191
- :param url: 请求地址
192
- :param content_type: 内容类型
193
- :return:
194
- """
195
- data = None
196
- try:
197
- resp = await self.net.get(url, headers=self.headers)
198
- if content_type == ContentType.Bytes:
199
- data = resp.content
200
- if content_type == ContentType.Text:
201
- data = resp.text
202
- if content_type == ContentType.Json:
203
- data = resp.json
204
- if resp.status_code != 200:
205
- self.logger.error(f"请求{url}内容时返回码不正确,类型为:{content_type}, 返回码为:{resp.status_code}")
206
- return None
207
- except BaseException as exception:
208
- self.logger.error(f"请求{url}内容时发生异常,类型为:{content_type}, 异常信息为:{exception}")
209
-
210
- return data
211
-
212
180
  async def get_ts_list(self, url) -> list[dict]:
213
181
  """
214
182
  解析m3u8并保存至列表
@@ -235,6 +203,14 @@ class M3u8Downloader:
235
203
  self.logger.info(f"选择的播放地址:{play_url},比特率:{bandwidth}")
236
204
  return await self.get_ts_list(urlparse(play_url))
237
205
 
206
+ # 处理具有 #EXT-X-MAP:URI="*.mp4" 的情况
207
+ segment_map_count = len(m3u8_obj.segment_map)
208
+ if segment_map_count > 0:
209
+ if segment_map_count > 1:
210
+ raise ValueError("暂不支持segment_map有多个的情况,请提交issues,并告知m3u8的地址,方便做适配")
211
+ self.mp4_head_hrl = prefix + m3u8_obj.segment_map[0].uri
212
+ m3u8_obj.segment_map[0].uri = "head.mp4"
213
+
238
214
  # 遍历ts文件
239
215
  for index, segments in enumerate(m3u8_obj.segments):
240
216
  ts_uri = segments.uri if "http" in m3u8_obj.segments[index].uri else segments.absolute_uri
@@ -243,10 +219,16 @@ class M3u8Downloader:
243
219
 
244
220
  # 保存解密key
245
221
  if len(m3u8_obj.keys) > 0 and m3u8_obj.keys[0]:
246
- resp = await self.net.get(m3u8_obj.keys[0].absolute_uri, headers=self.headers)
247
- key_data = resp.content
222
+ iv = m3u8_obj.keys[0].iv
223
+ if not self.custom_key:
224
+ resp = await self.net.get(m3u8_obj.keys[0].absolute_uri, headers=self.headers)
225
+ key_data = resp.content
226
+ else:
227
+ key_data = self.custom_key.key
228
+ iv = self.custom_key.iv or iv
229
+
248
230
  self.save_file(key_data, self.key_path)
249
- self.ts_key = M3u8Key(key=key_data, iv=m3u8_obj.keys[0].iv)
231
+ self.ts_key = M3u8Key(key=key_data, iv=iv)
250
232
  key = m3u8_obj.segments[0].key
251
233
  key.uri = "key.key"
252
234
  m3u8_obj.segments[0].key = key
@@ -271,7 +253,7 @@ class M3u8Downloader:
271
253
  if Path(ts_path).exists():
272
254
  self.ts_path_list[index] = str(ts_path)
273
255
  return
274
- resp = await self.net.get(ts_item["uri"])
256
+ resp = await self.net.get(ts_item["uri"], self.headers)
275
257
  ts_content = resp.content
276
258
  if ts_content is None:
277
259
  return
@@ -283,7 +265,7 @@ class M3u8Downloader:
283
265
  self.logger.info(f"{ts_uri}下载成功")
284
266
  self.ts_path_list[index] = str(ts_path)
285
267
 
286
- def merge(self):
268
+ async def merge(self):
287
269
  """
288
270
  合并ts文件为mp4文件
289
271
  :return:
@@ -301,8 +283,17 @@ class M3u8Downloader:
301
283
  # mp4路径
302
284
  mp4_path = self.save_dir.parent / f"{self.save_name}.mp4"
303
285
 
286
+ # 如果保护mp4的头,则把ts放到后面
287
+ mp4_head_data = b""
288
+ if self.mp4_head_hrl:
289
+ resp = await self.net.get(self.mp4_head_hrl)
290
+ mp4_head_data = resp.content
291
+ mp4_head_file = self.save_dir / "head.mp4"
292
+ mp4_head_file.write_bytes(mp4_head_data)
293
+
304
294
  # 把ts文件整合到一起
305
295
  big_ts_file = big_ts_path.open("ab+")
296
+ big_ts_file.write(mp4_head_data)
306
297
  for path in self.ts_path_list:
307
298
  with open(path, "rb") as ts_file:
308
299
  data = ts_file.read()
@@ -316,7 +307,7 @@ class M3u8Downloader:
316
307
  ffmpeg_bin = get_ffmpeg()
317
308
  command = (
318
309
  f'{ffmpeg_bin} -i "{big_ts_path}" '
319
- f'-c copy -map 0:v -map 0:a -bsf:a aac_adtstoasc -threads 32 "{mp4_path}" -y'
310
+ f'-c copy -map 0:v -map 0:a? -bsf:a aac_adtstoasc -threads 32 "{mp4_path}" -y'
320
311
  )
321
312
  self.logger.info(f"ts整合成功,开始转为mp4。 command:{command}")
322
313
  result = subprocess.run(command, shell=True, capture_output=True, text=True)
@@ -1,3 +0,0 @@
1
- from hs_m3u8.main import M3u8Downloader
2
-
3
- __version__ = "0.1.2"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes