hs-m3u8 0.1.0a1__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.
@@ -0,0 +1,25 @@
1
+ # python generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # venv
10
+ .venv
11
+
12
+ # rye
13
+ .python-version
14
+
15
+ # downloiad
16
+ downloads
17
+
18
+ # pycharm
19
+ .idea
20
+
21
+ # res
22
+ res/ffmpeg_win.exe
23
+
24
+ # Mac
25
+ .DS_Store
@@ -0,0 +1,18 @@
1
+ # See https://pre-commit.com for more information
2
+ # See https://pre-commit.com/hooks.html for more hooks
3
+ repos:
4
+ # bandit
5
+ - repo: https://github.com/PyCQA/bandit
6
+ rev: 1.7.10
7
+ hooks:
8
+ - id: bandit
9
+ args: [ "-c", "pyproject.toml" ]
10
+ additional_dependencies: [ "bandit[toml]" ]
11
+
12
+ # ruff
13
+ - repo: https://github.com/astral-sh/ruff-pre-commit
14
+ rev: 'v0.6.9'
15
+ hooks:
16
+ - id: ruff
17
+ args: [ --fix, --exit-non-zero-on-fix, --show-fixes ]
18
+ - id: ruff-format
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.3
2
+ Name: hs-m3u8
3
+ Version: 0.1.0a1
4
+ Summary: m3u8 下载器
5
+ Author-email: 昊色居士 <xhrtxh@gmail.com>
6
+ License: MIT
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: MacOS :: MacOS X
11
+ Classifier: Operating System :: Microsoft :: Windows
12
+ Classifier: Operating System :: POSIX :: BSD
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: hssp>=0.4.4
21
+ Requires-Dist: m3u8>=6.0.0
22
+ Description-Content-Type: text/markdown
23
+
24
+ # hs-m3u8
25
+
26
+ m3u8 视频下载工具。支持大部分的m3u8视频下载。后续增加UI界面。
27
+
28
+ ## 功能
29
+
30
+ - aes解密
31
+ - 自动选择高分辨m3u8
32
+ - 合并MP4
33
+ - 可选择保留ts文件
34
+ - 内置Windows平台ffmpeg可执行文件(由于Linux及Mac下权限问题,需自行安装ffmpeg文件)
35
+
36
+ ## 使用
37
+
38
+ ```python
39
+ url = "https://surrit.com/6d3bb2b2-d707-4b79-adf0-89542cb1383c/playlist.m3u8"
40
+ name = "SDAB-129"
41
+ dl = M3u8Downloader(
42
+ url=url,
43
+ save_path=f"downloads/{name}",
44
+ max_workers=64
45
+ )
46
+ await dl.run(del_hls=False, merge=True)
47
+ ```
48
+
49
+ - del_hls 为True时会删除ts、m3u8、key等文件,否则会经过处理后保留,以便直接使用
50
+ - merge 为True时会自动合并为mp4
51
+
52
+ ## 安装
53
+
54
+ ### rye 安装
55
+
56
+ ```bash
57
+ rye sync
58
+ ```
59
+
60
+ ### pip 安装
61
+ 该`requirements.lock`文件是在Mac环境在生成的,不同系统环境下可能会遇到不同的效果,如果使用请使用`rye`安装
62
+
63
+ ```bash
64
+ pip install -r requirements.lock
65
+ ```
@@ -0,0 +1,42 @@
1
+ # hs-m3u8
2
+
3
+ m3u8 视频下载工具。支持大部分的m3u8视频下载。后续增加UI界面。
4
+
5
+ ## 功能
6
+
7
+ - aes解密
8
+ - 自动选择高分辨m3u8
9
+ - 合并MP4
10
+ - 可选择保留ts文件
11
+ - 内置Windows平台ffmpeg可执行文件(由于Linux及Mac下权限问题,需自行安装ffmpeg文件)
12
+
13
+ ## 使用
14
+
15
+ ```python
16
+ url = "https://surrit.com/6d3bb2b2-d707-4b79-adf0-89542cb1383c/playlist.m3u8"
17
+ name = "SDAB-129"
18
+ dl = M3u8Downloader(
19
+ url=url,
20
+ save_path=f"downloads/{name}",
21
+ max_workers=64
22
+ )
23
+ await dl.run(del_hls=False, merge=True)
24
+ ```
25
+
26
+ - del_hls 为True时会删除ts、m3u8、key等文件,否则会经过处理后保留,以便直接使用
27
+ - merge 为True时会自动合并为mp4
28
+
29
+ ## 安装
30
+
31
+ ### rye 安装
32
+
33
+ ```bash
34
+ rye sync
35
+ ```
36
+
37
+ ### pip 安装
38
+ 该`requirements.lock`文件是在Mac环境在生成的,不同系统环境下可能会遇到不同的效果,如果使用请使用`rye`安装
39
+
40
+ ```bash
41
+ pip install -r requirements.lock
42
+ ```
@@ -0,0 +1,140 @@
1
+ [project]
2
+ name = "hs-m3u8"
3
+ version = "0.1.0a1"
4
+ description = "m3u8 下载器"
5
+ authors = [
6
+ { name = "昊色居士", email = "xhrtxh@gmail.com" }
7
+ ]
8
+ classifiers = [
9
+ "Intended Audience :: Developers",
10
+ "Development Status :: 4 - Beta",
11
+ "Operating System :: POSIX :: Linux",
12
+ "Operating System :: MacOS :: MacOS X",
13
+ "Operating System :: POSIX :: BSD",
14
+ "Operating System :: Microsoft :: Windows",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Topic :: Software Development :: Libraries :: Python Modules",
21
+ ]
22
+
23
+ license = "MIT"
24
+ readme = "README.md"
25
+ requires-python = ">= 3.10"
26
+
27
+ dependencies = [
28
+ "m3u8>=6.0.0",
29
+ "hssp>=0.4.4",
30
+ ]
31
+
32
+ [build-system]
33
+ requires = ["hatchling"]
34
+ build-backend = "hatchling.build"
35
+
36
+ [tool.rye]
37
+ managed = true
38
+ dev-dependencies = [
39
+ "pre-commit>=4.0.1",
40
+ ]
41
+ include = [
42
+ "src/hs_m3u8/"
43
+ ]
44
+
45
+ [tool.hatch.metadata]
46
+ allow-direct-references = true
47
+
48
+ [tool.hatch.build.targets.wheel]
49
+ packages = ["src/hs_m3u8"]
50
+
51
+ [[tool.rye.sources]]
52
+ name = "default"
53
+ url = "https://pypi.tuna.tsinghua.edu.cn/simple"
54
+
55
+ [tool.bandit]
56
+ skips = [
57
+ "B404",
58
+ "B602",
59
+ "B501",
60
+ "B113"
61
+ ]
62
+
63
+ [tool.ruff]
64
+ # Exclude a variety of commonly ignored directories.
65
+ exclude = [
66
+ ".bzr",
67
+ ".direnv",
68
+ ".eggs",
69
+ ".git",
70
+ ".git-rewrite",
71
+ ".hg",
72
+ ".ipynb_checkpoints",
73
+ ".mypy_cache",
74
+ ".nox",
75
+ ".pants.d",
76
+ ".pyenv",
77
+ ".pytest_cache",
78
+ ".pytype",
79
+ ".ruff_cache",
80
+ ".svn",
81
+ ".tox",
82
+ ".venv",
83
+ ".vscode",
84
+ "__pypackages__",
85
+ "_build",
86
+ "buck-out",
87
+ "build",
88
+ "dist",
89
+ "node_modules",
90
+ "site-packages",
91
+ "venv",
92
+ ]
93
+
94
+ # Same as Black.
95
+ line-length = 120
96
+ indent-width = 4
97
+
98
+ # Assume Python 3.12
99
+ target-version = "py312"
100
+
101
+ [tool.ruff.lint]
102
+ select = [
103
+ # pycodestyle error
104
+ "E",
105
+ # Pyflakes
106
+ "F",
107
+ # pycodestyle warnings
108
+ "W",
109
+ # pyupgrade
110
+ "UP",
111
+ # flake8-comprehensions
112
+ "C",
113
+ # flake8-bugbear
114
+ "B",
115
+ # flake8-simplify
116
+ "SIM",
117
+ # isort
118
+ "I",
119
+ ]
120
+ ignore = [
121
+ # do not perform function calls in argument defaults
122
+ "B008",
123
+ # too complex
124
+ "C901"
125
+ ]
126
+ fixable = ["ALL"]
127
+ unfixable = []
128
+
129
+ [tool.ruff.lint.per-file-ignores]
130
+ "__init__.py" = ["F401"]
131
+
132
+
133
+ [tool.rye.scripts]
134
+ publish_testpypi = { cmd = "rye publish --repository testpypi --repository-url https://test.pypi.org/legacy/" }
135
+ publish_pypi = { cmd = "rye publish" }
136
+ sb = { cmd = "rye build --clean" }
137
+ spt = { chain = ["sb", "publish_testpypi"] }
138
+ sp = { chain = ["sb", "publish_pypi"] }
139
+ check_i = { cmd = "rye run pre-commit install" }
140
+ check = { cmd = "rye run pre-commit run --all-files" }
@@ -0,0 +1,172 @@
1
+ # generated by rye
2
+ # use `rye lock` or `rye sync` to update this lockfile
3
+ #
4
+ # last locked with the following flags:
5
+ # pre: false
6
+ # features: []
7
+ # all-features: false
8
+ # with-sources: false
9
+ # generate-hashes: false
10
+ # universal: false
11
+
12
+ -e file:.
13
+ aiohttp==3.9.5
14
+ # via hssp
15
+ aiosignal==1.3.1
16
+ # via aiohttp
17
+ annotated-types==0.7.0
18
+ # via pydantic
19
+ anyio==4.6.2.post1
20
+ # via httpx
21
+ apscheduler==3.10.4
22
+ # via hssp
23
+ attrs==24.2.0
24
+ # via aiohttp
25
+ blinker==1.9.0
26
+ # via hssp
27
+ certifi==2024.8.30
28
+ # via curl-cffi
29
+ # via httpcore
30
+ # via httpx
31
+ # via requests
32
+ cffi==1.17.1
33
+ # via curl-cffi
34
+ cfgv==3.4.0
35
+ # via pre-commit
36
+ charset-normalizer==3.4.0
37
+ # via requests
38
+ click==8.1.7
39
+ # via drissionpage
40
+ cssselect==1.2.0
41
+ # via drissionpage
42
+ # via parsel
43
+ curl-cffi==0.7.3
44
+ # via hssp
45
+ datarecorder==3.6.2
46
+ # via downloadkit
47
+ distlib==0.3.9
48
+ # via virtualenv
49
+ downloadkit==2.0.5
50
+ # via drissionpage
51
+ drissionpage==4.1.0.12
52
+ # via hssp
53
+ et-xmlfile==2.0.0
54
+ # via openpyxl
55
+ fake-useragent==1.5.1
56
+ # via hssp
57
+ filelock==3.16.1
58
+ # via tldextract
59
+ # via virtualenv
60
+ frozenlist==1.5.0
61
+ # via aiohttp
62
+ # via aiosignal
63
+ furl==2.1.3
64
+ # via hssp
65
+ h11==0.14.0
66
+ # via httpcore
67
+ h2==4.1.0
68
+ # via httpx
69
+ hpack==4.0.0
70
+ # via h2
71
+ hssp==0.4.7
72
+ # via hs-m3u8
73
+ httpcore==1.0.6
74
+ # via httpx
75
+ httpx==0.27.2
76
+ # via hssp
77
+ hyperframe==6.0.1
78
+ # via h2
79
+ identify==2.6.2
80
+ # via pre-commit
81
+ idna==3.10
82
+ # via anyio
83
+ # via httpx
84
+ # via requests
85
+ # via tldextract
86
+ # via yarl
87
+ jmespath==1.0.1
88
+ # via parsel
89
+ loguru==0.7.2
90
+ # via hssp
91
+ lxml==5.3.0
92
+ # via drissionpage
93
+ # via parsel
94
+ m3u8==6.0.0
95
+ # via hs-m3u8
96
+ multidict==6.1.0
97
+ # via aiohttp
98
+ # via yarl
99
+ nodeenv==1.9.1
100
+ # via pre-commit
101
+ openpyxl==3.1.5
102
+ # via datarecorder
103
+ orderedmultidict==1.0.1
104
+ # via furl
105
+ packaging==24.2
106
+ # via parsel
107
+ parsel==1.9.1
108
+ # via hssp
109
+ platformdirs==4.3.6
110
+ # via virtualenv
111
+ pre-commit==4.0.1
112
+ propcache==0.2.0
113
+ # via yarl
114
+ psutil==6.1.0
115
+ # via drissionpage
116
+ pycparser==2.22
117
+ # via cffi
118
+ pycryptodomex==3.21.0
119
+ # via hssp
120
+ pydantic==2.9.2
121
+ # via hssp
122
+ # via pydantic-settings
123
+ pydantic-core==2.23.4
124
+ # via pydantic
125
+ pydantic-settings==2.6.1
126
+ # via hssp
127
+ python-dotenv==1.0.1
128
+ # via pydantic-settings
129
+ pytz==2024.2
130
+ # via apscheduler
131
+ pyyaml==6.0.2
132
+ # via pre-commit
133
+ # via pydantic-settings
134
+ requests==2.32.3
135
+ # via downloadkit
136
+ # via drissionpage
137
+ # via hssp
138
+ # via requests-file
139
+ # via tldextract
140
+ requests-file==2.1.0
141
+ # via tldextract
142
+ six==1.16.0
143
+ # via apscheduler
144
+ # via furl
145
+ # via orderedmultidict
146
+ sniffio==1.3.1
147
+ # via anyio
148
+ # via httpx
149
+ tenacity==9.0.0
150
+ # via hssp
151
+ tldextract==5.1.3
152
+ # via drissionpage
153
+ tomli==2.1.0
154
+ # via pydantic-settings
155
+ typing-extensions==4.12.2
156
+ # via curl-cffi
157
+ # via pydantic
158
+ # via pydantic-core
159
+ tzlocal==5.2
160
+ # via apscheduler
161
+ urllib3==2.2.3
162
+ # via requests
163
+ uvloop==0.21.0
164
+ # via hssp
165
+ virtualenv==20.27.1
166
+ # via pre-commit
167
+ w3lib==2.2.1
168
+ # via parsel
169
+ websocket-client==1.8.0
170
+ # via drissionpage
171
+ yarl==1.17.1
172
+ # via aiohttp
@@ -0,0 +1,157 @@
1
+ # generated by rye
2
+ # use `rye lock` or `rye sync` to update this lockfile
3
+ #
4
+ # last locked with the following flags:
5
+ # pre: false
6
+ # features: []
7
+ # all-features: false
8
+ # with-sources: false
9
+ # generate-hashes: false
10
+ # universal: false
11
+
12
+ -e file:.
13
+ aiohttp==3.9.5
14
+ # via hssp
15
+ aiosignal==1.3.1
16
+ # via aiohttp
17
+ annotated-types==0.7.0
18
+ # via pydantic
19
+ anyio==4.6.2.post1
20
+ # via httpx
21
+ apscheduler==3.10.4
22
+ # via hssp
23
+ attrs==24.2.0
24
+ # via aiohttp
25
+ blinker==1.9.0
26
+ # via hssp
27
+ certifi==2024.8.30
28
+ # via curl-cffi
29
+ # via httpcore
30
+ # via httpx
31
+ # via requests
32
+ cffi==1.17.1
33
+ # via curl-cffi
34
+ charset-normalizer==3.4.0
35
+ # via requests
36
+ click==8.1.7
37
+ # via drissionpage
38
+ cssselect==1.2.0
39
+ # via drissionpage
40
+ # via parsel
41
+ curl-cffi==0.7.3
42
+ # via hssp
43
+ datarecorder==3.6.2
44
+ # via downloadkit
45
+ downloadkit==2.0.5
46
+ # via drissionpage
47
+ drissionpage==4.1.0.12
48
+ # via hssp
49
+ et-xmlfile==2.0.0
50
+ # via openpyxl
51
+ fake-useragent==1.5.1
52
+ # via hssp
53
+ filelock==3.16.1
54
+ # via tldextract
55
+ frozenlist==1.5.0
56
+ # via aiohttp
57
+ # via aiosignal
58
+ furl==2.1.3
59
+ # via hssp
60
+ h11==0.14.0
61
+ # via httpcore
62
+ h2==4.1.0
63
+ # via httpx
64
+ hpack==4.0.0
65
+ # via h2
66
+ hssp==0.4.7
67
+ # via hs-m3u8
68
+ httpcore==1.0.6
69
+ # via httpx
70
+ httpx==0.27.2
71
+ # via hssp
72
+ hyperframe==6.0.1
73
+ # via h2
74
+ idna==3.10
75
+ # via anyio
76
+ # via httpx
77
+ # via requests
78
+ # via tldextract
79
+ # via yarl
80
+ jmespath==1.0.1
81
+ # via parsel
82
+ loguru==0.7.2
83
+ # via hssp
84
+ lxml==5.3.0
85
+ # via drissionpage
86
+ # via parsel
87
+ m3u8==6.0.0
88
+ # via hs-m3u8
89
+ multidict==6.1.0
90
+ # via aiohttp
91
+ # via yarl
92
+ openpyxl==3.1.5
93
+ # via datarecorder
94
+ orderedmultidict==1.0.1
95
+ # via furl
96
+ packaging==24.2
97
+ # via parsel
98
+ parsel==1.9.1
99
+ # via hssp
100
+ propcache==0.2.0
101
+ # via yarl
102
+ psutil==6.1.0
103
+ # via drissionpage
104
+ pycparser==2.22
105
+ # via cffi
106
+ pycryptodomex==3.21.0
107
+ # via hssp
108
+ pydantic==2.9.2
109
+ # via hssp
110
+ # via pydantic-settings
111
+ pydantic-core==2.23.4
112
+ # via pydantic
113
+ pydantic-settings==2.6.1
114
+ # via hssp
115
+ python-dotenv==1.0.1
116
+ # via pydantic-settings
117
+ pytz==2024.2
118
+ # via apscheduler
119
+ pyyaml==6.0.2
120
+ # via pydantic-settings
121
+ requests==2.32.3
122
+ # via downloadkit
123
+ # via drissionpage
124
+ # via hssp
125
+ # via requests-file
126
+ # via tldextract
127
+ requests-file==2.1.0
128
+ # via tldextract
129
+ six==1.16.0
130
+ # via apscheduler
131
+ # via furl
132
+ # via orderedmultidict
133
+ sniffio==1.3.1
134
+ # via anyio
135
+ # via httpx
136
+ tenacity==9.0.0
137
+ # via hssp
138
+ tldextract==5.1.3
139
+ # via drissionpage
140
+ tomli==2.1.0
141
+ # via pydantic-settings
142
+ typing-extensions==4.12.2
143
+ # via curl-cffi
144
+ # via pydantic
145
+ # via pydantic-core
146
+ tzlocal==5.2
147
+ # via apscheduler
148
+ urllib3==2.2.3
149
+ # via requests
150
+ uvloop==0.21.0
151
+ # via hssp
152
+ w3lib==2.2.1
153
+ # via parsel
154
+ websocket-client==1.8.0
155
+ # via drissionpage
156
+ yarl==1.17.1
157
+ # via aiohttp
Binary file
File without changes
@@ -0,0 +1,14 @@
1
+ import asyncio
2
+
3
+ from hs_m3u8 import M3u8Downloader
4
+
5
+
6
+ async def main():
7
+ url = "https://surrit.com/6d3bb2b2-d707-4b79-adf0-89542cb1383c/playlist.m3u8"
8
+ name = "SDAB-129"
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,33 @@
1
+ import asyncio
2
+
3
+ from hs_m3u8 import M3u8Downloader
4
+
5
+
6
+ def get_m3u8(resp_text: str):
7
+ """
8
+ 获取m3u8真实文本
9
+ Args:
10
+ resp_text: m3u8_url 获取到的响应文本
11
+
12
+ Returns:
13
+ 返回真正的m3u8文本
14
+
15
+ """
16
+ return resp_text
17
+
18
+
19
+ async def main():
20
+ url = "https://surrit.com/6d3bb2b2-d707-4b79-adf0-89542cb1383c/playlist.m3u8"
21
+ name = "SDAB-129"
22
+ headers = {
23
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
24
+ "(KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36"
25
+ }
26
+ dl = M3u8Downloader(
27
+ m3u8_url=url, save_path=f"../../downloads/{name}", headers=headers, max_workers=64, get_m3u8_func=get_m3u8
28
+ )
29
+ await dl.run(del_hls=False, merge=True)
30
+
31
+
32
+ if __name__ == "__main__":
33
+ 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 = "https://surrit.com/85f671be-4ebc-4cad-961e-a8d339483cc6/playlist.m3u8"
8
+ name = "CUS-2413"
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,14 @@
1
+ import asyncio
2
+
3
+ from hs_m3u8 import M3u8Downloader
4
+
5
+
6
+ async def main():
7
+ url = "https://v3.dious.cc/20220422/EZWdBGuQ/index.m3u8"
8
+ name = "日日是好日"
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,14 @@
1
+ import asyncio
2
+
3
+ from hs_m3u8 import M3u8Downloader
4
+
5
+
6
+ async def main():
7
+ url = "https://v4.qrssv.com/202412/05/CCu6EzN8tR20/video/index.m3u8"
8
+ name = "毒液:最后一舞 HD-索尼"
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,3 @@
1
+ from hs_m3u8.main import M3u8Downloader
2
+
3
+ __version__ = "0.1.0a1"
@@ -0,0 +1,340 @@
1
+ """
2
+ M3U8 下载器
3
+ """
4
+
5
+ import asyncio
6
+ import platform
7
+ import posixpath
8
+ import shutil
9
+ import subprocess
10
+ from collections.abc import Callable
11
+ from enum import Enum, auto
12
+ from hashlib import md5
13
+ from pathlib import Path
14
+ from typing import Any
15
+ from urllib.parse import urljoin, urlparse
16
+ from zipfile import ZipFile
17
+
18
+ import m3u8
19
+ from hssp import Net
20
+ from hssp.utils import crypto
21
+ from loguru import logger
22
+
23
+
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
+ res_path = Path(__file__).parent.parent.parent / "res"
34
+ ffmpeg_bin = res_path / "ffmpeg_win.exe"
35
+
36
+ if ffmpeg_bin.exists():
37
+ return str(ffmpeg_bin)
38
+
39
+ # ZIP 文件
40
+ ffmpeg_bin_zip = Path(ffmpeg_bin.parent) / f"{ffmpeg_bin.name}.zip"
41
+ if ffmpeg_bin_zip.exists():
42
+ # 解压缩到同一目录
43
+ with ZipFile(ffmpeg_bin_zip, "r") as zip_ref:
44
+ zip_ref.extractall(ffmpeg_bin.parent)
45
+
46
+ return ffmpeg_bin
47
+
48
+
49
+ class ContentType(Enum):
50
+ """
51
+ 获取URL数据的,类型枚举
52
+ """
53
+
54
+ Text = auto()
55
+ Json = auto()
56
+ Bytes = auto()
57
+
58
+
59
+ class M3u8Key:
60
+ """
61
+ M3u8key
62
+ """
63
+
64
+ def __init__(self, key: bytes, iv: str = None):
65
+ """
66
+ :param key: 密钥
67
+ :param iv: 偏移
68
+ """
69
+ self.key = key
70
+ self.iv = iv or key
71
+
72
+
73
+ class M3u8Downloader:
74
+ """
75
+ M3u8 异步下载器,并保留hls文件
76
+ """
77
+
78
+ retry_count: int = 0
79
+ retry_max_count: int = 50
80
+ ts_url_list: list = []
81
+ ts_path_list: list = []
82
+ ts_key: M3u8Key = None
83
+ m3u8_md5 = ""
84
+
85
+ def __init__(
86
+ self,
87
+ m3u8_url: str,
88
+ save_path: str,
89
+ decrypt=False,
90
+ max_workers=None,
91
+ headers=None,
92
+ get_m3u8_func: Callable = None,
93
+ ):
94
+ """
95
+
96
+ Args:
97
+ m3u8_url: m3u8 地址
98
+ save_path: 保存路径
99
+ decrypt: 如果ts被加密,是否解密ts
100
+ max_workers: 最大并发数
101
+ headers: 情求头
102
+ get_m3u8_func: 处理m3u8情求的回调函数。适用于m3u8地址不是真正的地址,
103
+ 而是包含m3u8内容的情求,会把m3u8_url的响应传递给get_m3u8_func,要求返回真正的m3u8内容
104
+ """
105
+
106
+ sem = asyncio.Semaphore(max_workers) if max_workers else None
107
+ self.headers = headers
108
+ self.net = Net(sem=sem)
109
+ self.decrypt = decrypt
110
+ self.m3u8_url = urlparse(m3u8_url)
111
+ self.get_m3u8_func = get_m3u8_func
112
+ self.save_dir = Path(save_path) / "hls"
113
+ self.save_name = Path(save_path).name
114
+ self.key_path = self.save_dir / "key.key"
115
+
116
+ if not self.save_dir.exists():
117
+ self.save_dir.mkdir(parents=True)
118
+
119
+ logger.add(self.save_dir.parent / f"{self.save_name}.log")
120
+ self.logger = logger
121
+
122
+ async def run(self, merge=True, del_hls=False):
123
+ await self.start(merge, del_hls)
124
+ await self.net.close()
125
+
126
+ async def start(self, merge=True, del_hls=False):
127
+ """
128
+ 下载器启动函数
129
+ :param merge: ts下载完后是否合并,默认合并
130
+ :param del_hls: 是否删除hls系列文件,包括.m3u8文件、*.ts、.key文件
131
+ :return:
132
+ """
133
+ mp4_path = self.save_dir.parent / f"{self.save_name}.mp4"
134
+ if Path(mp4_path).exists():
135
+ self.logger.info(f"{mp4_path}已存在")
136
+ if del_hls:
137
+ shutil.rmtree(str(self.save_dir))
138
+ return True
139
+
140
+ self.logger.info(
141
+ f"开始下载: 合并ts为mp4={merge}, "
142
+ f"删除hls信息={del_hls}, "
143
+ f"下载地址为:{self.m3u8_url.geturl()}. 保存路径为:{self.save_dir}"
144
+ )
145
+
146
+ await self._download()
147
+ self.logger.info("ts下载完成")
148
+ self.ts_path_list = [ts_path for ts_path in self.ts_path_list if ts_path]
149
+ count_1, count_2 = len(self.ts_url_list), len(self.ts_path_list)
150
+ self.logger.info(f"TS应下载数量为:{count_1}, 实际下载数量为:{count_2}")
151
+ if count_1 == 0 or count_2 == 0:
152
+ self.logger.error("ts数量为0,请检查!!!")
153
+ return
154
+
155
+ if count_2 != count_1:
156
+ self.logger.error(f"ts下载数量与实际数量不符合!!!应该下载数量为:{count_1}, 实际下载数量为:{count_2}")
157
+ self.logger.error(self.ts_url_list)
158
+ self.logger.error(self.ts_path_list)
159
+ if self.retry_count < self.retry_max_count:
160
+ self.retry_count += 1
161
+ self.logger.error(f"正在进行重试:{self.retry_count}/{self.retry_max_count}")
162
+ return self.start(merge, del_hls)
163
+ return False
164
+
165
+ if not merge:
166
+ return True
167
+
168
+ if self.merge():
169
+ self.logger.info("合并成功")
170
+ else:
171
+ self.logger.error(
172
+ f"mp4合并失败. ts应该下载数量为:{count_1}, 实际下载数量为:{count_2}. 保存路径为:{self.save_dir}"
173
+ )
174
+ return False
175
+ if del_hls:
176
+ shutil.rmtree(str(self.save_dir))
177
+ return True
178
+
179
+ async def _download(self):
180
+ """
181
+ 下载ts文件、m3u8文件、key文件
182
+ :return:
183
+ """
184
+ self.ts_url_list = await self.get_ts_list(self.m3u8_url)
185
+ self.ts_path_list = [None] * len(self.ts_url_list)
186
+ await asyncio.gather(*[self._download_ts(url) for url in self.ts_url_list])
187
+
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
+ async def get_ts_list(self, url) -> list[dict]:
213
+ """
214
+ 解析m3u8并保存至列表
215
+ :param url:
216
+ :return:
217
+ """
218
+ resp = await self.net.get(url.geturl(), headers=self.headers)
219
+ m3u8_text = self.get_m3u8_func(resp.text) if self.get_m3u8_func else resp.text
220
+ m3u8_obj = m3u8.loads(m3u8_text)
221
+ prefix = f"{url.scheme}://{url.netloc}"
222
+ base_path = posixpath.normpath(url.path + "/..") + "/"
223
+ m3u8_obj.base_uri = urljoin(prefix, base_path)
224
+
225
+ # 解析多层m3u8, 默认选取比特率最高的
226
+ ts_url_list = []
227
+ if len(m3u8_obj.playlists) > 0:
228
+ bandwidth = 0
229
+ play_url = ""
230
+ self.logger.info("发现多个播放列表")
231
+ for playlist in m3u8_obj.playlists:
232
+ if int(playlist.stream_info.bandwidth) > bandwidth:
233
+ bandwidth = int(playlist.stream_info.bandwidth)
234
+ play_url = playlist.absolute_uri
235
+ self.logger.info(f"选择的播放地址:{play_url},比特率:{bandwidth}")
236
+ return await self.get_ts_list(urlparse(play_url))
237
+
238
+ # 遍历ts文件
239
+ for index, segments in enumerate(m3u8_obj.segments):
240
+ ts_uri = segments.uri if "http" in m3u8_obj.segments[index].uri else segments.absolute_uri
241
+ m3u8_obj.segments[index].uri = f"{index}.ts"
242
+ ts_url_list.append({"uri": ts_uri, "index": index})
243
+
244
+ # 保存解密key
245
+ 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
248
+ self.save_file(key_data, self.key_path)
249
+ self.ts_key = M3u8Key(key=key_data, iv=m3u8_obj.keys[0].iv)
250
+ key = m3u8_obj.segments[0].key
251
+ key.uri = "key.key"
252
+ m3u8_obj.segments[0].key = key
253
+
254
+ # 导出m3u8文件
255
+ m3u8_text = m3u8_obj.dumps()
256
+ self.m3u8_md5 = md5(m3u8_text.encode("utf8"), usedforsecurity=False).hexdigest().lower()
257
+ self.save_file(m3u8_text, self.save_dir / f"{self.m3u8_md5}.m3u8")
258
+ self.logger.info("导出m3u8文件成功")
259
+
260
+ return ts_url_list
261
+
262
+ async def _download_ts(self, ts_item: dict):
263
+ """
264
+ 下载ts
265
+ :param ts_item: ts 数据
266
+ :return:
267
+ """
268
+ index = ts_item["index"]
269
+ ts_uri = ts_item["uri"]
270
+ ts_path = self.save_dir / f"{index}.ts"
271
+ if Path(ts_path).exists():
272
+ self.ts_path_list[index] = str(ts_path)
273
+ return
274
+ resp = await self.net.get(ts_item["uri"])
275
+ ts_content = resp.content
276
+ if ts_content is None:
277
+ return
278
+
279
+ if self.ts_key and self.decrypt:
280
+ ts_content = crypto.decrypt_aes_256_cbc_pad7(ts_content, self.ts_key.key, self.ts_key.iv)
281
+
282
+ self.save_file(ts_content, ts_path)
283
+ self.logger.info(f"{ts_uri}下载成功")
284
+ self.ts_path_list[index] = str(ts_path)
285
+
286
+ def merge(self):
287
+ """
288
+ 合并ts文件为mp4文件
289
+ :return:
290
+ """
291
+ self.logger.info("开始合并mp4")
292
+ if len(self.ts_path_list) != len(self.ts_url_list):
293
+ self.logger.error("数量不足拒绝合并!")
294
+ return False
295
+
296
+ # 整合后的ts文件路径
297
+ big_ts_path = self.save_dir.parent / f"{self.save_name}.ts"
298
+ if big_ts_path.exists():
299
+ big_ts_path.unlink()
300
+
301
+ # mp4路径
302
+ mp4_path = self.save_dir.parent / f"{self.save_name}.mp4"
303
+
304
+ # 把ts文件整合到一起
305
+ big_ts_file = big_ts_path.open("ab+")
306
+ for path in self.ts_path_list:
307
+ with open(path, "rb") as ts_file:
308
+ data = ts_file.read()
309
+ if self.ts_key:
310
+ data = crypto.decrypt_aes_256_cbc_pad7(data, self.ts_key.key, self.ts_key.iv)
311
+ big_ts_file.write(data)
312
+ big_ts_file.close()
313
+ self.logger.info("ts文件整合完毕")
314
+
315
+ # 把大的ts文件转换成mp4文件
316
+ ffmpeg_bin = get_ffmpeg()
317
+ command = (
318
+ 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'
320
+ )
321
+ self.logger.info(f"ts整合成功,开始转为mp4。 command:{command}")
322
+ result = subprocess.run(command, shell=True, capture_output=True, text=True)
323
+ if result.returncode != 0:
324
+ logger.error(f"命令执行失败: {result.stderr or result.stdout}")
325
+
326
+ if Path(mp4_path).exists():
327
+ big_ts_path.unlink()
328
+ return Path(mp4_path).exists()
329
+
330
+ @staticmethod
331
+ def save_file(content: bytes | str, filepath):
332
+ """
333
+ 保存内容到文件
334
+ :param content: 内容
335
+ :param filepath: 文件路径
336
+ :return:
337
+ """
338
+ mode = "wb" if isinstance(content, bytes) else "w"
339
+ with open(file=filepath, mode=mode) as file:
340
+ file.write(content)