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 ADDED
@@ -0,0 +1,3 @@
1
+ """HLS 视频下载与格式转换工具。"""
2
+
3
+ __version__ = "0.1.0"
m3u8_cli/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """支持 python -m m3u8_cli。"""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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