m3u8-cli 0.1.0__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.
- m3u8_cli/__init__.py +3 -0
- m3u8_cli/__main__.py +6 -0
- m3u8_cli/cli.py +186 -0
- m3u8_cli/convert.py +277 -0
- m3u8_cli/download.py +193 -0
- m3u8_cli/downloader.py +105 -0
- m3u8_cli/errors.py +6 -0
- m3u8_cli/hls_export.py +151 -0
- m3u8_cli/merge.py +43 -0
- m3u8_cli/paths.py +30 -0
- m3u8_cli/playlist.py +165 -0
- m3u8_cli/serve.py +720 -0
- m3u8_cli-0.1.0.dist-info/METADATA +343 -0
- m3u8_cli-0.1.0.dist-info/RECORD +17 -0
- m3u8_cli-0.1.0.dist-info/WHEEL +4 -0
- m3u8_cli-0.1.0.dist-info/entry_points.txt +3 -0
- m3u8_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
m3u8_cli/__init__.py
ADDED
m3u8_cli/__main__.py
ADDED
m3u8_cli/cli.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""命令行入口与下载流程编排。"""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from .convert import convert_video
|
|
12
|
+
from .download import DownloadOptions, download_hls
|
|
13
|
+
from .errors import HLSException
|
|
14
|
+
from .serve import run_serve
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_parser():
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
prog="m3u8-cli",
|
|
20
|
+
description="HLS 下载与视频格式转换工具",
|
|
21
|
+
)
|
|
22
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
23
|
+
|
|
24
|
+
download = subparsers.add_parser(
|
|
25
|
+
"download",
|
|
26
|
+
help="下载 HLS(m3u8)视频",
|
|
27
|
+
description="并发下载 HLS 分片,产物保存在任务子目录中",
|
|
28
|
+
)
|
|
29
|
+
download.add_argument("url", help="m3u8 地址")
|
|
30
|
+
download.add_argument(
|
|
31
|
+
"-f", "--format", choices=("mp4", "hls"), default="mp4",
|
|
32
|
+
help="输出格式:mp4(合并封装,默认)或 hls(保留分片,不合并)",
|
|
33
|
+
)
|
|
34
|
+
download.add_argument(
|
|
35
|
+
"-o", "--output",
|
|
36
|
+
help="mp4: 最终文件名;hls: 任务目录(默认 output.mp4 / output)",
|
|
37
|
+
)
|
|
38
|
+
download.add_argument("-j", "--jobs", type=int, default=8)
|
|
39
|
+
download.add_argument("--retries", type=int, default=3)
|
|
40
|
+
download.add_argument("--timeout", type=int, default=30)
|
|
41
|
+
download.add_argument("--referer")
|
|
42
|
+
download.add_argument("--cookie")
|
|
43
|
+
download.add_argument("--user-agent", default="Mozilla/5.0")
|
|
44
|
+
download.add_argument(
|
|
45
|
+
"--overwrite", action="store_true",
|
|
46
|
+
help="覆盖已有输出文件",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
convert = subparsers.add_parser(
|
|
50
|
+
"convert",
|
|
51
|
+
help="把本地视频转换为 HLS 或其他格式",
|
|
52
|
+
description="用 FFmpeg 转换视频格式,默认输出 HLS",
|
|
53
|
+
)
|
|
54
|
+
convert.add_argument("input", help="输入视频文件")
|
|
55
|
+
convert.add_argument(
|
|
56
|
+
"-f", "--format", choices=("hls", "mp4", "mkv", "webm"),
|
|
57
|
+
default="hls", help="输出格式(默认 hls)",
|
|
58
|
+
)
|
|
59
|
+
convert.add_argument(
|
|
60
|
+
"-c", "--codec", choices=("h264", "hevc", "copy"),
|
|
61
|
+
default="h264", help="编码方式(默认 h264)",
|
|
62
|
+
)
|
|
63
|
+
convert.add_argument(
|
|
64
|
+
"-o", "--output-dir",
|
|
65
|
+
help="输出目录;默认为输入文件名",
|
|
66
|
+
)
|
|
67
|
+
convert.add_argument(
|
|
68
|
+
"--segment-time", type=int, default=6,
|
|
69
|
+
help="HLS 分片时长(秒,默认 6)",
|
|
70
|
+
)
|
|
71
|
+
convert.add_argument(
|
|
72
|
+
"--overwrite", action="store_true",
|
|
73
|
+
help="覆盖已有产物",
|
|
74
|
+
)
|
|
75
|
+
convert.add_argument(
|
|
76
|
+
"--encrypt", action="store_true",
|
|
77
|
+
help="为 HLS 分片启用 AES-128 加密",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
serve = subparsers.add_parser(
|
|
81
|
+
"serve",
|
|
82
|
+
help="启动 RESTful 下载服务",
|
|
83
|
+
description="在局域网启动 HTTP API,接受远程下载任务",
|
|
84
|
+
)
|
|
85
|
+
serve.add_argument("--host", default="0.0.0.0")
|
|
86
|
+
serve.add_argument("--port", type=int, default=8787)
|
|
87
|
+
serve.add_argument(
|
|
88
|
+
"--token", help="Bearer token;省略时自动生成并打印",
|
|
89
|
+
)
|
|
90
|
+
serve.add_argument(
|
|
91
|
+
"--output-dir", default=".",
|
|
92
|
+
help="任务目录根路径(默认当前目录)",
|
|
93
|
+
)
|
|
94
|
+
serve.add_argument(
|
|
95
|
+
"--max-tasks", type=int, default=3,
|
|
96
|
+
help="同时运行的任务数上限",
|
|
97
|
+
)
|
|
98
|
+
serve.add_argument(
|
|
99
|
+
"--jobs", type=int, default=8,
|
|
100
|
+
help="请求未指定 jobs 时的分片并发默认值",
|
|
101
|
+
)
|
|
102
|
+
serve.add_argument(
|
|
103
|
+
"--state-file", help="任务状态文件路径",
|
|
104
|
+
)
|
|
105
|
+
return parser
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def run_download(args):
|
|
109
|
+
if args.format == "mp4" and not shutil.which("ffmpeg"):
|
|
110
|
+
sys.exit("请先安装 ffmpeg")
|
|
111
|
+
|
|
112
|
+
output_arg = args.output if args.output is not None else (
|
|
113
|
+
"output" if args.format == "hls" else "output.mp4"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
headers = {"User-Agent": args.user_agent}
|
|
117
|
+
if args.referer:
|
|
118
|
+
headers["Referer"] = args.referer
|
|
119
|
+
if args.cookie:
|
|
120
|
+
headers["Cookie"] = args.cookie
|
|
121
|
+
|
|
122
|
+
def on_progress(completed, total):
|
|
123
|
+
print(f"\r下载进度:{completed}/{total}", end="", flush=True)
|
|
124
|
+
|
|
125
|
+
output = download_hls(
|
|
126
|
+
DownloadOptions(
|
|
127
|
+
url=args.url,
|
|
128
|
+
output=Path(output_arg),
|
|
129
|
+
format=args.format,
|
|
130
|
+
jobs=args.jobs,
|
|
131
|
+
retries=args.retries,
|
|
132
|
+
timeout=args.timeout,
|
|
133
|
+
headers=headers,
|
|
134
|
+
overwrite=args.overwrite,
|
|
135
|
+
),
|
|
136
|
+
on_progress=on_progress,
|
|
137
|
+
)
|
|
138
|
+
print()
|
|
139
|
+
print(f"完成:{output}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def run_convert(args):
|
|
143
|
+
if not shutil.which("ffmpeg"):
|
|
144
|
+
sys.exit("请先安装 ffmpeg")
|
|
145
|
+
|
|
146
|
+
output = convert_video(
|
|
147
|
+
args.input,
|
|
148
|
+
args.format,
|
|
149
|
+
args.codec,
|
|
150
|
+
args.output_dir,
|
|
151
|
+
args.segment_time,
|
|
152
|
+
args.overwrite,
|
|
153
|
+
args.encrypt,
|
|
154
|
+
)
|
|
155
|
+
print(f"完成:{output}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def main():
|
|
159
|
+
parser = build_parser()
|
|
160
|
+
args = parser.parse_args()
|
|
161
|
+
|
|
162
|
+
if args.command == "download":
|
|
163
|
+
if args.jobs < 1 or args.timeout < 1 or args.retries < 0:
|
|
164
|
+
parser.error("jobs/timeout 必须为正数,retries 不能为负数")
|
|
165
|
+
handler = run_download
|
|
166
|
+
elif args.command == "convert":
|
|
167
|
+
if args.encrypt and args.format != "hls":
|
|
168
|
+
parser.error("--encrypt 仅适用于 HLS")
|
|
169
|
+
if args.format == "hls" and args.segment_time < 1:
|
|
170
|
+
parser.error("segment-time 必须为正整数")
|
|
171
|
+
handler = run_convert
|
|
172
|
+
elif args.command == "serve":
|
|
173
|
+
# 0 表示由操作系统分配空闲端口(服务实际监听端口见启动日志)
|
|
174
|
+
if not 0 <= args.port <= 65535:
|
|
175
|
+
parser.error("port 必须在 0 到 65535 之间")
|
|
176
|
+
if args.max_tasks < 1 or args.jobs < 1:
|
|
177
|
+
parser.error("max-tasks/jobs 必须为正整数")
|
|
178
|
+
handler = run_serve
|
|
179
|
+
else:
|
|
180
|
+
parser.error(f"未知子命令:{args.command}")
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
handler(args)
|
|
184
|
+
except (HLSException, OSError, requests.RequestException,
|
|
185
|
+
subprocess.CalledProcessError) as exc:
|
|
186
|
+
sys.exit(f"\n错误:{exc}")
|
m3u8_cli/convert.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""视频格式转换(FFmpeg)。"""
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .errors import HLSException
|
|
8
|
+
|
|
9
|
+
HLS_KEY_NAME = "key.bin"
|
|
10
|
+
HLS_KEY_INFO_NAME = ".key_info.txt"
|
|
11
|
+
HLS_KEY_SIZE = 16
|
|
12
|
+
|
|
13
|
+
FORMAT_SUFFIX = {
|
|
14
|
+
"hls": None,
|
|
15
|
+
"mp4": ".mp4",
|
|
16
|
+
"mkv": ".mkv",
|
|
17
|
+
"webm": ".webm",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
CODEC_ARGS = {
|
|
21
|
+
"h264": ["-c:v", "libx264", "-c:a", "aac"],
|
|
22
|
+
"hevc": ["-c:v", "libx265", "-c:a", "aac"],
|
|
23
|
+
"copy": ["-c", "copy"],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
WEBM_CODEC_ARGS = {
|
|
27
|
+
# WebM 不支持 H.264/AAC,重编码统一使用 VP9/Opus。
|
|
28
|
+
"h264": [
|
|
29
|
+
"-c:v", "libvpx-vp9", "-crf", "32", "-b:v", "0",
|
|
30
|
+
"-c:a", "libopus",
|
|
31
|
+
],
|
|
32
|
+
"hevc": [
|
|
33
|
+
"-c:v", "libvpx-vp9", "-crf", "32", "-b:v", "0",
|
|
34
|
+
"-c:a", "libopus",
|
|
35
|
+
],
|
|
36
|
+
"copy": ["-c", "copy"],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve_output_dir(input_path, output_arg):
|
|
41
|
+
"""返回输出目录;未指定时使用输入文件 stem 作为目录名。"""
|
|
42
|
+
if output_arg:
|
|
43
|
+
output_dir = Path(output_arg).expanduser().resolve()
|
|
44
|
+
else:
|
|
45
|
+
output_dir = (Path.cwd() / input_path.stem).resolve()
|
|
46
|
+
|
|
47
|
+
if output_dir.exists() and not output_dir.is_dir():
|
|
48
|
+
raise HLSException(f"输出路径不是目录:{output_dir}")
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
except OSError as exc:
|
|
53
|
+
raise HLSException(f"无法创建输出目录:{output_dir}({exc})") from exc
|
|
54
|
+
return output_dir
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def codec_args(fmt, codec):
|
|
58
|
+
"""返回目标格式对应的 FFmpeg 编码参数。"""
|
|
59
|
+
if fmt == "webm":
|
|
60
|
+
return WEBM_CODEC_ARGS[codec]
|
|
61
|
+
return CODEC_ARGS[codec]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_hls_command(input_path, output_dir, codec, segment_time,
|
|
65
|
+
encrypt=False):
|
|
66
|
+
"""构造 HLS 转换的 FFmpeg 命令。"""
|
|
67
|
+
command = [
|
|
68
|
+
"ffmpeg", "-hide_banner", "-y",
|
|
69
|
+
"-i", str(input_path),
|
|
70
|
+
"-map", "0:v?", "-map", "0:a?",
|
|
71
|
+
*codec_args("hls", codec),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
if codec != "copy":
|
|
75
|
+
command += [
|
|
76
|
+
"-force_key_frames",
|
|
77
|
+
f"expr:gte(t,n_forced*{segment_time})",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
command += [
|
|
81
|
+
"-f", "hls",
|
|
82
|
+
"-hls_time", str(segment_time),
|
|
83
|
+
"-hls_playlist_type", "vod",
|
|
84
|
+
"-hls_list_size", "0",
|
|
85
|
+
"-hls_base_url", "ts/",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
if encrypt:
|
|
89
|
+
command += [
|
|
90
|
+
"-hls_key_info_file",
|
|
91
|
+
str(output_dir / HLS_KEY_INFO_NAME),
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
command += [
|
|
95
|
+
"-hls_segment_filename", str(output_dir / "ts" / "%06d.ts"),
|
|
96
|
+
str(output_dir / "playlist.m3u8"),
|
|
97
|
+
]
|
|
98
|
+
return command
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_file_command(input_path, fmt, codec, partial):
|
|
102
|
+
"""构造单文件格式转换的 FFmpeg 命令。"""
|
|
103
|
+
command = [
|
|
104
|
+
"ffmpeg", "-hide_banner", "-y",
|
|
105
|
+
"-i", str(input_path),
|
|
106
|
+
"-map", "0:v?", "-map", "0:a?",
|
|
107
|
+
*codec_args(fmt, codec),
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
if fmt == "mp4":
|
|
111
|
+
command += ["-movflags", "+faststart"]
|
|
112
|
+
if codec == "hevc":
|
|
113
|
+
command += ["-tag:v", "hvc1"]
|
|
114
|
+
|
|
115
|
+
command.append(str(partial))
|
|
116
|
+
return command
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _clear_hls_output(playlist, segments_dir):
|
|
120
|
+
"""只清理旧播放列表与 6 位编号分片,保留 ts/ 中的其他文件。"""
|
|
121
|
+
playlist.unlink(missing_ok=True)
|
|
122
|
+
if not segments_dir.is_dir():
|
|
123
|
+
return
|
|
124
|
+
for entry in segments_dir.iterdir():
|
|
125
|
+
stem = entry.name[:-3]
|
|
126
|
+
if (
|
|
127
|
+
entry.name.endswith(".ts")
|
|
128
|
+
and len(stem) == 6
|
|
129
|
+
and stem.isascii()
|
|
130
|
+
and stem.isdigit()
|
|
131
|
+
and (entry.is_file() or entry.is_symlink())
|
|
132
|
+
):
|
|
133
|
+
entry.unlink()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _write_hls_key(output_dir):
|
|
137
|
+
"""生成新的 AES-128 密钥并返回其路径。"""
|
|
138
|
+
key_path = output_dir / HLS_KEY_NAME
|
|
139
|
+
if key_path.is_symlink():
|
|
140
|
+
key_path.unlink()
|
|
141
|
+
elif key_path.exists() and key_path.is_dir():
|
|
142
|
+
raise HLSException(f"密钥路径是目录:{key_path}")
|
|
143
|
+
key_path.write_bytes(secrets.token_bytes(HLS_KEY_SIZE))
|
|
144
|
+
return key_path
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _write_key_info(output_dir, key_path):
|
|
148
|
+
"""写入 FFmpeg 的 key-info 临时文件并返回其路径。"""
|
|
149
|
+
key_info = output_dir / HLS_KEY_INFO_NAME
|
|
150
|
+
if key_info.is_symlink():
|
|
151
|
+
key_info.unlink()
|
|
152
|
+
elif key_info.exists() and key_info.is_dir():
|
|
153
|
+
raise HLSException(f"密钥信息路径是目录:{key_info}")
|
|
154
|
+
key_info.write_text(
|
|
155
|
+
f"{HLS_KEY_NAME}\n{key_path}\n",
|
|
156
|
+
encoding="utf-8",
|
|
157
|
+
)
|
|
158
|
+
return key_info
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def convert_video(input_arg, fmt, codec, output_arg,
|
|
162
|
+
segment_time, overwrite, encrypt=False):
|
|
163
|
+
"""转换视频并返回最终产物路径。"""
|
|
164
|
+
if encrypt and fmt != "hls":
|
|
165
|
+
raise HLSException("--encrypt 仅适用于 HLS")
|
|
166
|
+
|
|
167
|
+
input_path = Path(input_arg).expanduser().resolve()
|
|
168
|
+
if not input_path.is_file():
|
|
169
|
+
raise HLSException(f"输入文件不存在:{input_path}")
|
|
170
|
+
|
|
171
|
+
output_dir = resolve_output_dir(input_path, output_arg)
|
|
172
|
+
|
|
173
|
+
if fmt == "hls":
|
|
174
|
+
playlist = output_dir / "playlist.m3u8"
|
|
175
|
+
segments_dir = output_dir / "ts"
|
|
176
|
+
key_path = output_dir / HLS_KEY_NAME
|
|
177
|
+
key_info_path = output_dir / HLS_KEY_INFO_NAME
|
|
178
|
+
|
|
179
|
+
if encrypt:
|
|
180
|
+
if key_path.is_dir() and not key_path.is_symlink():
|
|
181
|
+
raise HLSException(f"密钥路径是目录:{key_path}")
|
|
182
|
+
if key_info_path.is_dir() and not key_info_path.is_symlink():
|
|
183
|
+
raise HLSException(f"密钥信息路径是目录:{key_info_path}")
|
|
184
|
+
|
|
185
|
+
if playlist == input_path:
|
|
186
|
+
raise HLSException(f"输出文件与输入文件相同:{playlist}")
|
|
187
|
+
if segments_dir == input_path or segments_dir in input_path.parents:
|
|
188
|
+
raise HLSException(f"输入文件位于分片目录中:{input_path}")
|
|
189
|
+
if encrypt and (key_path == input_path or key_info_path == input_path):
|
|
190
|
+
raise HLSException(f"输出文件与输入文件相同:{input_path}")
|
|
191
|
+
if segments_dir.is_symlink():
|
|
192
|
+
raise HLSException(f"分片路径是符号链接:{segments_dir}")
|
|
193
|
+
if segments_dir.exists() and not segments_dir.is_dir():
|
|
194
|
+
raise HLSException(f"分片路径不是目录:{segments_dir}")
|
|
195
|
+
if playlist.is_dir():
|
|
196
|
+
raise HLSException(f"播放列表路径不是文件:{playlist}")
|
|
197
|
+
if not overwrite and (
|
|
198
|
+
playlist.exists()
|
|
199
|
+
or playlist.is_symlink()
|
|
200
|
+
or segments_dir.exists()
|
|
201
|
+
or (
|
|
202
|
+
encrypt
|
|
203
|
+
and (
|
|
204
|
+
key_path.exists()
|
|
205
|
+
or key_path.is_symlink()
|
|
206
|
+
or key_info_path.exists()
|
|
207
|
+
or key_info_path.is_symlink()
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
):
|
|
211
|
+
raise HLSException(
|
|
212
|
+
f"目标已存在:{output_dir},使用 --overwrite 覆盖"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if overwrite:
|
|
216
|
+
_clear_hls_output(playlist, segments_dir)
|
|
217
|
+
segments_dir.mkdir(parents=True, exist_ok=True)
|
|
218
|
+
|
|
219
|
+
key_info = None
|
|
220
|
+
if encrypt:
|
|
221
|
+
key_path = _write_hls_key(output_dir)
|
|
222
|
+
try:
|
|
223
|
+
key_info = _write_key_info(output_dir, key_path)
|
|
224
|
+
except BaseException:
|
|
225
|
+
key_path.unlink(missing_ok=True)
|
|
226
|
+
raise
|
|
227
|
+
else:
|
|
228
|
+
key_path = None
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
subprocess.run(
|
|
232
|
+
build_hls_command(
|
|
233
|
+
input_path, output_dir, codec, segment_time, encrypt
|
|
234
|
+
),
|
|
235
|
+
check=True,
|
|
236
|
+
)
|
|
237
|
+
except BaseException:
|
|
238
|
+
if key_path is not None:
|
|
239
|
+
key_path.unlink(missing_ok=True)
|
|
240
|
+
raise
|
|
241
|
+
finally:
|
|
242
|
+
if key_info is not None:
|
|
243
|
+
key_info.unlink(missing_ok=True)
|
|
244
|
+
|
|
245
|
+
return playlist
|
|
246
|
+
|
|
247
|
+
output = output_dir / (input_path.stem + FORMAT_SUFFIX[fmt])
|
|
248
|
+
if output == input_path:
|
|
249
|
+
raise HLSException(f"输出文件与输入文件相同:{output}")
|
|
250
|
+
if output.is_dir():
|
|
251
|
+
raise HLSException(f"输出路径是目录:{output}")
|
|
252
|
+
if not overwrite and (output.exists() or output.is_symlink()):
|
|
253
|
+
raise HLSException(
|
|
254
|
+
f"输出文件已存在:{output},使用 --overwrite 覆盖"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
partial = output_dir / (
|
|
258
|
+
input_path.stem + ".partial" + FORMAT_SUFFIX[fmt]
|
|
259
|
+
)
|
|
260
|
+
if partial.is_symlink():
|
|
261
|
+
partial.unlink()
|
|
262
|
+
elif partial.exists():
|
|
263
|
+
if partial.is_dir():
|
|
264
|
+
raise HLSException(f"临时输出路径是目录:{partial}")
|
|
265
|
+
partial.unlink()
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
subprocess.run(
|
|
269
|
+
build_file_command(input_path, fmt, codec, partial),
|
|
270
|
+
check=True,
|
|
271
|
+
)
|
|
272
|
+
partial.replace(output)
|
|
273
|
+
except Exception:
|
|
274
|
+
if partial.is_file() or partial.is_symlink():
|
|
275
|
+
partial.unlink()
|
|
276
|
+
raise
|
|
277
|
+
return output
|
m3u8_cli/download.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""可复用的 HLS 下载流程。"""
|
|
2
|
+
|
|
3
|
+
import concurrent.futures
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .downloader import Downloader
|
|
9
|
+
from .errors import DownloadCancelled, HLSException
|
|
10
|
+
from .hls_export import export_hls
|
|
11
|
+
from .merge import combine_segments, mux
|
|
12
|
+
from .paths import resolve_task_paths
|
|
13
|
+
from .playlist import load_playlist
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class DownloadOptions:
|
|
18
|
+
url: str
|
|
19
|
+
output: Path
|
|
20
|
+
format: str = "mp4"
|
|
21
|
+
jobs: int = 8
|
|
22
|
+
retries: int = 3
|
|
23
|
+
timeout: int = 30
|
|
24
|
+
headers: dict = field(default_factory=dict)
|
|
25
|
+
overwrite: bool = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _check_cancel(cancel):
|
|
29
|
+
if cancel is not None and cancel.is_set():
|
|
30
|
+
raise DownloadCancelled("下载已取消")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _resolve_paths(options):
|
|
34
|
+
if options.format == "hls":
|
|
35
|
+
task_dir = Path(options.output).expanduser().resolve()
|
|
36
|
+
if task_dir.exists() and not task_dir.is_dir():
|
|
37
|
+
raise HLSException(f"任务目录不是目录:{task_dir}")
|
|
38
|
+
return task_dir / "playlist.m3u8", task_dir / "cache"
|
|
39
|
+
return resolve_task_paths(options.output)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _load_state(state_file):
|
|
43
|
+
if not state_file.exists():
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
return json.loads(state_file.read_text("utf-8"))
|
|
47
|
+
except (OSError, ValueError):
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def download_hls(options, on_progress=None, cancel=None):
|
|
52
|
+
"""下载 HLS 并输出;mp4 返回最终文件,hls 返回任务目录。"""
|
|
53
|
+
if options.format not in ("mp4", "hls"):
|
|
54
|
+
raise HLSException(f"不支持的输出格式:{options.format}")
|
|
55
|
+
|
|
56
|
+
_check_cancel(cancel)
|
|
57
|
+
final, cache = _resolve_paths(options)
|
|
58
|
+
task_dir = cache.parent
|
|
59
|
+
|
|
60
|
+
if final.exists() and not options.overwrite:
|
|
61
|
+
raise HLSException(
|
|
62
|
+
f"输出文件已存在:{final},使用 --overwrite 覆盖"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
|
|
67
|
+
print("读取 m3u8...")
|
|
68
|
+
playlist = load_playlist(options.url, options.headers, options.timeout)
|
|
69
|
+
segments = playlist["segments"]
|
|
70
|
+
fingerprint = playlist["fingerprint"]
|
|
71
|
+
|
|
72
|
+
state_file = cache / "state.json"
|
|
73
|
+
previous = _load_state(state_file)
|
|
74
|
+
if previous and (
|
|
75
|
+
previous.get("fingerprint") != fingerprint
|
|
76
|
+
or previous.get("mode", "mp4") != options.format
|
|
77
|
+
):
|
|
78
|
+
print("播放列表或输出模式已变化,清除旧分片缓存")
|
|
79
|
+
for p in cache.glob("segment_*.bin"):
|
|
80
|
+
p.unlink(missing_ok=True)
|
|
81
|
+
for p in cache.glob("map_*.bin"):
|
|
82
|
+
p.unlink(missing_ok=True)
|
|
83
|
+
|
|
84
|
+
state_file.write_text(
|
|
85
|
+
json.dumps(
|
|
86
|
+
{"fingerprint": fingerprint, "mode": options.format},
|
|
87
|
+
indent=2,
|
|
88
|
+
),
|
|
89
|
+
encoding="utf-8",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
downloader = Downloader(
|
|
93
|
+
options.headers, options.timeout, options.retries
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
key_data = {}
|
|
97
|
+
key_names = {}
|
|
98
|
+
if options.format == "hls":
|
|
99
|
+
key_uris = list(dict.fromkeys(
|
|
100
|
+
s["key"]["uri"] for s in segments if s.get("key")
|
|
101
|
+
))
|
|
102
|
+
for uri in key_uris:
|
|
103
|
+
_check_cancel(cancel)
|
|
104
|
+
key_data[uri] = downloader.get_key(uri)
|
|
105
|
+
for index, uri in enumerate(key_uris):
|
|
106
|
+
key_names[uri] = (
|
|
107
|
+
"key.bin" if len(key_uris) == 1
|
|
108
|
+
else f"key_{index:03d}.bin"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
map_paths = {}
|
|
112
|
+
for i, map_url in enumerate(playlist["maps"]):
|
|
113
|
+
_check_cancel(cancel)
|
|
114
|
+
path = cache / f"map_{i:04d}.bin"
|
|
115
|
+
map_paths[map_url] = downloader.download(
|
|
116
|
+
map_url, path, decrypt=False
|
|
117
|
+
)
|
|
118
|
+
print(f"初始化片段已准备:{i + 1}/{len(playlist['maps'])}")
|
|
119
|
+
|
|
120
|
+
jobs = [
|
|
121
|
+
(i, segment, cache / f"segment_{i:06d}.bin")
|
|
122
|
+
for i, segment in enumerate(segments)
|
|
123
|
+
]
|
|
124
|
+
print(f"共 {len(jobs)} 个分片,并发数 {options.jobs}")
|
|
125
|
+
|
|
126
|
+
completed = 0
|
|
127
|
+
decrypt = options.format == "mp4"
|
|
128
|
+
|
|
129
|
+
with concurrent.futures.ThreadPoolExecutor(
|
|
130
|
+
max_workers=options.jobs
|
|
131
|
+
) as pool:
|
|
132
|
+
pending = set()
|
|
133
|
+
iterator = iter(jobs)
|
|
134
|
+
|
|
135
|
+
def submit_next():
|
|
136
|
+
try:
|
|
137
|
+
i, segment, path = next(iterator)
|
|
138
|
+
except StopIteration:
|
|
139
|
+
return False
|
|
140
|
+
future = pool.submit(
|
|
141
|
+
downloader.download,
|
|
142
|
+
segment["url"],
|
|
143
|
+
path,
|
|
144
|
+
segment["key"],
|
|
145
|
+
segment["sequence"],
|
|
146
|
+
decrypt,
|
|
147
|
+
)
|
|
148
|
+
pending.add(future)
|
|
149
|
+
return True
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
_check_cancel(cancel)
|
|
153
|
+
for _ in range(options.jobs * 2):
|
|
154
|
+
if not submit_next():
|
|
155
|
+
break
|
|
156
|
+
|
|
157
|
+
while pending:
|
|
158
|
+
done, _ = concurrent.futures.wait(
|
|
159
|
+
pending,
|
|
160
|
+
return_when=concurrent.futures.FIRST_COMPLETED,
|
|
161
|
+
)
|
|
162
|
+
for future in done:
|
|
163
|
+
pending.discard(future)
|
|
164
|
+
future.result()
|
|
165
|
+
completed += 1
|
|
166
|
+
if on_progress is not None:
|
|
167
|
+
on_progress(completed, len(jobs))
|
|
168
|
+
_check_cancel(cancel)
|
|
169
|
+
for _ in range(len(done)):
|
|
170
|
+
if not submit_next():
|
|
171
|
+
break
|
|
172
|
+
except BaseException:
|
|
173
|
+
for future in pending:
|
|
174
|
+
future.cancel()
|
|
175
|
+
raise
|
|
176
|
+
|
|
177
|
+
if options.format == "hls":
|
|
178
|
+
_check_cancel(cancel)
|
|
179
|
+
return export_hls(
|
|
180
|
+
playlist,
|
|
181
|
+
cache,
|
|
182
|
+
task_dir,
|
|
183
|
+
key_data,
|
|
184
|
+
key_names,
|
|
185
|
+
options.overwrite,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
print("\n所有分片下载完成,开始本地合并")
|
|
189
|
+
combined = combine_segments(
|
|
190
|
+
segments, playlist["maps"], map_paths, cache
|
|
191
|
+
)
|
|
192
|
+
mux(combined, cache, final)
|
|
193
|
+
return final
|