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/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()
|