m3u8-dl-tool 0.1.0__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.
- m3u8_dl_tool-0.1.0/LICENSE +21 -0
- m3u8_dl_tool-0.1.0/PKG-INFO +51 -0
- m3u8_dl_tool-0.1.0/README.md +17 -0
- m3u8_dl_tool-0.1.0/pyproject.toml +21 -0
- m3u8_dl_tool-0.1.0/setup.cfg +4 -0
- m3u8_dl_tool-0.1.0/src/m3u8_dl_tool.egg-info/PKG-INFO +51 -0
- m3u8_dl_tool-0.1.0/src/m3u8_dl_tool.egg-info/SOURCES.txt +10 -0
- m3u8_dl_tool-0.1.0/src/m3u8_dl_tool.egg-info/dependency_links.txt +1 -0
- m3u8_dl_tool-0.1.0/src/m3u8_dl_tool.egg-info/requires.txt +2 -0
- m3u8_dl_tool-0.1.0/src/m3u8_dl_tool.egg-info/top_level.txt +1 -0
- m3u8_dl_tool-0.1.0/src/m3u8dltool/M3u8DL.py +337 -0
- m3u8_dl_tool-0.1.0/src/m3u8dltool/__init__.py +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jxwubin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: m3u8-dl-tool
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 跨平台m3u8下载工具,自动过滤广告分片,多线程下载
|
|
5
|
+
Author-email: jxwubin <549719522@qq.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 jxwubin
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
Project-URL: Homepage, https://github.com/xxx/xxx
|
|
28
|
+
Requires-Python: >=3.9
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Requires-Dist: requests>=2.31.0
|
|
32
|
+
Requires-Dist: pycryptodome>=3.19.0
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# m3u8‑dl‑tool
|
|
36
|
+
跨平台m3u8视频下载工具,自动检测并删除重复广告分片,多线程高速下载,支持Windows / macOS / Linux。
|
|
37
|
+
|
|
38
|
+
## ✨功能特点
|
|
39
|
+
- 多线程并发下载TS分片
|
|
40
|
+
- 自动计算分片哈希,过滤重复广告TS片段
|
|
41
|
+
- AES加密m3u8自动解密
|
|
42
|
+
- 下载完成自动合并TS为MP4
|
|
43
|
+
- 自动清理临时TS文件
|
|
44
|
+
- 参数说明
|
|
45
|
+
max_workers:下载并发线程数,建议 10~30,不要设置过大,避免被服务器限流
|
|
46
|
+
base_dir:自定义工作目录,不填则默认使用调用脚本所在文件夹
|
|
47
|
+
run (max_workers=15,start_index=0, end_index=None, base_dir=r"D:\video_download")
|
|
48
|
+
|
|
49
|
+
## 安装
|
|
50
|
+
```bash
|
|
51
|
+
pip install m3u8-dl-tool
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# m3u8‑dl‑tool
|
|
2
|
+
跨平台m3u8视频下载工具,自动检测并删除重复广告分片,多线程高速下载,支持Windows / macOS / Linux。
|
|
3
|
+
|
|
4
|
+
## ✨功能特点
|
|
5
|
+
- 多线程并发下载TS分片
|
|
6
|
+
- 自动计算分片哈希,过滤重复广告TS片段
|
|
7
|
+
- AES加密m3u8自动解密
|
|
8
|
+
- 下载完成自动合并TS为MP4
|
|
9
|
+
- 自动清理临时TS文件
|
|
10
|
+
- 参数说明
|
|
11
|
+
max_workers:下载并发线程数,建议 10~30,不要设置过大,避免被服务器限流
|
|
12
|
+
base_dir:自定义工作目录,不填则默认使用调用脚本所在文件夹
|
|
13
|
+
run (max_workers=15,start_index=0, end_index=None, base_dir=r"D:\video_download")
|
|
14
|
+
|
|
15
|
+
## 安装
|
|
16
|
+
```bash
|
|
17
|
+
pip install m3u8-dl-tool
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "m3u8-dl-tool"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name="jxwubin", email="549719522@qq.com" }
|
|
10
|
+
]
|
|
11
|
+
description = "跨平台m3u8下载工具,自动过滤广告分片,多线程下载"
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"requests>=2.31.0",
|
|
15
|
+
"pycryptodome>=3.19.0"
|
|
16
|
+
]
|
|
17
|
+
readme = "README.md"
|
|
18
|
+
license = {file="LICENSE"}
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
"Homepage" = "https://github.com/xxx/xxx"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: m3u8-dl-tool
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 跨平台m3u8下载工具,自动过滤广告分片,多线程下载
|
|
5
|
+
Author-email: jxwubin <549719522@qq.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 jxwubin
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
Project-URL: Homepage, https://github.com/xxx/xxx
|
|
28
|
+
Requires-Python: >=3.9
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Requires-Dist: requests>=2.31.0
|
|
32
|
+
Requires-Dist: pycryptodome>=3.19.0
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# m3u8‑dl‑tool
|
|
36
|
+
跨平台m3u8视频下载工具,自动检测并删除重复广告分片,多线程高速下载,支持Windows / macOS / Linux。
|
|
37
|
+
|
|
38
|
+
## ✨功能特点
|
|
39
|
+
- 多线程并发下载TS分片
|
|
40
|
+
- 自动计算分片哈希,过滤重复广告TS片段
|
|
41
|
+
- AES加密m3u8自动解密
|
|
42
|
+
- 下载完成自动合并TS为MP4
|
|
43
|
+
- 自动清理临时TS文件
|
|
44
|
+
- 参数说明
|
|
45
|
+
max_workers:下载并发线程数,建议 10~30,不要设置过大,避免被服务器限流
|
|
46
|
+
base_dir:自定义工作目录,不填则默认使用调用脚本所在文件夹
|
|
47
|
+
run (max_workers=15,start_index=0, end_index=None, base_dir=r"D:\video_download")
|
|
48
|
+
|
|
49
|
+
## 安装
|
|
50
|
+
```bash
|
|
51
|
+
pip install m3u8-dl-tool
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/m3u8_dl_tool.egg-info/PKG-INFO
|
|
5
|
+
src/m3u8_dl_tool.egg-info/SOURCES.txt
|
|
6
|
+
src/m3u8_dl_tool.egg-info/dependency_links.txt
|
|
7
|
+
src/m3u8_dl_tool.egg-info/requires.txt
|
|
8
|
+
src/m3u8_dl_tool.egg-info/top_level.txt
|
|
9
|
+
src/m3u8dltool/M3u8DL.py
|
|
10
|
+
src/m3u8dltool/__init__.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
m3u8dltool
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from urllib import parse
|
|
3
|
+
import requests
|
|
4
|
+
from Crypto.Cipher import AES
|
|
5
|
+
import re
|
|
6
|
+
import os
|
|
7
|
+
import hashlib
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
10
|
+
import json
|
|
11
|
+
import inspect
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class M3u8Downloader:
|
|
15
|
+
@staticmethod
|
|
16
|
+
def _get_caller_script_dir():
|
|
17
|
+
"""自动获取外部执行脚本所在目录"""
|
|
18
|
+
import sys
|
|
19
|
+
this_module = sys.modules[__name__].__file__
|
|
20
|
+
for frame in inspect.stack():
|
|
21
|
+
fname = frame.filename
|
|
22
|
+
# 跳过本库自身文件
|
|
23
|
+
if fname == this_module:
|
|
24
|
+
continue
|
|
25
|
+
if os.path.exists(fname) and not fname.startswith("<"):
|
|
26
|
+
return os.path.dirname(os.path.abspath(fname))
|
|
27
|
+
return os.getcwd()
|
|
28
|
+
|
|
29
|
+
def __init__(self, max_workers=30, base_dir=None):
|
|
30
|
+
if base_dir is None:
|
|
31
|
+
self.BASE_DIR = self._get_caller_script_dir()
|
|
32
|
+
else:
|
|
33
|
+
self.BASE_DIR = base_dir
|
|
34
|
+
|
|
35
|
+
self.JSON_FILE = os.path.join(self.BASE_DIR, 'm3u8_url.json')
|
|
36
|
+
self.TS_ROOT = os.path.join(self.BASE_DIR, "m3u8_ts")
|
|
37
|
+
self.OUTPUT_ROOT = os.path.join(self.BASE_DIR, "MP4")
|
|
38
|
+
self.max_workers = max_workers
|
|
39
|
+
|
|
40
|
+
if not os.path.exists(self.TS_ROOT):
|
|
41
|
+
os.makedirs(self.TS_ROOT)
|
|
42
|
+
if not os.path.exists(self.OUTPUT_ROOT):
|
|
43
|
+
os.makedirs(self.OUTPUT_ROOT)
|
|
44
|
+
|
|
45
|
+
class _SingleVideoDownload:
|
|
46
|
+
"""内部嵌套类,对应原来的 download_m3u8"""
|
|
47
|
+
def __init__(self, m3u8_url, video_name, ts_root, output_root, max_workers) -> None:
|
|
48
|
+
self.TS_ROOT = ts_root
|
|
49
|
+
self.OUTPUT_ROOT = output_root
|
|
50
|
+
self.video_name = video_name
|
|
51
|
+
self.m3u8_url = m3u8_url
|
|
52
|
+
self.ts_dir_name = f'ts_{self.video_name}'
|
|
53
|
+
self.ts_path = os.path.join(self.TS_ROOT, self.ts_dir_name)
|
|
54
|
+
self.output_path = os.path.join(self.OUTPUT_ROOT, f'{video_name}.mp4')
|
|
55
|
+
self.num = 1
|
|
56
|
+
# 接收外部传入线程数
|
|
57
|
+
self.max_workers = max_workers
|
|
58
|
+
|
|
59
|
+
# 一、下载 ts 文件
|
|
60
|
+
def download(self):
|
|
61
|
+
# 主线程读取m3u8,只在主线使用requests,无并发,安全
|
|
62
|
+
m3u8 = requests.get(self.m3u8_url, timeout=15).text
|
|
63
|
+
mixed_m3u8_url = None
|
|
64
|
+
if '.m3u8' in m3u8:
|
|
65
|
+
mixed_m3u8_url = m3u8.split()[-1]
|
|
66
|
+
mixed_m3u8_url = parse.urljoin(self.m3u8_url, mixed_m3u8_url)
|
|
67
|
+
m3u8 = requests.get(mixed_m3u8_url, timeout=15).text[:-2]
|
|
68
|
+
# 【取加密 key 的网址】
|
|
69
|
+
key = None
|
|
70
|
+
if '.key' in m3u8:
|
|
71
|
+
zz = re.findall('"([^"]*)"', m3u8)[0]
|
|
72
|
+
key_url = parse.urljoin(self.m3u8_url, zz)
|
|
73
|
+
key = requests.get(key_url, timeout=15).content
|
|
74
|
+
print(f'key-->{key}')
|
|
75
|
+
# 【下载 ts 文件到本地】
|
|
76
|
+
ts_dir_full = os.path.join(self.TS_ROOT, f'ts_{self.video_name}')
|
|
77
|
+
if not os.path.exists(ts_dir_full):
|
|
78
|
+
os.makedirs(ts_dir_full)
|
|
79
|
+
self.ts_path = ts_dir_full
|
|
80
|
+
download_tasks = []
|
|
81
|
+
ts_count = []
|
|
82
|
+
n = 1
|
|
83
|
+
# 使用实例保存的线程数 self.max_workers
|
|
84
|
+
with ThreadPoolExecutor(self.max_workers) as executor:
|
|
85
|
+
for line in m3u8.split('\n'):
|
|
86
|
+
if line.startswith("#"):
|
|
87
|
+
continue
|
|
88
|
+
if len(line) == 0:
|
|
89
|
+
continue
|
|
90
|
+
if mixed_m3u8_url:
|
|
91
|
+
line = parse.urljoin(mixed_m3u8_url, line)
|
|
92
|
+
else:
|
|
93
|
+
line = parse.urljoin(self.m3u8_url, line)
|
|
94
|
+
ts_count.append(line)
|
|
95
|
+
ts_name = str(n).zfill(5)
|
|
96
|
+
n += 1
|
|
97
|
+
ts_file = os.path.join(self.ts_path, f'{ts_name}.ts')
|
|
98
|
+
download_tasks.append(
|
|
99
|
+
executor.submit(
|
|
100
|
+
self.download_single_ts,
|
|
101
|
+
line,
|
|
102
|
+
ts_count,
|
|
103
|
+
ts_file,
|
|
104
|
+
key if '.key' in m3u8 else None
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
# 等待所有下载任务完成
|
|
108
|
+
print("=====等待全部下载线程结束=====")
|
|
109
|
+
for future in download_tasks:
|
|
110
|
+
future.result()
|
|
111
|
+
time.sleep(2)
|
|
112
|
+
print("\n\n=====全部ts下载完成,开始检测重复分片(广告)=====", flush=True)
|
|
113
|
+
# 所有分片下载完成后,按内容去重
|
|
114
|
+
self.remove_duplicate_ts()
|
|
115
|
+
|
|
116
|
+
def download_single_ts(self, line, ts_count, ts_file, key=None):
|
|
117
|
+
retry_count = 1
|
|
118
|
+
while retry_count < 10:
|
|
119
|
+
try:
|
|
120
|
+
with open(ts_file, mode="wb") as f:
|
|
121
|
+
# 独立请求,不加共用session,增加超时,防止永久挂起
|
|
122
|
+
resp = requests.get(line, timeout=15)
|
|
123
|
+
ts = resp.content
|
|
124
|
+
if key:
|
|
125
|
+
crypto = AES.new(key, AES.MODE_CBC, key)
|
|
126
|
+
f.write(crypto.decrypt(ts))
|
|
127
|
+
else:
|
|
128
|
+
f.write(ts)
|
|
129
|
+
print(
|
|
130
|
+
f'\r 下载成功({self.num}/{len(ts_count)})-->'
|
|
131
|
+
+ os.path.basename(ts_file),
|
|
132
|
+
end='',
|
|
133
|
+
flush=True
|
|
134
|
+
)
|
|
135
|
+
self.num += 1
|
|
136
|
+
break
|
|
137
|
+
except Exception as e:
|
|
138
|
+
print(
|
|
139
|
+
f"\n---->下载失败,重新下载(第{retry_count}次)"
|
|
140
|
+
f"-->{os.path.basename(ts_file)}--{line}"
|
|
141
|
+
)
|
|
142
|
+
retry_count += 1
|
|
143
|
+
time.sleep(1)
|
|
144
|
+
|
|
145
|
+
# 新增:计算文件的 SHA‑256
|
|
146
|
+
@staticmethod
|
|
147
|
+
def get_file_hash(file_path):
|
|
148
|
+
sha256 = hashlib.sha256()
|
|
149
|
+
try:
|
|
150
|
+
with open(file_path, 'rb') as f:
|
|
151
|
+
for chunk in iter(lambda: f.read(1024 * 1024), b''):
|
|
152
|
+
sha256.update(chunk)
|
|
153
|
+
return sha256.hexdigest()
|
|
154
|
+
except Exception as e:
|
|
155
|
+
print(f"读取哈希失败跳过文件:{file_path} 错误:{e}")
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
# 新增:删除所有内容重复的 TS 文件
|
|
159
|
+
def remove_duplicate_ts(self):
|
|
160
|
+
ts_files = [
|
|
161
|
+
os.path.join(self.ts_path, filename)
|
|
162
|
+
for filename in os.listdir(self.ts_path)
|
|
163
|
+
if filename.lower().endswith('.ts')
|
|
164
|
+
]
|
|
165
|
+
# 建立“文件路径 -> 文件内容哈希”的对应关系
|
|
166
|
+
file_hashes = {}
|
|
167
|
+
for fp in ts_files:
|
|
168
|
+
h = self.get_file_hash(fp)
|
|
169
|
+
if h is not None:
|
|
170
|
+
file_hashes[fp] = h
|
|
171
|
+
# 统计每种哈希出现次数
|
|
172
|
+
hash_counts = Counter(file_hashes.values())
|
|
173
|
+
# 出现两次及以上,全部删除(广告分片)
|
|
174
|
+
duplicate_files = [
|
|
175
|
+
file_path
|
|
176
|
+
for file_path in file_hashes.keys()
|
|
177
|
+
if hash_counts[file_hashes[file_path]] >= 2
|
|
178
|
+
]
|
|
179
|
+
for file_path in duplicate_files:
|
|
180
|
+
try:
|
|
181
|
+
os.remove(file_path)
|
|
182
|
+
except Exception as e:
|
|
183
|
+
print(f"删除重复分片失败:{file_path},错误:{e}", flush=True)
|
|
184
|
+
print(
|
|
185
|
+
f'重复 TS 检查完成:发现并过滤 '
|
|
186
|
+
f'{len(duplicate_files)} 个重复片段'
|
|
187
|
+
)
|
|
188
|
+
if duplicate_files:
|
|
189
|
+
print(
|
|
190
|
+
'被过滤的文件:'
|
|
191
|
+
+ ', '.join(os.path.basename(path) for path in duplicate_files)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# 二、合并为 MP4【跨平台版本,不用copy命令】
|
|
195
|
+
def cmd_merge(self):
|
|
196
|
+
print("ts文件合并中......", flush=True)
|
|
197
|
+
ts_dir_full = os.path.join(self.TS_ROOT, f'ts_{self.video_name}')
|
|
198
|
+
out_mp4 = os.path.join(self.OUTPUT_ROOT, f"{self.video_name}.mp4")
|
|
199
|
+
|
|
200
|
+
ts_files = []
|
|
201
|
+
for fname in os.listdir(ts_dir_full):
|
|
202
|
+
if fname.lower().endswith(".ts"):
|
|
203
|
+
ts_files.append(fname)
|
|
204
|
+
ts_files.sort()
|
|
205
|
+
# 分块读写,避免大视频占满内存
|
|
206
|
+
block_size = 4 * 1024 * 1024
|
|
207
|
+
with open(out_mp4, "wb") as fw:
|
|
208
|
+
for fname in ts_files:
|
|
209
|
+
fp = os.path.join(ts_dir_full, fname)
|
|
210
|
+
with open(fp, "rb") as fr:
|
|
211
|
+
while True:
|
|
212
|
+
chunk = fr.read(block_size)
|
|
213
|
+
if not chunk:
|
|
214
|
+
break
|
|
215
|
+
fw.write(chunk)
|
|
216
|
+
print('合并完成', flush=True)
|
|
217
|
+
|
|
218
|
+
# 三、删除 ts 文件及文件夹【跨平台版本,不用del命令】
|
|
219
|
+
def del_ts(self):
|
|
220
|
+
print("开始清理ts临时文件夹", flush=True)
|
|
221
|
+
ts_dir_full = os.path.join(self.TS_ROOT, f'ts_{self.video_name}')
|
|
222
|
+
try:
|
|
223
|
+
for fname in os.listdir(ts_dir_full):
|
|
224
|
+
fpath = os.path.join(ts_dir_full, fname)
|
|
225
|
+
if os.path.isfile(fpath):
|
|
226
|
+
os.remove(fpath)
|
|
227
|
+
os.rmdir(ts_dir_full)
|
|
228
|
+
print('ts 文件夹已删除')
|
|
229
|
+
except Exception as e:
|
|
230
|
+
print(f"清理ts文件夹失败:{ts_dir_full},错误:{e}", flush=True)
|
|
231
|
+
|
|
232
|
+
# 四、运行
|
|
233
|
+
def run(self):
|
|
234
|
+
print(f'\n{"=" * 30}')
|
|
235
|
+
print(f'开始下载: {self.video_name}')
|
|
236
|
+
print(f'{"=" * 30}')
|
|
237
|
+
print('【1】 >>>>>>>>>>>>>>> 开始下载 ts')
|
|
238
|
+
self.download()
|
|
239
|
+
print('\n【2】 >>>>>>>>>>>>>>> 开始合并 ts')
|
|
240
|
+
self.cmd_merge()
|
|
241
|
+
print('【3】 >>>>>>>>>>>>>>> 开始删除 ts')
|
|
242
|
+
self.del_ts()
|
|
243
|
+
print(f'【完成】 {self.video_name} 下载完成!')
|
|
244
|
+
|
|
245
|
+
def load_video_list(self, json_file_path):
|
|
246
|
+
"""读取 JSON 文件,如果文件不存在则自动生成示例 m3u8_url.json,返回 None;存在就读取返回列表"""
|
|
247
|
+
if not os.path.exists(json_file_path):
|
|
248
|
+
# 构造示例模板内容
|
|
249
|
+
demo_data = [
|
|
250
|
+
{
|
|
251
|
+
"title": "视频名称1",
|
|
252
|
+
"url": "m3u8地址"
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
"title": "视频名称2",
|
|
256
|
+
"url": "m3u8地址"
|
|
257
|
+
}
|
|
258
|
+
]
|
|
259
|
+
# 写入示例文件
|
|
260
|
+
with open(json_file_path, 'w', encoding='utf-8') as fw:
|
|
261
|
+
json.dump(demo_data, fw, ensure_ascii=False, indent=4)
|
|
262
|
+
print(f"\n【提示】未找到 {json_file_path},已自动生成示例json文件!", flush=True)
|
|
263
|
+
print("请打开该文件,添加下载任务后,重新运行脚本!", flush=True)
|
|
264
|
+
# 文件新建成功,返回 None,告诉上层不要继续下载
|
|
265
|
+
return None
|
|
266
|
+
# 文件存在,正常读取
|
|
267
|
+
with open(json_file_path, 'r', encoding='utf-8') as f:
|
|
268
|
+
video_list = json.load(f)
|
|
269
|
+
return video_list
|
|
270
|
+
|
|
271
|
+
def batch_download(self, start_index=0, end_index=None):
|
|
272
|
+
"""
|
|
273
|
+
批量下载视频。
|
|
274
|
+
Args:
|
|
275
|
+
start_index: 起始索引,从 0 开始
|
|
276
|
+
end_index: 结束索引,不包含;None 表示到最后
|
|
277
|
+
"""
|
|
278
|
+
json_file_path = self.JSON_FILE
|
|
279
|
+
video_list = self.load_video_list(json_file_path)
|
|
280
|
+
if video_list is None:
|
|
281
|
+
return
|
|
282
|
+
if end_index is None:
|
|
283
|
+
end_index = len(video_list)
|
|
284
|
+
video_list = video_list[start_index:end_index]
|
|
285
|
+
total = len(video_list)
|
|
286
|
+
print(f'>>> 共找到 {total} 个视频待下载')
|
|
287
|
+
for idx, video in enumerate(video_list, 1):
|
|
288
|
+
title = video.get('title')
|
|
289
|
+
url = video.get('url')
|
|
290
|
+
if not title or not url:
|
|
291
|
+
print(f'跳过无效条目: {video}')
|
|
292
|
+
continue
|
|
293
|
+
print(f'>>> 当前进度: {idx}/{total}')
|
|
294
|
+
try:
|
|
295
|
+
downloader = self._SingleVideoDownload(
|
|
296
|
+
m3u8_url=url,
|
|
297
|
+
video_name=title,
|
|
298
|
+
ts_root=self.TS_ROOT,
|
|
299
|
+
output_root=self.OUTPUT_ROOT,
|
|
300
|
+
max_workers=self.max_workers
|
|
301
|
+
)
|
|
302
|
+
downloader.run()
|
|
303
|
+
except Exception as e:
|
|
304
|
+
print(f'下载 {title} 时出错: {e}')
|
|
305
|
+
continue
|
|
306
|
+
time.sleep(1)
|
|
307
|
+
print(f'\n{"=" * 30}')
|
|
308
|
+
print('所有视频下载完成!')
|
|
309
|
+
print(f'{"=" * 30}')
|
|
310
|
+
|
|
311
|
+
def run(self, start_index=0, end_index=None, max_workers=None):
|
|
312
|
+
"""统一入口:执行全部批量下载并统计总耗时
|
|
313
|
+
:param start_index: 起始下标
|
|
314
|
+
:param end_index: 结束下标
|
|
315
|
+
:param max_workers: 线程池并发数,不传使用实例初始化的数值
|
|
316
|
+
"""
|
|
317
|
+
# 如果调用run传入线程数,优先使用传入的值
|
|
318
|
+
if max_workers is not None:
|
|
319
|
+
self.max_workers = max_workers
|
|
320
|
+
|
|
321
|
+
start = time.perf_counter()
|
|
322
|
+
self.batch_download(start_index=start_index, end_index=end_index)
|
|
323
|
+
end = time.perf_counter()
|
|
324
|
+
print(f"耗时:{end - start:.2f} 秒")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == '__main__':
|
|
328
|
+
# 方式1:初始化时指定线程数
|
|
329
|
+
# app = M3u8Downloader(max_workers=15)
|
|
330
|
+
# app.run()
|
|
331
|
+
|
|
332
|
+
# 方式2:run()调用时动态修改线程数(推荐)
|
|
333
|
+
app = M3u8Downloader()
|
|
334
|
+
app.run(max_workers=15)
|
|
335
|
+
|
|
336
|
+
# 限定范围下载示例
|
|
337
|
+
# app.run(start_index=0, end_index=5, max_workers=20)
|