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/serve.py ADDED
@@ -0,0 +1,720 @@
1
+ """RESTful 下载服务。"""
2
+
3
+ import hmac
4
+ import json
5
+ import secrets
6
+ import threading
7
+ import time
8
+ import uuid
9
+ from dataclasses import dataclass, field
10
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
11
+ from pathlib import Path
12
+ from urllib.parse import parse_qs, urlsplit
13
+
14
+ from .download import DownloadOptions, download_hls
15
+ from .errors import DownloadCancelled, HLSException
16
+ from .paths import derive_task_paths
17
+
18
+ KEEP_TERMINAL = 200
19
+ TERMINAL_STATES = {"succeeded", "failed", "cancelled", "interrupted"}
20
+
21
+
22
+ class RequestError(ValueError):
23
+ pass
24
+
25
+
26
+ class TaskConflict(RuntimeError):
27
+ pass
28
+
29
+
30
+ class TaskNotFound(KeyError):
31
+ pass
32
+
33
+
34
+ def valid_output_name(name):
35
+ return (
36
+ isinstance(name, str)
37
+ and name not in ("", ".", "..")
38
+ and len(name) <= 200
39
+ and "/" not in name
40
+ and "\\" not in name
41
+ and "\x00" not in name
42
+ and not name.endswith((".", " "))
43
+ )
44
+
45
+
46
+ def utcnow():
47
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
48
+
49
+
50
+ @dataclass
51
+ class TaskRecord:
52
+ id: str
53
+ url: str
54
+ format: str
55
+ output: str
56
+ state: str = "queued"
57
+ final_path: str | None = None
58
+ created_at: str = field(default_factory=utcnow)
59
+ started_at: str | None = None
60
+ finished_at: str | None = None
61
+ completed: int = 0
62
+ total: int = 0
63
+ error: str | None = None
64
+
65
+ def to_dict(self):
66
+ return {
67
+ "id": self.id,
68
+ "url": self.url,
69
+ "format": self.format,
70
+ "output": self.output,
71
+ "state": self.state,
72
+ "final_path": self.final_path,
73
+ "created_at": self.created_at,
74
+ "started_at": self.started_at,
75
+ "finished_at": self.finished_at,
76
+ "completed": self.completed,
77
+ "total": self.total,
78
+ "error": self.error,
79
+ }
80
+
81
+
82
+ class TaskManager:
83
+ def __init__(self, output_dir, max_tasks=3, default_jobs=8,
84
+ state_file=None, runner=None):
85
+ self.output_dir = Path(output_dir).expanduser().resolve()
86
+ self.output_dir.mkdir(parents=True, exist_ok=True)
87
+ self.max_tasks = max_tasks
88
+ self.default_jobs = default_jobs
89
+ self.state_file = (
90
+ Path(state_file).expanduser()
91
+ if state_file is not None
92
+ else self.output_dir / ".m3u8-serve-state.json"
93
+ )
94
+ self.state_file.parent.mkdir(parents=True, exist_ok=True)
95
+ self.runner = runner or download_hls
96
+
97
+ self.lock = threading.RLock()
98
+ self.tasks = {}
99
+ self.order = []
100
+ self.queue = []
101
+ self.running = set()
102
+ self._params = {}
103
+ self._cancels = {}
104
+ self._dirty = False
105
+ self._stop = threading.Event()
106
+ self._workers = {}
107
+ self._closed = False
108
+
109
+ self._load()
110
+ self._flusher = threading.Thread(
111
+ target=self._flush_loop, daemon=True
112
+ )
113
+ self._flusher.start()
114
+
115
+ def _task_paths(self, fmt, output):
116
+ if fmt == "mp4":
117
+ final, cache = derive_task_paths(self.output_dir / output)
118
+ return cache.parent / final.name, cache.parent
119
+ task_dir = (self.output_dir / output).resolve()
120
+ return task_dir / "playlist.m3u8", task_dir
121
+
122
+ def submit(self, *, url, fmt, output, jobs, retries, timeout,
123
+ overwrite, headers):
124
+ if self._closed:
125
+ raise RequestError("服务正在关闭")
126
+ if fmt not in ("mp4", "hls"):
127
+ raise RequestError(f"不支持的格式:{fmt}")
128
+ if not valid_output_name(output):
129
+ raise RequestError("output 必须是纯文件名")
130
+
131
+ final, task_dir = self._task_paths(fmt, output)
132
+ key = str(task_dir)
133
+
134
+ with self.lock:
135
+ if task_dir.exists() and not task_dir.is_dir():
136
+ raise TaskConflict(
137
+ f"目标路径不是目录:{task_dir}"
138
+ )
139
+ for task in self.tasks.values():
140
+ if task.state in ("queued", "running"):
141
+ _, active_dir = self._task_paths(
142
+ task.format, task.output
143
+ )
144
+ if str(active_dir) == key:
145
+ raise TaskConflict(
146
+ f"输出正在被任务 {task.id} 使用"
147
+ )
148
+ if not overwrite and final.exists():
149
+ raise TaskConflict(
150
+ f"目标已存在:{final},使用 overwrite 覆盖"
151
+ )
152
+
153
+ record = TaskRecord(
154
+ id=uuid.uuid4().hex,
155
+ url=url,
156
+ format=fmt,
157
+ output=output,
158
+ )
159
+ self.tasks[record.id] = record
160
+ self.order.append(record.id)
161
+ self.queue.append(record.id)
162
+ self._params[record.id] = {
163
+ "url": url,
164
+ "format": fmt,
165
+ "output": self.output_dir / output,
166
+ "jobs": jobs,
167
+ "retries": retries,
168
+ "timeout": timeout,
169
+ "headers": dict(headers),
170
+ "overwrite": overwrite,
171
+ }
172
+ self._cancels[record.id] = threading.Event()
173
+ self._dispatch_locked()
174
+ self._save_locked()
175
+ return record.to_dict()
176
+
177
+ def _dispatch_locked(self):
178
+ if self._closed:
179
+ return
180
+ while len(self.running) < self.max_tasks and self.queue:
181
+ task_id = self.queue.pop(0)
182
+ task = self.tasks.get(task_id)
183
+ if task is None or task.state != "queued":
184
+ continue
185
+ task.state = "running"
186
+ task.started_at = utcnow()
187
+ self.running.add(task_id)
188
+ thread = threading.Thread(
189
+ target=self._run, args=(task_id,), daemon=True
190
+ )
191
+ self._workers[task_id] = thread
192
+ thread.start()
193
+
194
+ def _run(self, task_id):
195
+ with self.lock:
196
+ params = self._params.pop(task_id, None)
197
+ cancel = self._cancels.get(task_id)
198
+ if params is None or cancel is None:
199
+ self._finish(task_id, "failed", error="任务参数缺失")
200
+ return
201
+
202
+ options = DownloadOptions(**params)
203
+
204
+ def on_progress(completed, total):
205
+ with self.lock:
206
+ task = self.tasks.get(task_id)
207
+ if task is None or task.state != "running":
208
+ return
209
+ task.completed = completed
210
+ task.total = total
211
+ self._dirty = True
212
+
213
+ try:
214
+ result = self.runner(
215
+ options, on_progress=on_progress, cancel=cancel
216
+ )
217
+ except DownloadCancelled:
218
+ self._finish(task_id, "cancelled")
219
+ except Exception as exc:
220
+ self._finish(task_id, "failed", error=str(exc))
221
+ else:
222
+ self._finish(
223
+ task_id, "succeeded", final_path=str(result)
224
+ )
225
+
226
+ def _finish(self, task_id, state, error=None, final_path=None):
227
+ with self.lock:
228
+ task = self.tasks.get(task_id)
229
+ self.running.discard(task_id)
230
+ self._workers.pop(task_id, None)
231
+ self._cancels.pop(task_id, None)
232
+ if task is not None and task.state == "running":
233
+ task.state = state
234
+ task.finished_at = utcnow()
235
+ task.error = error
236
+ if final_path is not None:
237
+ task.final_path = final_path
238
+ self._dispatch_locked()
239
+ self._prune_locked()
240
+ self._save_locked()
241
+
242
+ def cancel(self, task_id):
243
+ with self.lock:
244
+ if self._closed:
245
+ raise TaskConflict("服务正在关闭")
246
+ task = self.tasks.get(task_id)
247
+ if task is None:
248
+ raise TaskNotFound(task_id)
249
+ if task.state == "queued":
250
+ self.queue.remove(task_id)
251
+ task.state = "cancelled"
252
+ task.finished_at = utcnow()
253
+ self._params.pop(task_id, None)
254
+ self._cancels.pop(task_id, None)
255
+ self._prune_locked()
256
+ self._save_locked()
257
+ return task.to_dict()
258
+ if task.state == "running":
259
+ event = self._cancels.get(task_id)
260
+ if event is not None:
261
+ event.set()
262
+ return task.to_dict()
263
+ raise TaskConflict(f"任务已结束:{task.state}")
264
+
265
+ def get(self, task_id):
266
+ with self.lock:
267
+ task = self.tasks.get(task_id)
268
+ if task is None:
269
+ raise TaskNotFound(task_id)
270
+ return task.to_dict()
271
+
272
+ def list(self, state=None, limit=50, offset=0):
273
+ with self.lock:
274
+ records = [
275
+ self.tasks[i].to_dict()
276
+ for i in reversed(self.order)
277
+ if i in self.tasks
278
+ ]
279
+ if state is not None:
280
+ records = [r for r in records if r["state"] == state]
281
+ total = len(records)
282
+ return records[offset:offset + limit], total
283
+
284
+ def _load(self):
285
+ if not self.state_file.exists():
286
+ return
287
+ try:
288
+ data = json.loads(self.state_file.read_text("utf-8"))
289
+ except (OSError, ValueError):
290
+ return
291
+ if not isinstance(data, dict):
292
+ return
293
+ for item in data.get("tasks", []):
294
+ try:
295
+ task = TaskRecord(**item)
296
+ except TypeError:
297
+ continue
298
+ if task.state in ("queued", "running"):
299
+ task.state = "interrupted"
300
+ task.finished_at = task.finished_at or utcnow()
301
+ self.tasks[task.id] = task
302
+ self.order.append(task.id)
303
+ self._prune_locked()
304
+
305
+ def _prune_locked(self):
306
+ terminal = [
307
+ task_id for task_id in self.order
308
+ if self.tasks.get(task_id) is not None
309
+ and self.tasks[task_id].state in TERMINAL_STATES
310
+ ]
311
+ for task_id in terminal[:-KEEP_TERMINAL]:
312
+ self.tasks.pop(task_id, None)
313
+ self.order.remove(task_id)
314
+ self._params.pop(task_id, None)
315
+ self._cancels.pop(task_id, None)
316
+
317
+ def _save_locked(self):
318
+ payload = {
319
+ "version": 1,
320
+ "tasks": [
321
+ self.tasks[i].to_dict()
322
+ for i in self.order
323
+ if i in self.tasks
324
+ ],
325
+ }
326
+ temp = self.state_file.with_name(
327
+ self.state_file.name + ".part"
328
+ )
329
+ try:
330
+ temp.write_text(
331
+ json.dumps(payload, ensure_ascii=False, indent=2),
332
+ "utf-8",
333
+ )
334
+ temp.replace(self.state_file)
335
+ except OSError as exc:
336
+ temp.unlink(missing_ok=True)
337
+ print(f"状态保存失败:{exc}")
338
+ return
339
+ self._dirty = False
340
+
341
+ def _flush_loop(self):
342
+ while not self._stop.wait(2):
343
+ with self.lock:
344
+ if self._dirty:
345
+ self._save_locked()
346
+
347
+ def close(self, timeout=5.0):
348
+ with self.lock:
349
+ if self._closed:
350
+ return
351
+ self._closed = True
352
+ self._stop.set()
353
+ for event in self._cancels.values():
354
+ event.set()
355
+ for task in self.tasks.values():
356
+ if task.state in ("queued", "running"):
357
+ task.state = "interrupted"
358
+ task.finished_at = (
359
+ task.finished_at or utcnow()
360
+ )
361
+ self._prune_locked()
362
+ workers = list(self._workers.values())
363
+
364
+ deadline = time.monotonic() + timeout
365
+ for thread in workers:
366
+ remaining = deadline - time.monotonic()
367
+ if remaining <= 0:
368
+ break
369
+ thread.join(remaining)
370
+
371
+ with self.lock:
372
+ self._save_locked()
373
+
374
+
375
+ MAX_BODY = 1024 * 1024
376
+ MAX_HEADERS_BYTES = 16 * 1024
377
+
378
+
379
+ def _int_field(payload, name, default, minimum, maximum):
380
+ value = payload.get(name, default)
381
+ if not isinstance(value, int) or isinstance(value, bool):
382
+ raise RequestError(f"{name} 必须是整数")
383
+ if value < minimum or value > maximum:
384
+ raise RequestError(
385
+ f"{name} 必须在 {minimum} 到 {maximum} 之间"
386
+ )
387
+ return value
388
+
389
+
390
+ def parse_task_request(payload, default_jobs=8):
391
+ """校验 POST /api/tasks 的请求体并返回 submit 参数。"""
392
+ if not isinstance(payload, dict):
393
+ raise RequestError("请求体必须是 JSON 对象")
394
+
395
+ url = payload.get("url")
396
+ if not isinstance(url, str) or not url:
397
+ raise RequestError("url 不能为空")
398
+ try:
399
+ parts = urlsplit(url)
400
+ hostname = parts.hostname
401
+ parts.port
402
+ except ValueError as exc:
403
+ raise RequestError("url 无效") from exc
404
+ if (
405
+ parts.scheme not in ("http", "https")
406
+ or not parts.netloc
407
+ or not hostname
408
+ ):
409
+ raise RequestError("url 必须是 http 或 https 地址")
410
+ if any(
411
+ ch.isspace() or ord(ch) < 32 for ch in hostname
412
+ ):
413
+ raise RequestError("url 无效")
414
+
415
+ fmt = payload.get("format", "mp4")
416
+ if fmt not in ("mp4", "hls"):
417
+ raise RequestError("format 必须是 mp4 或 hls")
418
+
419
+ output = payload.get("output")
420
+ if output is None:
421
+ output = "output" if fmt == "hls" else "output.mp4"
422
+ if not valid_output_name(output):
423
+ raise RequestError("output 必须是纯文件名")
424
+
425
+ overwrite = payload.get("overwrite", False)
426
+ if not isinstance(overwrite, bool):
427
+ raise RequestError("overwrite 必须是布尔值")
428
+
429
+ headers = payload.get("headers", {})
430
+ if not isinstance(headers, dict) or not all(
431
+ isinstance(k, str) and isinstance(v, str)
432
+ for k, v in headers.items()
433
+ ):
434
+ raise RequestError("headers 必须是字符串映射")
435
+ for name, value in headers.items():
436
+ if any(
437
+ ch in name or ch in value
438
+ for ch in ("\r", "\n", "\x00")
439
+ ):
440
+ raise RequestError("headers 包含非法字符")
441
+ if sum(
442
+ len(k) + len(v) for k, v in headers.items()
443
+ ) > MAX_HEADERS_BYTES:
444
+ raise RequestError("headers 过大")
445
+
446
+ return {
447
+ "url": url,
448
+ "fmt": fmt,
449
+ "output": output,
450
+ "jobs": _int_field(payload, "jobs", default_jobs, 1, 64),
451
+ "retries": _int_field(payload, "retries", 3, 0, 10),
452
+ "timeout": _int_field(payload, "timeout", 30, 1, 600),
453
+ "overwrite": overwrite,
454
+ "headers": headers,
455
+ }
456
+
457
+
458
+ def make_handler(manager, token):
459
+ """创建绑定到指定 TaskManager 与 token 的请求处理器。"""
460
+ if not token:
461
+ raise ValueError("token 不能为空")
462
+ valid_states = {
463
+ "queued", "running", "succeeded",
464
+ "failed", "cancelled", "interrupted",
465
+ }
466
+
467
+ class ApiHandler(BaseHTTPRequestHandler):
468
+ server_version = "m3u8-cli"
469
+
470
+ def _cors(self):
471
+ self.send_header("Access-Control-Allow-Origin", "*")
472
+ self.send_header(
473
+ "Access-Control-Allow-Headers",
474
+ "Authorization, Content-Type",
475
+ )
476
+ self.send_header(
477
+ "Access-Control-Allow-Methods",
478
+ "GET, POST, DELETE, OPTIONS",
479
+ )
480
+
481
+ def _send_json(self, status, payload):
482
+ body = json.dumps(
483
+ payload, ensure_ascii=False
484
+ ).encode("utf-8")
485
+ try:
486
+ self.send_response(status)
487
+ self.send_header(
488
+ "Content-Type",
489
+ "application/json; charset=utf-8",
490
+ )
491
+ self.send_header(
492
+ "Content-Length", str(len(body))
493
+ )
494
+ self._cors()
495
+ self.end_headers()
496
+ self.wfile.write(body)
497
+ except (BrokenPipeError, ConnectionResetError):
498
+ self.close_connection = True
499
+
500
+ def _authorized(self):
501
+ header = self.headers.get("Authorization", "")
502
+ scheme, _, value = header.partition(" ")
503
+ if scheme.lower() != "bearer":
504
+ return False
505
+ try:
506
+ return hmac.compare_digest(
507
+ value.strip(), token
508
+ )
509
+ except TypeError:
510
+ return False
511
+
512
+ def _require_auth(self):
513
+ if not self._authorized():
514
+ self.close_connection = True
515
+ self._send_json(401, {"error": "未授权"})
516
+ return False
517
+ return True
518
+
519
+ def _path_parts(self):
520
+ return urlsplit(self.path).path.rstrip("/").split("/")
521
+
522
+ def _invalid_path(self):
523
+ return any(
524
+ ord(ch) < 32 or ord(ch) == 127
525
+ for ch in self.path
526
+ )
527
+
528
+ def do_OPTIONS(self):
529
+ if self._invalid_path():
530
+ self.close_connection = True
531
+ self._send_json(400, {"error": "无效的请求路径"})
532
+ return
533
+ self.send_response(204)
534
+ self._cors()
535
+ self.send_header("Content-Length", "0")
536
+ self.end_headers()
537
+
538
+ def do_GET(self):
539
+ if not self._require_auth():
540
+ return
541
+ if self._invalid_path():
542
+ self.close_connection = True
543
+ self._send_json(400, {"error": "无效的请求路径"})
544
+ return
545
+ try:
546
+ self._handle_get()
547
+ except Exception as exc:
548
+ self._send_json(500, {"error": str(exc)})
549
+
550
+ def _handle_get(self):
551
+ parts = self._path_parts()
552
+
553
+ if parts == ["", "api", "tasks"]:
554
+ query = parse_qs(urlsplit(self.path).query)
555
+ state = query.get("state", [None])[0]
556
+ if state is not None and state not in valid_states:
557
+ self._send_json(400, {"error": "无效的 state"})
558
+ return
559
+ try:
560
+ limit = int(query.get("limit", ["50"])[0])
561
+ offset = int(query.get("offset", ["0"])[0])
562
+ except ValueError:
563
+ self._send_json(
564
+ 400, {"error": "limit/offset 必须是整数"}
565
+ )
566
+ return
567
+ if limit < 1 or limit > 500 or offset < 0:
568
+ self._send_json(
569
+ 400, {"error": "limit/offset 越界"}
570
+ )
571
+ return
572
+
573
+ tasks, total = manager.list(state, limit, offset)
574
+ self._send_json(
575
+ 200, {"tasks": tasks, "total": total}
576
+ )
577
+ return
578
+
579
+ if len(parts) == 4 and parts[:3] == ["", "api", "tasks"]:
580
+ try:
581
+ task = manager.get(parts[3])
582
+ except TaskNotFound:
583
+ self._send_json(404, {"error": "任务不存在"})
584
+ return
585
+ self._send_json(200, {"task": task})
586
+ return
587
+
588
+ self._send_json(404, {"error": "未找到路径"})
589
+
590
+ def do_POST(self):
591
+ if not self._require_auth():
592
+ return
593
+ if self._invalid_path():
594
+ self.close_connection = True
595
+ self._send_json(400, {"error": "无效的请求路径"})
596
+ return
597
+ try:
598
+ self._handle_post()
599
+ except Exception as exc:
600
+ self._send_json(500, {"error": str(exc)})
601
+
602
+ def _handle_post(self):
603
+ if self._path_parts() != ["", "api", "tasks"]:
604
+ self.close_connection = True
605
+ self._send_json(404, {"error": "未找到路径"})
606
+ return
607
+
608
+ try:
609
+ length = int(self.headers.get("Content-Length", "0"))
610
+ except ValueError:
611
+ self._send_json(
612
+ 400, {"error": "无效的 Content-Length"}
613
+ )
614
+ return
615
+ if length <= 0:
616
+ self._send_json(400, {"error": "请求体不能为空"})
617
+ return
618
+ if length > MAX_BODY:
619
+ self.close_connection = True
620
+ self._send_json(413, {"error": "请求体过大"})
621
+ return
622
+
623
+ try:
624
+ raw = self.rfile.read(length)
625
+ except OSError:
626
+ self.close_connection = True
627
+ self._send_json(
628
+ 400, {"error": "读取请求体失败"}
629
+ )
630
+ return
631
+ try:
632
+ payload = json.loads(raw.decode("utf-8"))
633
+ except (UnicodeDecodeError, ValueError):
634
+ self._send_json(
635
+ 400, {"error": "请求体不是有效 JSON"}
636
+ )
637
+ return
638
+
639
+ try:
640
+ params = parse_task_request(
641
+ payload, manager.default_jobs
642
+ )
643
+ task = manager.submit(**params)
644
+ except RequestError as exc:
645
+ self._send_json(400, {"error": str(exc)})
646
+ return
647
+ except TaskConflict as exc:
648
+ self._send_json(409, {"error": str(exc)})
649
+ return
650
+
651
+ self._send_json(202, {"task": task})
652
+
653
+ def do_DELETE(self):
654
+ if not self._require_auth():
655
+ return
656
+ if self._invalid_path():
657
+ self.close_connection = True
658
+ self._send_json(400, {"error": "无效的请求路径"})
659
+ return
660
+ try:
661
+ parts = self._path_parts()
662
+ if len(parts) != 4 or parts[:3] != ["", "api", "tasks"]:
663
+ self._send_json(404, {"error": "未找到路径"})
664
+ return
665
+ try:
666
+ task = manager.cancel(parts[3])
667
+ except TaskNotFound:
668
+ self._send_json(404, {"error": "任务不存在"})
669
+ return
670
+ except TaskConflict as exc:
671
+ self._send_json(409, {"error": str(exc)})
672
+ return
673
+ self._send_json(200, {"task": task})
674
+ except Exception as exc:
675
+ self._send_json(500, {"error": str(exc)})
676
+
677
+ def log_message(self, format, *args):
678
+ print(f"[serve] {self.address_string()} {format % args}")
679
+
680
+ return ApiHandler
681
+
682
+
683
+ def run_serve(args):
684
+ """启动 HTTP 服务,直到 Ctrl+C。"""
685
+ output_dir = Path(args.output_dir).expanduser().resolve()
686
+ if output_dir.exists() and not output_dir.is_dir():
687
+ raise HLSException(f"输出路径不是目录:{output_dir}")
688
+ output_dir.mkdir(parents=True, exist_ok=True)
689
+
690
+ token = args.token or secrets.token_urlsafe(24)
691
+ state_file = (
692
+ Path(args.state_file).expanduser()
693
+ if args.state_file
694
+ else output_dir / ".m3u8-serve-state.json"
695
+ )
696
+ manager = TaskManager(
697
+ output_dir,
698
+ max_tasks=args.max_tasks,
699
+ default_jobs=args.jobs,
700
+ state_file=state_file,
701
+ )
702
+ server = ThreadingHTTPServer(
703
+ (args.host, args.port), make_handler(manager, token)
704
+ )
705
+ server.daemon_threads = True
706
+
707
+ print(
708
+ f"监听地址:http://{args.host}:{server.server_address[1]}",
709
+ flush=True,
710
+ )
711
+ print(f"输出目录:{output_dir}", flush=True)
712
+ print(f"API token:{token}", flush=True)
713
+
714
+ try:
715
+ server.serve_forever()
716
+ except KeyboardInterrupt:
717
+ pass
718
+ finally:
719
+ server.server_close()
720
+ manager.close()