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/downloader.py ADDED
@@ -0,0 +1,105 @@
1
+ """分片下载、密钥缓存与 AES-128 解密。"""
2
+
3
+ import re
4
+ import threading
5
+ import time
6
+
7
+ import requests
8
+ from Crypto.Cipher import AES
9
+ from Crypto.Util.Padding import unpad
10
+
11
+ from .errors import HLSException
12
+
13
+
14
+ class Downloader:
15
+ def __init__(self, headers, timeout, retries):
16
+ self.headers = headers
17
+ self.timeout = timeout
18
+ self.retries = retries
19
+ self.local = threading.local()
20
+ self.keys = {}
21
+ self.key_lock = threading.Lock()
22
+
23
+ def session(self):
24
+ if not hasattr(self.local, "session"):
25
+ self.local.session = requests.Session()
26
+ self.local.session.headers.update(self.headers)
27
+ return self.local.session
28
+
29
+ def get_bytes(self, url):
30
+ response = self.session().get(
31
+ url, timeout=self.timeout
32
+ )
33
+ response.raise_for_status()
34
+ return response.content
35
+
36
+ def get_key(self, key_url):
37
+ # 同一密钥只请求一次,避免并发重复拉取。
38
+ with self.key_lock:
39
+ if key_url in self.keys:
40
+ return self.keys[key_url]
41
+
42
+ data = self.get_bytes(key_url)
43
+ if len(data) != 16:
44
+ raise HLSException(
45
+ f"AES-128 密钥长度异常:{len(data)} 字节"
46
+ )
47
+
48
+ self.keys[key_url] = data
49
+ return data
50
+
51
+ def decrypt(self, data, key_info, sequence):
52
+ if not key_info:
53
+ return data
54
+
55
+ key = self.get_key(key_info["uri"])
56
+ iv_text = key_info.get("iv")
57
+
58
+ if iv_text:
59
+ if not re.fullmatch(r"0[xX][0-9a-fA-F]{1,32}", iv_text):
60
+ raise HLSException("无效的 AES IV")
61
+ iv = bytes.fromhex(iv_text[2:].zfill(32))
62
+ else:
63
+ # HLS AES-128 未指定 IV 时使用媒体序列号。
64
+ iv = sequence.to_bytes(16, "big")
65
+
66
+ if not data or len(data) % AES.block_size:
67
+ raise HLSException("AES-128 密文长度不符合 CBC 块要求")
68
+
69
+ cipher = AES.new(key, AES.MODE_CBC, iv)
70
+ plaintext = cipher.decrypt(data)
71
+
72
+ try:
73
+ return unpad(plaintext, AES.block_size)
74
+ except ValueError as exc:
75
+ raise HLSException(
76
+ "AES 解密后的填充无效,请检查密钥、IV 和内容类型"
77
+ ) from exc
78
+
79
+ def download(
80
+ self, url, path, key_info=None, sequence=0, decrypt=True
81
+ ):
82
+ if path.exists() and path.stat().st_size > 0:
83
+ return path
84
+
85
+ last_error = None
86
+
87
+ for attempt in range(self.retries + 1):
88
+ try:
89
+ data = self.get_bytes(url)
90
+ if decrypt:
91
+ data = self.decrypt(data, key_info, sequence)
92
+
93
+ temp = path.with_suffix(path.suffix + ".part")
94
+ temp.write_bytes(data)
95
+ temp.replace(path)
96
+ return path
97
+
98
+ except Exception as exc:
99
+ last_error = exc
100
+ if attempt < self.retries:
101
+ time.sleep(min(2 ** attempt, 10))
102
+
103
+ raise HLSException(
104
+ f"下载失败:{url}\n最后错误:{last_error}"
105
+ )
m3u8_cli/errors.py ADDED
@@ -0,0 +1,6 @@
1
+ class HLSException(RuntimeError):
2
+ pass
3
+
4
+
5
+ class DownloadCancelled(HLSException):
6
+ """任务被协作式取消。"""
m3u8_cli/hls_export.py ADDED
@@ -0,0 +1,151 @@
1
+ """把已下载的 HLS 资源导出为本地目录。"""
2
+
3
+ import math
4
+ import re
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ from .errors import HLSException
9
+
10
+ SEGMENT_RE = re.compile(r"\d{6}\.(?:ts|m4s)")
11
+ KEY_RE = re.compile(r"key(?:_\d+)?\.bin")
12
+
13
+
14
+ def segment_suffix(playlist):
15
+ """fMP4 用 .m4s,其余沿用 .ts。"""
16
+ return ".m4s" if playlist["maps"] else ".ts"
17
+
18
+
19
+ def build_playlist(playlist, key_names):
20
+ """生成引用本地文件的可播放媒体播放列表。"""
21
+ suffix = segment_suffix(playlist)
22
+ segments = playlist["segments"]
23
+ durations = [float(s.get("duration") or 0) for s in segments]
24
+ target = max(1, math.ceil(max(durations) if durations else 0))
25
+
26
+ lines = [
27
+ "#EXTM3U",
28
+ "#EXT-X-VERSION:6" if playlist["maps"] else "#EXT-X-VERSION:3",
29
+ f"#EXT-X-TARGETDURATION:{target}",
30
+ "#EXT-X-PLAYLIST-TYPE:VOD",
31
+ f"#EXT-X-MEDIA-SEQUENCE:{playlist['media_sequence']}",
32
+ ]
33
+ if playlist["maps"]:
34
+ lines.append('#EXT-X-MAP:URI="init.mp4"')
35
+
36
+ previous_key = None
37
+ for i, segment in enumerate(segments):
38
+ key = segment.get("key")
39
+ if key is None and previous_key is not None:
40
+ lines.append("#EXT-X-KEY:METHOD=NONE")
41
+ elif key is not None and key != previous_key:
42
+ attrs = [
43
+ f'METHOD={key["method"]}',
44
+ f'URI="{key_names[key["uri"]]}"',
45
+ ]
46
+ if key.get("iv"):
47
+ attrs.append(f'IV={key["iv"]}')
48
+ lines.append("#EXT-X-KEY:" + ",".join(attrs))
49
+ previous_key = key
50
+ lines.append(f"#EXTINF:{durations[i]:.3f},")
51
+ lines.append(f"{i:06d}{suffix}")
52
+ lines.append("#EXT-X-ENDLIST")
53
+ return "\n".join(lines) + "\n"
54
+
55
+
56
+ def _clear_output(output_dir):
57
+ """只删除本工具生成的播放列表、分片、初始化片段与密钥。"""
58
+ for entry in output_dir.iterdir():
59
+ if not (entry.is_file() or entry.is_symlink()):
60
+ continue
61
+ if (
62
+ entry.name == "playlist.m3u8"
63
+ or entry.name == "init.mp4"
64
+ or SEGMENT_RE.fullmatch(entry.name)
65
+ or KEY_RE.fullmatch(entry.name)
66
+ ):
67
+ entry.unlink()
68
+
69
+
70
+ def _targets(playlist, key_names):
71
+ """返回本次导出会写入的所有目标路径。"""
72
+ suffix = segment_suffix(playlist)
73
+ targets = [f"{i:06d}{suffix}"
74
+ for i in range(len(playlist["segments"]))]
75
+ if playlist["maps"]:
76
+ targets.append("init.mp4")
77
+ targets.append("playlist.m3u8")
78
+ targets.extend(key_names.values())
79
+ return targets
80
+
81
+
82
+ def _check_targets(playlist, output_dir, key_names, overwrite):
83
+ if output_dir.exists() and not output_dir.is_dir():
84
+ raise HLSException(f"输出路径不是目录:{output_dir}")
85
+ if len(playlist["maps"]) > 1:
86
+ raise HLSException("不支持多个初始化片段")
87
+
88
+ for name in _targets(playlist, key_names):
89
+ target = output_dir / name
90
+ if target.is_dir():
91
+ raise HLSException(f"目标路径是目录:{target}")
92
+ if not overwrite and target.exists():
93
+ raise HLSException(
94
+ f"目标已存在:{target},使用 --overwrite 覆盖"
95
+ )
96
+
97
+
98
+ def _check_sources(playlist, cache):
99
+ for i in range(len(playlist["segments"])):
100
+ source = cache / f"segment_{i:06d}.bin"
101
+ if not source.is_file():
102
+ raise HLSException(
103
+ f"缓存分片缺失:{source};请删除 cache 目录后重试"
104
+ )
105
+ if playlist["maps"]:
106
+ source = cache / "map_0000.bin"
107
+ if not source.is_file():
108
+ raise HLSException(
109
+ f"缓存初始化片段缺失:{source};请删除 cache 目录后重试"
110
+ )
111
+
112
+
113
+ def export_hls(playlist, cache, output_dir, key_data, key_names,
114
+ overwrite):
115
+ """把缓存分片、初始化片段与密钥导出到本地 HLS 目录。"""
116
+ _check_targets(playlist, output_dir, key_names, overwrite)
117
+ _check_sources(playlist, cache)
118
+
119
+ try:
120
+ output_dir.mkdir(parents=True, exist_ok=True)
121
+ except OSError as exc:
122
+ raise HLSException(
123
+ f"无法创建输出目录:{output_dir}({exc})"
124
+ ) from exc
125
+
126
+ if overwrite:
127
+ _clear_output(output_dir)
128
+
129
+ suffix = segment_suffix(playlist)
130
+ for i in range(len(playlist["segments"])):
131
+ source = cache / f"segment_{i:06d}.bin"
132
+ shutil.copyfile(source, output_dir / f"{i:06d}{suffix}")
133
+
134
+ if playlist["maps"]:
135
+ shutil.copyfile(
136
+ cache / "map_0000.bin", output_dir / "init.mp4"
137
+ )
138
+
139
+ for uri, name in key_names.items():
140
+ if uri not in key_data:
141
+ raise HLSException(f"缺少密钥数据:{uri}")
142
+ (output_dir / name).write_bytes(key_data[uri])
143
+
144
+ part = output_dir / "playlist.m3u8.part"
145
+ try:
146
+ part.write_text(build_playlist(playlist, key_names), "utf-8")
147
+ part.replace(output_dir / "playlist.m3u8")
148
+ except BaseException:
149
+ part.unlink(missing_ok=True)
150
+ raise
151
+ return output_dir
m3u8_cli/merge.py ADDED
@@ -0,0 +1,43 @@
1
+ """本地分片合并与 FFmpeg 封装。"""
2
+
3
+ import shutil
4
+ import subprocess
5
+
6
+
7
+ def combine_segments(segments, maps, map_paths, cache):
8
+ """按播放列表顺序把已解密分片写入 combined 文件。"""
9
+ is_fmp4 = bool(maps)
10
+ combined = cache / ("combined.mp4" if is_fmp4 else "combined.ts")
11
+
12
+ with combined.open("wb") as out:
13
+ # fMP4 先写初始化片段,再拼接媒体分片。
14
+ if is_fmp4:
15
+ out.write(map_paths[maps[0]].read_bytes())
16
+
17
+ for i in range(len(segments)):
18
+ path = cache / f"segment_{i:06d}.bin"
19
+ with path.open("rb") as src:
20
+ shutil.copyfileobj(src, out, length=1024 * 1024)
21
+
22
+ return combined
23
+
24
+
25
+ def mux(combined, cache, output):
26
+ """把合并文件无损封装为最终 MP4,成功后原子替换。"""
27
+ # 临时文件与最终文件同属一个任务目录,重命名保持原子性。
28
+ temp_output = cache / (output.stem + ".partial" + output.suffix)
29
+ command = [
30
+ "ffmpeg", "-hide_banner", "-y",
31
+ "-i", str(combined),
32
+ "-map", "0",
33
+ "-c", "copy",
34
+ "-movflags", "+faststart",
35
+ str(temp_output),
36
+ ]
37
+
38
+ try:
39
+ subprocess.run(command, check=True)
40
+ temp_output.replace(output)
41
+ except Exception:
42
+ temp_output.unlink(missing_ok=True)
43
+ raise
m3u8_cli/paths.py ADDED
@@ -0,0 +1,30 @@
1
+ """任务子目录路径派生。"""
2
+
3
+ from pathlib import Path
4
+
5
+ from .errors import HLSException
6
+
7
+
8
+ def derive_task_paths(output_arg):
9
+ """返回最终文件路径和缓存目录,不创建任何目录。"""
10
+ output = Path(output_arg).expanduser().resolve()
11
+
12
+ # -o output/name.mp4 时直接复用 output/,避免嵌套成 output/output/。
13
+ if output.parent.name == output.stem:
14
+ task_dir = output.parent
15
+ else:
16
+ task_dir = output.parent / output.stem
17
+
18
+ return output, task_dir / "cache"
19
+
20
+
21
+ def resolve_task_paths(output_arg):
22
+ """返回任务目录内的最终文件路径和缓存目录,并创建任务目录。"""
23
+ output, cache = derive_task_paths(output_arg)
24
+ task_dir = cache.parent
25
+
26
+ if task_dir.exists() and not task_dir.is_dir():
27
+ raise HLSException(f"任务目录路径不是目录:{task_dir}")
28
+
29
+ task_dir.mkdir(parents=True, exist_ok=True)
30
+ return output, cache
m3u8_cli/playlist.py ADDED
@@ -0,0 +1,165 @@
1
+ """m3u8 播放列表获取与解析。"""
2
+
3
+ import hashlib
4
+ import math
5
+ import re
6
+ from urllib.parse import urljoin
7
+
8
+ import requests
9
+
10
+ from .errors import HLSException
11
+
12
+
13
+ def parse_attrs(line):
14
+ """解析 EXT-X-KEY 等标签中的属性,支持引号内逗号。"""
15
+ result = {}
16
+ pattern = r'([A-Z0-9-]+)=("(?:[^"\\]|\\.)*"|[^,]*)'
17
+ for key, value in re.findall(pattern, line):
18
+ value = value.strip()
19
+ if value.startswith('"') and value.endswith('"'):
20
+ value = value[1:-1]
21
+ result[key] = value
22
+ return result
23
+
24
+
25
+ def request_text(url, headers, timeout):
26
+ with requests.get(
27
+ url, headers=headers, timeout=timeout
28
+ ) as response:
29
+ response.raise_for_status()
30
+ return response.text
31
+
32
+
33
+ def load_playlist(url, headers, timeout, depth=0):
34
+ """解析媒体播放列表;如果是 master playlist,选择最高带宽变体。"""
35
+ if depth > 5:
36
+ raise HLSException("主播放列表嵌套层数过多")
37
+
38
+ text = request_text(url, headers, timeout)
39
+ lines = [x.strip() for x in text.splitlines() if x.strip()]
40
+
41
+ if not lines or lines[0] != "#EXTM3U":
42
+ raise HLSException("服务器返回的内容不是有效的 M3U8")
43
+
44
+ variants = []
45
+ for i, line in enumerate(lines):
46
+ if line.startswith("#EXT-X-STREAM-INF:"):
47
+ attrs = parse_attrs(line)
48
+ for following in lines[i + 1:]:
49
+ if not following.startswith("#"):
50
+ try:
51
+ bandwidth = int(attrs.get("BANDWIDTH", "0"))
52
+ except ValueError:
53
+ bandwidth = 0
54
+ variants.append(
55
+ (bandwidth, urljoin(url, following))
56
+ )
57
+ break
58
+
59
+ if variants:
60
+ variants.sort(reverse=True)
61
+ print(f"发现 {len(variants)} 个清晰度,选择最高带宽变体")
62
+ return load_playlist(
63
+ variants[0][1], headers, timeout, depth + 1
64
+ )
65
+
66
+ media_sequence = 0
67
+ key = None
68
+ current_map = None
69
+ segments = []
70
+ maps = []
71
+ pending_map = None
72
+ pending_duration = 0.0
73
+
74
+ for line in lines:
75
+ if line.startswith("#EXT-X-MEDIA-SEQUENCE:"):
76
+ try:
77
+ media_sequence = int(line.split(":", 1)[1])
78
+ except ValueError as exc:
79
+ raise HLSException("无效的 MEDIA-SEQUENCE") from exc
80
+
81
+ elif line.startswith("#EXTINF:"):
82
+ try:
83
+ pending_duration = float(
84
+ line.split(":", 1)[1].split(",", 1)[0]
85
+ )
86
+ except ValueError as exc:
87
+ raise HLSException("无效的 EXTINF") from exc
88
+ if not math.isfinite(pending_duration):
89
+ raise HLSException("无效的 EXTINF")
90
+
91
+ elif line.startswith("#EXT-X-KEY:"):
92
+ attrs = parse_attrs(line)
93
+ method = attrs.get("METHOD", "")
94
+
95
+ if method == "NONE":
96
+ key = None
97
+ elif method == "AES-128":
98
+ keyformat = attrs.get("KEYFORMAT", "identity")
99
+ if keyformat != "identity":
100
+ raise HLSException(
101
+ f"不支持的 KEYFORMAT:{keyformat}"
102
+ )
103
+ if not attrs.get("URI"):
104
+ raise HLSException("AES-128 标签缺少密钥 URI")
105
+ key = {
106
+ "method": method,
107
+ "uri": urljoin(url, attrs["URI"]),
108
+ "iv": attrs.get("IV"),
109
+ }
110
+ else:
111
+ raise HLSException(
112
+ f"不支持的加密方式:{method};不处理 DRM"
113
+ )
114
+
115
+ elif line.startswith("#EXT-X-MAP:"):
116
+ attrs = parse_attrs(line)
117
+ if not attrs.get("URI"):
118
+ raise HLSException("EXT-X-MAP 缺少 URI")
119
+ if "BYTERANGE" in attrs:
120
+ raise HLSException("暂不支持初始化片段 BYTERANGE")
121
+ pending_map = urljoin(url, attrs["URI"])
122
+
123
+ elif line.startswith("#EXT-X-BYTERANGE:"):
124
+ raise HLSException(
125
+ "暂不支持 EXT-X-BYTERANGE,请使用完整分片 URL"
126
+ )
127
+
128
+ elif not line.startswith("#"):
129
+ segment_key = dict(key) if key else None
130
+ segment_map = pending_map or current_map
131
+
132
+ if pending_map:
133
+ current_map = pending_map
134
+ pending_map = None
135
+
136
+ segments.append({
137
+ "url": urljoin(url, line),
138
+ "sequence": media_sequence + len(segments),
139
+ "duration": pending_duration,
140
+ "key": segment_key,
141
+ "map": segment_map,
142
+ })
143
+ pending_duration = 0.0
144
+
145
+ if not segments:
146
+ raise HLSException("播放列表中没有媒体分片")
147
+
148
+ unique_maps = list(dict.fromkeys(
149
+ s["map"] for s in segments if s["map"]
150
+ ))
151
+ if len(unique_maps) > 1:
152
+ raise HLSException("检测到多个不同的初始化片段,当前版本不支持")
153
+
154
+ if unique_maps:
155
+ maps = unique_maps
156
+
157
+ return {
158
+ "playlist_url": url,
159
+ "segments": segments,
160
+ "maps": maps,
161
+ "media_sequence": media_sequence,
162
+ "fingerprint": hashlib.sha256(
163
+ text.encode("utf-8")
164
+ ).hexdigest(),
165
+ }