返回 douyin-downloader
app.py
根目录 / server / app.py
1 """FastAPI REST 服务入口。
2
3 HTTP 层薄封装:
4 - 接收 URL,创建 job,返回 job_id
5 - 实际下载委托给 cli.main.download_url 的简化复用
6
7 fastapi/uvicorn 是**可选**依赖。若未安装,导入本模块会 ImportError。
8 """
9
10 from __future__ import annotations
11
12 from contextlib import asynccontextmanager
13 from typing import Any, Dict, List
14
15 from fastapi import FastAPI, HTTPException
16 from pydantic import BaseModel
17
18 from auth import CookieManager
19 from config import ConfigLoader
20 from control import QueueManager, RateLimiter, RetryHandler
21 from core import DouyinAPIClient, DownloaderFactory, URLParser
22 from server.jobs import JobManager
23 from storage import FileManager
24 from utils.logger import setup_logger
25 from utils.validators import is_short_url, normalize_short_url
26
27 logger = setup_logger("REST")
28
29
30 class DownloadRequest(BaseModel):
31 url: str
32
33
34 class JobResponse(BaseModel):
35 job_id: str
36 status: str
37 url: str
38
39
40 class _ServerDeps:
41 """跨请求复用的重量级依赖。
42
43 REST 服务在进程生命周期内只需要一份 FileManager / RateLimiter / RetryHandler /
44 QueueManager / CookieManager;每个请求重新构造既浪费又会触发文件系统 mkdir。
45 DouyinAPIClient 由于持有 aiohttp.ClientSession,依旧按请求创建,避免跨请求泄漏
46 连接状态或触发 "Session is closed" 错误。
47 """
48
49 def __init__(self, config: ConfigLoader):
50 self.config = config
51 # Resolve the cookie file path relative to the config file's directory
52 # so the sidecar can find it regardless of its working directory (which
53 # on macOS is often '/' when launched by Electron).
54 if config.config_path:
55 from pathlib import Path
56
57 cookie_file = str(Path(config.config_path).resolve().parent / ".cookies.json")
58 else:
59 cookie_file = ".cookies.json"
60 self.cookie_manager = CookieManager(cookie_file=cookie_file)
61 # Load cookies from the config (env var / YAML cookie key) first, then
62 # fall back to whatever is already on disk in the cookie file. This
63 # ensures that cookies saved by a previous session are picked up on
64 # restart even when the config doesn't embed them inline.
65 initial_cookies = config.get_cookies()
66 if initial_cookies:
67 self.cookie_manager.set_cookies(initial_cookies)
68 else:
69 # Trigger a load from disk so get_cookies() returns the persisted
70 # session without requiring a fresh login on every app restart.
71 self.cookie_manager.get_cookies()
72 self.file_manager = FileManager(config.get("path"))
73 self.rate_limiter = RateLimiter(max_per_second=float(config.get("rate_limit", 2) or 2))
74 self.retry_handler = RetryHandler(max_retries=int(config.get("retry_times", 3) or 3))
75 self.queue_manager = QueueManager(max_workers=int(config.get("thread", 5) or 5))
76
77
78 async def _execute_download(url: str, deps: "_ServerDeps") -> Dict[str, int]:
79 """简化版 download_url:只负责执行并返回成功/失败计数。
80
81 有意不复用 cli.main.download_url —— 后者绑定了 progress_display 的 rich 状态。
82 API client 仍按请求创建(aiohttp session 不跨请求复用);其余重量级依赖从
83 _ServerDeps 共享。
84 """
85 async with DouyinAPIClient(deps.cookie_manager.get_cookies()) as api_client:
86 if is_short_url(url):
87 resolved = await api_client.resolve_short_url(normalize_short_url(url))
88 if not resolved:
89 raise RuntimeError(f"Failed to resolve short URL: {url}")
90 url = resolved
91
92 parsed = URLParser.parse(url)
93 if not parsed:
94 raise RuntimeError(f"Unsupported URL: {url}")
95
96 downloader = DownloaderFactory.create(
97 parsed["type"],
98 deps.config,
99 api_client,
100 deps.file_manager,
101 deps.cookie_manager,
102 None, # database 不在 server 场景里启用,避免单例冲突
103 deps.rate_limiter,
104 deps.retry_handler,
105 deps.queue_manager,
106 progress_reporter=None,
107 )
108 if downloader is None:
109 raise RuntimeError(f"No downloader for url_type={parsed['type']}")
110
111 result = await downloader.download(parsed)
112 return {
113 "total": result.total,
114 "success": result.success,
115 "failed": result.failed,
116 "skipped": result.skipped,
117 }
118
119
120 def build_app(config: ConfigLoader) -> FastAPI:
121 deps = _ServerDeps(config)
122
123 async def executor(url: str) -> Dict[str, int]:
124 return await _execute_download(url, deps)
125
126 server_cfg = config.get("server") or {}
127 if not isinstance(server_cfg, dict):
128 server_cfg = {}
129 manager = JobManager(
130 executor=executor,
131 max_concurrency=int(config.get("thread", 2) or 2),
132 max_jobs=int(server_cfg.get("max_jobs") or JobManager.DEFAULT_MAX_JOBS),
133 job_ttl_seconds=float(
134 server_cfg.get("job_ttl_seconds") or JobManager.DEFAULT_JOB_TTL_SECONDS
135 ),
136 )
137
138 @asynccontextmanager
139 async def lifespan(app: FastAPI):
140 yield
141 await manager.shutdown()
142
143 app = FastAPI(
144 title="Douyin Downloader API",
145 version="1.0",
146 description="REST API for dispatching Douyin download jobs.",
147 lifespan=lifespan,
148 )
149 app.state.job_manager = manager
150 app.state.deps = deps
151
152 @app.get("/api/v1/health")
153 async def health() -> Dict[str, str]:
154 return {"status": "ok"}
155
156 @app.post("/api/v1/download", response_model=JobResponse)
157 async def create_job(req: DownloadRequest) -> JobResponse:
158 if not req.url:
159 raise HTTPException(status_code=400, detail="url is required")
160 job = await manager.submit(req.url)
161 return JobResponse(job_id=job.job_id, status=job.status, url=job.url)
162
163 @app.get("/api/v1/jobs/{job_id}")
164 async def get_job(job_id: str) -> Dict[str, Any]:
165 job = await manager.get(job_id)
166 if job is None:
167 raise HTTPException(status_code=404, detail="job not found")
168 return job.to_dict()
169
170 @app.get("/api/v1/jobs")
171 async def list_jobs() -> Dict[str, List[Dict[str, Any]]]:
172 jobs = await manager.list_jobs()
173 return {"jobs": [j.to_dict() for j in jobs]}
174
175 return app
176
177
178 async def run_server(config: ConfigLoader, *, host: str, port: int) -> None:
179 import uvicorn
180
181 app = build_app(config)
182 uv_config = uvicorn.Config(app, host=host, port=port, log_level="info")
183 server = uvicorn.Server(uv_config)
184 await server.serve()
185
185 lines PYTHON