Ghost Downloader

架构总览

心智模型、完整生命周期、设计决策。

GD 是信号驱动的 actor 模型。每个 Service 拥有私有状态,对外通信仅通过 Qt Signal。UI 层可能随时被替换(桌面 vs Android),但 Service 的行为不应该变。依赖单向:UI → Services → Models ← Features,逆向引用会导致 Service 耦合到特定 UI 框架。

源码:app/services/app/models/features/

组件

组件职责源文件
CoroutineRunnerQt↔asyncio 桥接,所有异步工作在它的线程跑app/common/coroutine_runner.py
TaskService任务队列、状态机、调度、持久化app/services/task_service.py
FeatureService功能包加载(拓扑排序)、解析器链、身份预设注入app/services/feature_service.py
BrowserService扩展 WebSocket 通信、配对、任务快照推送app/services/browser_service.py
SpeedMeter全局速度汇总(1 秒/次)、全局速度限制app/services/speed_meter.py
Aria2RpcServerAria2 RPC 协议兼容层app/services/aria2_rpc_server.py
CategoryService按文件类型自动分类下载目录app/services/category_service.py

CoroutineRunner 用独立线程而非主线程内嵌 asyncio,避免大量网络 I/O 阻塞主线程导致 UI 卡顿。任务快照推送每秒做一次全量字符串比较,任务列表小(几十到几百条),增量追踪的代码量是全量比较的十倍,不值得。

一次下载的完整生命周期

任务生命周期:主线程与异步线程的协作

从 URL 到任务

无论 URL 从哪来,都经过同一条管道:解析 → 入队 → 调度 → 执行。差异只在入口。

扩展发送下载请求

用户在浏览器里点了下载按钮(或触发了下载接管)。扩展通过 WebSocket 发送请求,携带 URL、请求头、资源元数据。

WebSocket 而非 Native Messaging。跨平台,方便,稳定。Native Messaging 需要每个浏览器安装单独的 host manifest 和平台特定注册,WebSocket 一个端口全通。

桌面端解析

桌面端按优先级遍历所有解析器。第一个能处理这个 URL 的解析器接管,创建一个任务。

如果 URL 命中了身份预设(比如 Bilibili 域名),且请求中没有携带客户端指纹,解析前自动注入预设的指纹配置。

草稿确认或直接入队

两个条件决定是否弹出确认窗口:

  • 扩展请求中的 draft 参数
  • 用户设置中的"下载前先确认"选项

两者都为 false 时直接入队。否则弹出确认窗口,用户选择格式、勾选文件、确认路径后才入队。

确认结果通过 WebSocket 返回扩展:已创建 / 进入草稿 / 解析失败。

入队

文件名去重

检查三个位置是否有同名文件:内存中的任务列表、磁盘上的实际文件、磁盘上的进度文件。有冲突时追加 (1)(2) 等后缀。

进度文件也要检查,否则会和正在下载的任务撞名。

分类与磁盘检查

如果启用了自动分类,按文件类型分配下载目录。检查目标磁盘剩余空间是否足够。

进入等待队列

任务追加到等待队列尾部。调度器检查并发数,有空位时立即提交到异步线程执行。

执行

任务按步骤顺序逐个执行。一个 Bilibili 视频任务通常有三个步骤:下载视频轨、下载音频轨、FFmpeg 混流。每个步骤独立管理自己的网络连接、进度和错误恢复。

步骤迭代器在每次取下一个步骤前都会重新检查任务状态。如果上一个步骤失败导致任务状态变为失败,迭代立即终止,不会尝试后续步骤。已完成的步骤的输出文件保留在磁盘上。

步骤内部的下载机制(分片、加速、续传)见下载引擎。浏览器扩展到桌面端的完整通信管道见浏览器桥接

暂停与恢复

暂停取消运行中的异步任务,所有未完成的步骤标记为已暂停,进度文件保留在磁盘上。

恢复时任务重新入队。已完成的步骤跳过,失败的步骤从头开始(进度清零),已暂停的步骤从进度文件中保存的位置继续。

进程退出时的暂停行为不同:只改状态,不取消异步任务。逐个取消的成本高于直接结束进程,异步任务随进程销毁。

错误与失败

步骤内部的临时错误(网络超时、连接断开)由步骤自己重试(等待 5 秒后重连)。永久错误(HTTP 403/404、致命 I/O 错误)直接中止步骤。

步骤失败后任务标记为失败。用户手动重新开始时,任务重新入队。已完成的步骤跳过,失败的步骤进度清零从头执行。

变更操作

编辑、重新下载、勾选变更、删除都遵循同一个模式:先取消运行,等取消完成后再执行后续动作。

回调的时序取决于任务是否正在运行:运行中 → 异步线程完成清理后回调(下一个事件循环轮次);未运行 → 回调在当前调用栈内立即触发。取消运行中的任务需要等异步线程响应,取消未运行的任务没有异步工作要等。

操作取消后做什么
编辑替换参数(URL、请求头、指纹等),任务 ID 不变 → 重新调度
重新下载删除输出文件和进度文件 → 所有步骤重置 → 重新调度
勾选变更更新勾选。如果已完成的任务新增了未完成文件 → 重新入队
删除可选删除输出文件 → 从内存和持久化中移除

多文件任务(BT、播放列表)取消勾选的文件保留已有进度,不删除。如果取消勾选了正在下载的文件,先取消运行再更新勾选。

进程退出与恢复

退出:所有任务设为已暂停 → 写盘 → 逐个停用功能包 → 再次写盘 → 关闭异步线程。

两次写盘:第一次保存暂停状态,第二次捕获功能包停用时可能产生的状态修改。

恢复:从持久化文件加载所有任务。等待中和运行中的任务自动入队调度。失败和已暂停的任务保持原状,等用户手动操作。

持久化

任务列表存储为 JSONL(每行一个 JSON 对象),用类名做反序列化标识。

JSONL 而非 SQLite。任务的子类多且频繁变化,每个子类的字段不同。类名注册表实现了零迁移成本的多态序列化:新增子类不需要写迁移脚本。代价是类名成为序列化契约,改名会导致旧任务无法恢复。

写入策略:200ms 防抖合并多次状态变更为一次写入,先写临时文件再重命名(原子操作)。进程在防抖窗口内崩溃会丢失最近一次状态变更。

改动影响范围

你改了检查
任务调度逻辑快照格式、扩展的任务列表处理
添加/改解析器优先级数字越小越先检查,确认不和现有冲突
扩展侧消息格式桌面端消息处理和扩展两边同步
任务/步骤子类名持久化文件靠类名映射,改名导致旧任务加载失败
功能包的启用/停用停用是同步阻塞的(用事件循环等待),从异步线程调用会死锁
功能包配置子类子类在导入时注册到全局配置,标识含类名,同名子类会冲突
身份预设逻辑解析前注入预设,但请求已携带指纹时跳过注入

入口点

你想从哪开始
调试下载卡住/速度异常对应功能包的 task.py
调试扩展连不上app/services/browser_service.py
加新站点支持从教程开始features/ 下新建功能包
改 UIapp/view/ 下的对话框和卡片
改下载引擎下载引擎
改扩展行为浏览器桥接

本页目录