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/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
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
|
+
}
|