hyperdownloader-core 1.0.4__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.
- hyperdownloader/__init__.py +47 -0
- hyperdownloader/api_server.py +417 -0
- hyperdownloader/config_manager.py +176 -0
- hyperdownloader/core.py +307 -0
- hyperdownloader/downloader.py +455 -0
- hyperdownloader/enums.py +33 -0
- hyperdownloader/models.py +239 -0
- hyperdownloader/scheduler.py +321 -0
- hyperdownloader/segment.py +187 -0
- hyperdownloader/utils.py +276 -0
- hyperdownloader_core-1.0.4.dist-info/METADATA +225 -0
- hyperdownloader_core-1.0.4.dist-info/RECORD +14 -0
- hyperdownloader_core-1.0.4.dist-info/WHEEL +5 -0
- hyperdownloader_core-1.0.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HyperDownloader Core — 高性能多线程下载引擎
|
|
3
|
+
=============================================
|
|
4
|
+
完全解耦,不依赖任何 GUI 框架,可无缝集成到其他 Python 项目中。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .enums import DownloadStatus, SegmentStatus, SchedulerPolicy
|
|
8
|
+
from .models import (
|
|
9
|
+
DownloadTask,
|
|
10
|
+
DownloadConfig,
|
|
11
|
+
DownloadProgress,
|
|
12
|
+
DownloadSegment,
|
|
13
|
+
DownloadResult,
|
|
14
|
+
ProxyConfig,
|
|
15
|
+
Headers,
|
|
16
|
+
)
|
|
17
|
+
from .utils import format_bytes, format_speed, format_time, get_downloads_folder
|
|
18
|
+
from .core import HyperDownloader
|
|
19
|
+
from .config_manager import AppConfig, load_config, save_config
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
# 枚举
|
|
23
|
+
"DownloadStatus",
|
|
24
|
+
"SegmentStatus",
|
|
25
|
+
"SchedulerPolicy",
|
|
26
|
+
# 数据模型
|
|
27
|
+
"DownloadTask",
|
|
28
|
+
"DownloadConfig",
|
|
29
|
+
"DownloadProgress",
|
|
30
|
+
"DownloadSegment",
|
|
31
|
+
"DownloadResult",
|
|
32
|
+
"ProxyConfig",
|
|
33
|
+
"Headers",
|
|
34
|
+
# 核心入口
|
|
35
|
+
"HyperDownloader",
|
|
36
|
+
# 配置管理
|
|
37
|
+
"AppConfig",
|
|
38
|
+
"load_config",
|
|
39
|
+
"save_config",
|
|
40
|
+
# 工具
|
|
41
|
+
"get_downloads_folder",
|
|
42
|
+
"format_bytes",
|
|
43
|
+
"format_speed",
|
|
44
|
+
"format_time",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
__version__ = "1.0.4"
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"""
|
|
2
|
+
REST API 服务器 — 直接使用 TaskDownloader,不经过调度器
|
|
3
|
+
|
|
4
|
+
提供 JSON API 供第三方前端(Web / 桌面 App)接入。
|
|
5
|
+
|
|
6
|
+
启动:
|
|
7
|
+
python -m hyperdownloader.api_server [--port 8765]
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import urllib.parse
|
|
18
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
19
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
_project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
23
|
+
if _project_root not in sys.path:
|
|
24
|
+
sys.path.insert(0, _project_root)
|
|
25
|
+
|
|
26
|
+
from hyperdownloader import DownloadTask, DownloadConfig, DownloadStatus
|
|
27
|
+
from hyperdownloader.downloader import TaskDownloader
|
|
28
|
+
from hyperdownloader.utils import get_downloads_folder
|
|
29
|
+
from hyperdownloader.config_manager import load_config
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("hyperdownloader.api")
|
|
32
|
+
_STATIC_DIR = os.path.normpath(
|
|
33
|
+
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "tools")
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# ── 全局任务仓库(支持多任务并发下载)──
|
|
37
|
+
_tasks: dict[str, TaskDownloader] = {} # 所有任务(运行中 + 排队中)
|
|
38
|
+
_tasks_lock = threading.Lock()
|
|
39
|
+
_max_concurrent = 5 # 最大并发下载数
|
|
40
|
+
_pending_queue: list[dict] = [] # 排队等待的任务参数
|
|
41
|
+
_queue_lock = threading.Lock()
|
|
42
|
+
_queue_thread_running = False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _start_task_directly(url: str, save_dir: str, filename: Optional[str] = None,
|
|
46
|
+
segments: int = 4, expected_sha256: Optional[str] = None) -> str:
|
|
47
|
+
"""直接启动一个下载任务(不排队)"""
|
|
48
|
+
config = DownloadConfig(max_segments=segments)
|
|
49
|
+
task = DownloadTask(
|
|
50
|
+
url=url, save_dir=save_dir, filename=filename,
|
|
51
|
+
config=config, expected_sha256=expected_sha256,
|
|
52
|
+
)
|
|
53
|
+
dl = TaskDownloader(task)
|
|
54
|
+
with _tasks_lock:
|
|
55
|
+
_tasks[task.task_id] = dl
|
|
56
|
+
dl.start()
|
|
57
|
+
logger.info("任务已启动: %s [%s]", url, task.task_id)
|
|
58
|
+
return task.task_id
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _try_start_task(url: str, save_dir: str, filename: Optional[str] = None,
|
|
62
|
+
segments: int = 4, expected_sha256: Optional[str] = None) -> tuple[str, bool]:
|
|
63
|
+
"""
|
|
64
|
+
尝试启动任务。如果并发数未满则立即启动,否则加入排队队列。
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
(task_id, 是否立即启动)
|
|
68
|
+
"""
|
|
69
|
+
with _tasks_lock:
|
|
70
|
+
running = sum(1 for d in _tasks.values()
|
|
71
|
+
if d.status == DownloadStatus.RUNNING)
|
|
72
|
+
if running < _max_concurrent:
|
|
73
|
+
# 有可用槽位,直接启动
|
|
74
|
+
return _start_task_directly(url, save_dir, filename, segments, expected_sha256), True
|
|
75
|
+
|
|
76
|
+
# 并发已满,加入排队队列
|
|
77
|
+
with _queue_lock:
|
|
78
|
+
_pending_queue.append({
|
|
79
|
+
"url": url, "save_dir": save_dir, "filename": filename,
|
|
80
|
+
"segments": segments, "expected_sha256": expected_sha256,
|
|
81
|
+
})
|
|
82
|
+
logger.info("并发已满,任务排队: %s (运行中 %d)", url, running)
|
|
83
|
+
_ensure_queue_worker()
|
|
84
|
+
return "", False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _ensure_queue_worker():
|
|
88
|
+
"""确保排队调度线程在运行"""
|
|
89
|
+
global _queue_thread_running
|
|
90
|
+
if _queue_thread_running:
|
|
91
|
+
return
|
|
92
|
+
_queue_thread_running = True
|
|
93
|
+
t = threading.Thread(target=_queue_worker, daemon=True, name="api-queue")
|
|
94
|
+
t.start()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _queue_worker():
|
|
98
|
+
"""排队调度线程:监视频道,有空位就取出排队任务"""
|
|
99
|
+
import time
|
|
100
|
+
global _queue_thread_running
|
|
101
|
+
try:
|
|
102
|
+
while True:
|
|
103
|
+
# 检查是否有空位
|
|
104
|
+
with _tasks_lock:
|
|
105
|
+
running = sum(1 for d in _tasks.values()
|
|
106
|
+
if d.status == DownloadStatus.RUNNING)
|
|
107
|
+
# 清理已完成的任务
|
|
108
|
+
_cleanup_finished()
|
|
109
|
+
|
|
110
|
+
if running < _max_concurrent:
|
|
111
|
+
with _queue_lock:
|
|
112
|
+
if _pending_queue:
|
|
113
|
+
item = _pending_queue.pop(0)
|
|
114
|
+
with _tasks_lock:
|
|
115
|
+
new_running = sum(1 for d in _tasks.values()
|
|
116
|
+
if d.status == DownloadStatus.RUNNING)
|
|
117
|
+
if new_running < _max_concurrent:
|
|
118
|
+
_start_task_directly(
|
|
119
|
+
item["url"], item["save_dir"], item["filename"],
|
|
120
|
+
item["segments"], item["expected_sha256"],
|
|
121
|
+
)
|
|
122
|
+
continue
|
|
123
|
+
else:
|
|
124
|
+
# 又被占满了,放回去
|
|
125
|
+
_pending_queue.insert(0, item)
|
|
126
|
+
|
|
127
|
+
if not _pending_queue:
|
|
128
|
+
break # 队列为空,结束线程
|
|
129
|
+
|
|
130
|
+
time.sleep(1)
|
|
131
|
+
finally:
|
|
132
|
+
_queue_thread_running = False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _cleanup_finished():
|
|
136
|
+
"""清理已结束的任务"""
|
|
137
|
+
with _tasks_lock:
|
|
138
|
+
finished = [
|
|
139
|
+
tid for tid, dl in list(_tasks.items())
|
|
140
|
+
if dl.status in (DownloadStatus.COMPLETED, DownloadStatus.FAILED, DownloadStatus.CANCELLED)
|
|
141
|
+
]
|
|
142
|
+
for tid in finished:
|
|
143
|
+
_tasks.pop(tid, None)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _snapshot(dl: TaskDownloader) -> dict:
|
|
147
|
+
"""获取 TaskDownloader 的快照"""
|
|
148
|
+
p = dl.progress
|
|
149
|
+
return {
|
|
150
|
+
"task_id": p.task_id,
|
|
151
|
+
"url": p.url,
|
|
152
|
+
"filename": os.path.basename(p.file_path),
|
|
153
|
+
"status": p.status.name,
|
|
154
|
+
"progress": p.progress,
|
|
155
|
+
"downloaded": p.downloaded,
|
|
156
|
+
"total_size": p.total_size,
|
|
157
|
+
"speed": round(p.speed, 2),
|
|
158
|
+
"avg_speed": round(p.avg_speed, 2),
|
|
159
|
+
"elapsed": round(p.elapsed, 2),
|
|
160
|
+
"eta": round(p.eta, 2) if p.eta > 0 else 0,
|
|
161
|
+
"segments_total": p.segments_total,
|
|
162
|
+
"segments_completed": p.segments_completed,
|
|
163
|
+
"segments_speed": [round(s, 2) for s in p.segments_speed],
|
|
164
|
+
"error": p.error_message,
|
|
165
|
+
"file_path": p.file_path,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ═══════════════════════════════════════════════
|
|
170
|
+
# HTTP Handler
|
|
171
|
+
# ═══════════════════════════════════════════════
|
|
172
|
+
|
|
173
|
+
class APIHandler(BaseHTTPRequestHandler):
|
|
174
|
+
|
|
175
|
+
def do_GET(self):
|
|
176
|
+
path = urllib.parse.urlparse(self.path).path.rstrip("/")
|
|
177
|
+
try:
|
|
178
|
+
if path == "/api/tasks":
|
|
179
|
+
self._list_tasks()
|
|
180
|
+
elif path.startswith("/api/tasks/"):
|
|
181
|
+
self._get_task(path)
|
|
182
|
+
elif path == "/api/stats":
|
|
183
|
+
self._stats()
|
|
184
|
+
elif path == "/api/config":
|
|
185
|
+
self._get_config()
|
|
186
|
+
else:
|
|
187
|
+
self._serve_static()
|
|
188
|
+
except Exception as e:
|
|
189
|
+
self._send(500, {"error": str(e)})
|
|
190
|
+
|
|
191
|
+
def do_POST(self):
|
|
192
|
+
path = urllib.parse.urlparse(self.path).path.rstrip("/")
|
|
193
|
+
body = self._read_body()
|
|
194
|
+
if body is None:
|
|
195
|
+
return
|
|
196
|
+
try:
|
|
197
|
+
if path == "/api/tasks":
|
|
198
|
+
self._create_task(body)
|
|
199
|
+
elif path.endswith("/pause"):
|
|
200
|
+
self._pause_task(path)
|
|
201
|
+
elif path.endswith("/resume"):
|
|
202
|
+
self._resume_task(path)
|
|
203
|
+
elif path.endswith("/cancel"):
|
|
204
|
+
self._cancel_task(path)
|
|
205
|
+
else:
|
|
206
|
+
self._send(404, {"error": "Not Found"})
|
|
207
|
+
except Exception as e:
|
|
208
|
+
self._send(500, {"error": str(e)})
|
|
209
|
+
|
|
210
|
+
def do_OPTIONS(self):
|
|
211
|
+
self._cors()
|
|
212
|
+
self.send_response(204)
|
|
213
|
+
self.end_headers()
|
|
214
|
+
|
|
215
|
+
# ── 工具 ──
|
|
216
|
+
|
|
217
|
+
def _read_body(self) -> Optional[dict]:
|
|
218
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
219
|
+
if length <= 0:
|
|
220
|
+
return {}
|
|
221
|
+
try:
|
|
222
|
+
return json.loads(self.rfile.read(length))
|
|
223
|
+
except json.JSONDecodeError:
|
|
224
|
+
self._send(400, {"error": "Invalid JSON"})
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
def _send(self, status: int, data):
|
|
228
|
+
self.send_response(status)
|
|
229
|
+
self._cors()
|
|
230
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
231
|
+
self.end_headers()
|
|
232
|
+
self.wfile.write(json.dumps(data, ensure_ascii=False, default=str).encode())
|
|
233
|
+
|
|
234
|
+
def _cors(self):
|
|
235
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
236
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
237
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
238
|
+
|
|
239
|
+
def log_message(self, fmt, *args):
|
|
240
|
+
pass # 静默日志,避免刷屏
|
|
241
|
+
|
|
242
|
+
def _task_id_from_path(self, path: str) -> str:
|
|
243
|
+
"""从 /api/tasks/{id}/action 提取 task_id"""
|
|
244
|
+
return path.split("/api/tasks/")[1].split("/")[0]
|
|
245
|
+
|
|
246
|
+
# ── API 端点 ──
|
|
247
|
+
|
|
248
|
+
def _list_tasks(self):
|
|
249
|
+
"""GET /api/tasks"""
|
|
250
|
+
with _tasks_lock:
|
|
251
|
+
items = [_snapshot(dl) for dl in _tasks.values()]
|
|
252
|
+
with _queue_lock:
|
|
253
|
+
queued = len(_pending_queue)
|
|
254
|
+
running = sum(1 for t in items if t["status"] == "RUNNING")
|
|
255
|
+
pending = sum(1 for t in items if t["status"] == "PENDING")
|
|
256
|
+
paused = sum(1 for t in items if t["status"] == "PAUSED")
|
|
257
|
+
completed = sum(1 for t in items if t["status"] == "COMPLETED")
|
|
258
|
+
self._send(200, {
|
|
259
|
+
"tasks": items,
|
|
260
|
+
"count": len(items),
|
|
261
|
+
"queue": {
|
|
262
|
+
"running": running,
|
|
263
|
+
"pending": pending,
|
|
264
|
+
"paused": paused,
|
|
265
|
+
"completed": completed,
|
|
266
|
+
"queued": queued,
|
|
267
|
+
"max_concurrent": _max_concurrent,
|
|
268
|
+
},
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
def _get_task(self, path: str):
|
|
272
|
+
"""GET /api/tasks/{id}"""
|
|
273
|
+
tid = self._task_id_from_path(path)
|
|
274
|
+
with _tasks_lock:
|
|
275
|
+
dl = _tasks.get(tid)
|
|
276
|
+
if dl is None:
|
|
277
|
+
self._send(404, {"error": "Task not found"})
|
|
278
|
+
else:
|
|
279
|
+
self._send(200, _snapshot(dl))
|
|
280
|
+
|
|
281
|
+
def _create_task(self, body: dict):
|
|
282
|
+
"""POST /api/tasks"""
|
|
283
|
+
url = body.get("url", "").strip()
|
|
284
|
+
if not url:
|
|
285
|
+
self._send(400, {"error": "Missing 'url' field"})
|
|
286
|
+
return
|
|
287
|
+
save_dir = body.get("save_dir") or get_downloads_folder()
|
|
288
|
+
filename = body.get("filename")
|
|
289
|
+
segments = body.get("max_segments", 4)
|
|
290
|
+
expected_sha256 = body.get("expected_sha256")
|
|
291
|
+
os.makedirs(save_dir, exist_ok=True)
|
|
292
|
+
tid, immediate = _try_start_task(url, save_dir, filename, segments, expected_sha256)
|
|
293
|
+
if immediate:
|
|
294
|
+
self._send(201, {
|
|
295
|
+
"task_id": tid, "url": url,
|
|
296
|
+
"save_dir": save_dir, "filename": filename or "",
|
|
297
|
+
"status": "RUNNING",
|
|
298
|
+
})
|
|
299
|
+
else:
|
|
300
|
+
self._send(202, {
|
|
301
|
+
"url": url,
|
|
302
|
+
"message": "并发已满,任务已排队,有空位时自动开始",
|
|
303
|
+
"status": "QUEUED",
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
def _pause_task(self, path: str):
|
|
307
|
+
tid = self._task_id_from_path(path)
|
|
308
|
+
with _tasks_lock:
|
|
309
|
+
dl = _tasks.get(tid)
|
|
310
|
+
if dl: dl.pause(); self._send(200, {"success": True})
|
|
311
|
+
else: self._send(404, {"error": "Not found"})
|
|
312
|
+
|
|
313
|
+
def _resume_task(self, path: str):
|
|
314
|
+
tid = self._task_id_from_path(path)
|
|
315
|
+
with _tasks_lock:
|
|
316
|
+
dl = _tasks.get(tid)
|
|
317
|
+
if dl: dl.resume(); self._send(200, {"success": True})
|
|
318
|
+
else: self._send(404, {"error": "Not found"})
|
|
319
|
+
|
|
320
|
+
def _cancel_task(self, path: str):
|
|
321
|
+
tid = self._task_id_from_path(path)
|
|
322
|
+
with _tasks_lock:
|
|
323
|
+
dl = _tasks.pop(tid, None)
|
|
324
|
+
if dl: dl.cancel(); self._send(200, {"success": True})
|
|
325
|
+
else: self._send(404, {"error": "Not found"})
|
|
326
|
+
|
|
327
|
+
def _stats(self):
|
|
328
|
+
"""GET /api/stats"""
|
|
329
|
+
_cleanup_finished()
|
|
330
|
+
with _tasks_lock:
|
|
331
|
+
vals = list(_tasks.values())
|
|
332
|
+
with _queue_lock:
|
|
333
|
+
queued = len(_pending_queue)
|
|
334
|
+
running = sum(1 for d in vals if d.status == DownloadStatus.RUNNING)
|
|
335
|
+
pending = sum(1 for d in vals if d.status == DownloadStatus.PENDING)
|
|
336
|
+
paused = sum(1 for d in vals if d.status == DownloadStatus.PAUSED)
|
|
337
|
+
failed = sum(1 for d in vals if d.status == DownloadStatus.FAILED)
|
|
338
|
+
completed = sum(1 for d in vals if d.status == DownloadStatus.COMPLETED)
|
|
339
|
+
self._send(200, {
|
|
340
|
+
"running": running, "pending": pending, "paused": paused,
|
|
341
|
+
"failed": failed, "completed": completed, "total": len(vals),
|
|
342
|
+
"queued": queued, "max_concurrent": _max_concurrent,
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
def _get_config(self):
|
|
346
|
+
cfg = load_config()
|
|
347
|
+
self._send(200, {
|
|
348
|
+
"max_segments": cfg.max_segments,
|
|
349
|
+
"speed_limit": cfg.speed_limit,
|
|
350
|
+
"max_concurrent": cfg.max_concurrent,
|
|
351
|
+
"debug": cfg.debug,
|
|
352
|
+
"timeout": cfg.timeout,
|
|
353
|
+
"verify_ssl": cfg.verify_ssl,
|
|
354
|
+
"resume": cfg.resume,
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
# ── 静态文件 ──
|
|
358
|
+
|
|
359
|
+
def _serve_static(self):
|
|
360
|
+
path = urllib.parse.urlparse(self.path).path
|
|
361
|
+
if path in ("", "/"):
|
|
362
|
+
path = "/web_demo.html"
|
|
363
|
+
file_path = os.path.normpath(os.path.join(_STATIC_DIR, path.lstrip("/")))
|
|
364
|
+
if not file_path.startswith(_STATIC_DIR) or not os.path.isfile(file_path):
|
|
365
|
+
self._send(404, {"error": "Not Found"})
|
|
366
|
+
return
|
|
367
|
+
ext = os.path.splitext(file_path)[1].lower()
|
|
368
|
+
mime = {".html": "text/html; charset=utf-8", ".js": "application/javascript",
|
|
369
|
+
".css": "text/css", ".png": "image/png", ".ico": "image/x-icon"}
|
|
370
|
+
self.send_response(200)
|
|
371
|
+
self.send_header("Content-Type", mime.get(ext, "application/octet-stream"))
|
|
372
|
+
self.send_header("Cache-Control", "no-cache")
|
|
373
|
+
self.end_headers()
|
|
374
|
+
with open(file_path, "rb") as f:
|
|
375
|
+
self.wfile.write(f.read())
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ═══════════════════════════════════════════════
|
|
379
|
+
# 启动
|
|
380
|
+
# ═══════════════════════════════════════════════
|
|
381
|
+
|
|
382
|
+
def run_server(host: str = "127.0.0.1", port: int = 8765):
|
|
383
|
+
server = HTTPServer((host, port), APIHandler)
|
|
384
|
+
server.timeout = 0.5 # 定期检查 KeyboardInterrupt
|
|
385
|
+
|
|
386
|
+
print(f"╔══════════════════════════════════════════════╗")
|
|
387
|
+
print(f"║ HyperDownloader API Server ║")
|
|
388
|
+
print(f"╠══════════════════════════════════════════════╣")
|
|
389
|
+
print(f"║ 🚀 服务已启动 ║")
|
|
390
|
+
print(f"║ 🌐 Web 界面: http://{host}:{port}/ ║")
|
|
391
|
+
print(f"║ 📡 API 地址: http://{host}:{port}/api ║")
|
|
392
|
+
print(f"╚══════════════════════════════════════════════╝")
|
|
393
|
+
print(f" 按 Ctrl+C 停止")
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
server.serve_forever()
|
|
397
|
+
except KeyboardInterrupt:
|
|
398
|
+
print("\n⏹ 正在停止...")
|
|
399
|
+
finally:
|
|
400
|
+
with _tasks_lock:
|
|
401
|
+
for dl in list(_tasks.values()):
|
|
402
|
+
dl.cancel()
|
|
403
|
+
server.server_close()
|
|
404
|
+
print("✅ 已停止")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def main():
|
|
408
|
+
import argparse
|
|
409
|
+
parser = argparse.ArgumentParser(description="HyperDownloader API Server")
|
|
410
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
411
|
+
parser.add_argument("--port", type=int, default=8765)
|
|
412
|
+
args = parser.parse_args()
|
|
413
|
+
run_server(args.host, args.port)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
if __name__ == "__main__":
|
|
417
|
+
main()
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""
|
|
2
|
+
配置管理器
|
|
3
|
+
|
|
4
|
+
从 JSON 配置文件加载设置,支持多级查找:
|
|
5
|
+
1. 显式指定的配置文件路径
|
|
6
|
+
2. 当前工作目录下的 ``config.json``
|
|
7
|
+
3. ``~/.hyperdownloader/config.json``(用户级)
|
|
8
|
+
|
|
9
|
+
所有配置项均可被代码中的显式参数覆盖。
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("hyperdownloader.config")
|
|
21
|
+
|
|
22
|
+
# 配置文件名
|
|
23
|
+
CONFIG_FILENAME = "config.json"
|
|
24
|
+
# 用户级配置目录
|
|
25
|
+
USER_CONFIG_DIR = os.path.expanduser("~/.hyperdownloader")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class AppConfig:
|
|
30
|
+
"""应用配置,对应 config.json 的结构"""
|
|
31
|
+
|
|
32
|
+
# ── 下载 ──
|
|
33
|
+
max_segments: int = 4
|
|
34
|
+
"""默认分片数"""
|
|
35
|
+
speed_limit: Optional[int] = None
|
|
36
|
+
"""全局速度限制(字节/秒)"""
|
|
37
|
+
max_concurrent: int = 3
|
|
38
|
+
"""最大并发任务数"""
|
|
39
|
+
|
|
40
|
+
# ── 调试 ──
|
|
41
|
+
debug: bool = False
|
|
42
|
+
"""是否启用调试模式(输出分片级详细信息)"""
|
|
43
|
+
|
|
44
|
+
# ── 路径 ──
|
|
45
|
+
default_save_dir: str = ""
|
|
46
|
+
"""默认下载保存目录,为空则使用系统下载文件夹"""
|
|
47
|
+
temp_suffix: str = ".hdt"
|
|
48
|
+
"""临时文件后缀"""
|
|
49
|
+
|
|
50
|
+
# ── 代理 ──
|
|
51
|
+
proxy_http: Optional[str] = None
|
|
52
|
+
proxy_https: Optional[str] = None
|
|
53
|
+
proxy_socks5: Optional[str] = None
|
|
54
|
+
|
|
55
|
+
# ── 网络 ──
|
|
56
|
+
timeout: float = 30.0
|
|
57
|
+
connect_timeout: float = 10.0
|
|
58
|
+
buffer_size: int = 8192
|
|
59
|
+
max_retries: int = 3
|
|
60
|
+
retry_delay: float = 2.0
|
|
61
|
+
verify_ssl: bool = True
|
|
62
|
+
|
|
63
|
+
# ── 行为 ──
|
|
64
|
+
resume: bool = True
|
|
65
|
+
"""是否启用断点续传"""
|
|
66
|
+
show_progress_bar: bool = True
|
|
67
|
+
"""是否显示进度条(仅 CLI 有效)"""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_config(config_path: Optional[str] = None) -> AppConfig:
|
|
71
|
+
"""
|
|
72
|
+
加载配置,优先级:参数路径 > 工作目录 > 用户目录 > 默认值。
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
config_path: 显式指定的配置文件路径
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
AppConfig 实例
|
|
79
|
+
"""
|
|
80
|
+
cfg = AppConfig()
|
|
81
|
+
|
|
82
|
+
# 查找配置文件
|
|
83
|
+
paths_to_try: list[str] = []
|
|
84
|
+
if config_path:
|
|
85
|
+
paths_to_try.append(config_path)
|
|
86
|
+
|
|
87
|
+
# 工作目录
|
|
88
|
+
cwd_config = os.path.join(os.getcwd(), CONFIG_FILENAME)
|
|
89
|
+
if cwd_config not in paths_to_try:
|
|
90
|
+
paths_to_try.append(cwd_config)
|
|
91
|
+
|
|
92
|
+
# 用户目录
|
|
93
|
+
user_config = os.path.join(USER_CONFIG_DIR, CONFIG_FILENAME)
|
|
94
|
+
if user_config not in paths_to_try:
|
|
95
|
+
paths_to_try.append(user_config)
|
|
96
|
+
|
|
97
|
+
# 依次尝试加载
|
|
98
|
+
loaded_path = None
|
|
99
|
+
for path in paths_to_try:
|
|
100
|
+
if os.path.isfile(path):
|
|
101
|
+
loaded_path = path
|
|
102
|
+
break
|
|
103
|
+
|
|
104
|
+
if loaded_path:
|
|
105
|
+
try:
|
|
106
|
+
with open(loaded_path, "r", encoding="utf-8") as f:
|
|
107
|
+
data: dict = json.load(f)
|
|
108
|
+
_apply_dict(cfg, data)
|
|
109
|
+
logger.info("已加载配置: %s", loaded_path)
|
|
110
|
+
except Exception as e:
|
|
111
|
+
logger.warning("加载配置失败 %s: %s", loaded_path, e)
|
|
112
|
+
else:
|
|
113
|
+
logger.info("未找到配置文件,使用默认配置")
|
|
114
|
+
|
|
115
|
+
return cfg
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def save_config(cfg: AppConfig, config_path: Optional[str] = None) -> str:
|
|
119
|
+
"""
|
|
120
|
+
保存配置到文件。
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
cfg: 应用配置
|
|
124
|
+
config_path: 目标路径,默认保存到用户目录
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
实际保存的文件路径
|
|
128
|
+
"""
|
|
129
|
+
path = config_path or os.path.join(USER_CONFIG_DIR, CONFIG_FILENAME)
|
|
130
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
131
|
+
|
|
132
|
+
data = _to_dict(cfg)
|
|
133
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
134
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
135
|
+
logger.info("配置已保存: %s", path)
|
|
136
|
+
return path
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _apply_dict(cfg: AppConfig, data: dict) -> None:
|
|
140
|
+
"""将字典中的值写入 AppConfig(仅覆盖非 None 字段)"""
|
|
141
|
+
for key, value in data.items():
|
|
142
|
+
if hasattr(cfg, key) and value is not None:
|
|
143
|
+
# 处理嵌套字段(如 proxy.http)
|
|
144
|
+
if isinstance(value, dict):
|
|
145
|
+
for sub_key, sub_val in value.items():
|
|
146
|
+
attr = f"{key}_{sub_key}"
|
|
147
|
+
if hasattr(cfg, attr) and sub_val is not None:
|
|
148
|
+
setattr(cfg, attr, sub_val)
|
|
149
|
+
else:
|
|
150
|
+
setattr(cfg, key, value)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _to_dict(cfg: AppConfig) -> dict:
|
|
154
|
+
"""将 AppConfig 转为字典"""
|
|
155
|
+
result: dict = {}
|
|
156
|
+
for field_name in (
|
|
157
|
+
"max_segments", "speed_limit", "max_concurrent", "debug",
|
|
158
|
+
"default_save_dir", "temp_suffix",
|
|
159
|
+
"timeout", "connect_timeout", "buffer_size",
|
|
160
|
+
"max_retries", "retry_delay", "verify_ssl",
|
|
161
|
+
"resume", "show_progress_bar",
|
|
162
|
+
):
|
|
163
|
+
val = getattr(cfg, field_name)
|
|
164
|
+
if val is not None:
|
|
165
|
+
result[field_name] = val
|
|
166
|
+
|
|
167
|
+
# 代理 (嵌套对象)
|
|
168
|
+
proxy: dict[str, Optional[str]] = {}
|
|
169
|
+
for k in ("http", "https", "socks5"):
|
|
170
|
+
val = getattr(cfg, f"proxy_{k}")
|
|
171
|
+
if val is not None:
|
|
172
|
+
proxy[k] = val
|
|
173
|
+
if proxy:
|
|
174
|
+
result["proxy"] = proxy
|
|
175
|
+
|
|
176
|
+
return result
|