要改代码? 直接看
docs/交接说明.md—— 工程导览、开发铁律(飞牛裁剪 Python、 cmd 脚本换行/权限)、测试与打包命令、已知坑都在那。本文只讲怎么用。
把 NAS 本地文件与 OpenList 云盘双向传起来:左右双栏选文件、队列上传、 断点续传、完整性校验、失败自动重试,目录比对已并入传输页,多出的文件可 一键上传、一键下载、一键清除。 体验对标百度网盘 / 阿里云盘桌面客户端。
- 版本:2.9.4(本版修掉「左栏选了 OpenList 来源之后,进文件夹时上方比对条左侧的地址
不跟着走」。根因是设置里那个开关让左栏自己变成了云端栏,界面上于是同时有两个云端栏
(左栏 + 右栏),而比对条的「输入框 ↔ 浏览栏」分配是按栏位名字写死的(左框↔本地栏),
两个框去抢同一个栏、先占到的赢,另一个永远不跟随。现在改成按栏的实际来源分配:
左栏的实际来源由设置决定,输入框声明的来源必须与栏相符,一个栏绝不服务两侧。
于是左栏是 OpenList 源时,两侧各自跟着自己的栏(在左栏进文件夹只改左框、右栏只改右框),
并且左侧来源会默认跟着栏变成「云端」。详见 §4.1 与 §4.2。
本 README 只做快速上手,功能基线与验证记录以
doubao-drive-2.9.4-说明.md为准) - 应用 id:
doubao-drive(沿用上版 id,可直接覆盖安装升级) - 平台:
all(x86 与 ARM 通用,纯 Python,无二进制依赖) - 安装方式:飞牛 应用中心 → 手动安装 → 选择
doubao-drive-2.9.4.fpk(也在本包根目录附了一份)
2.0 起,应用从「直连豆包 API + 自带 WebDAV」改为「OpenList 上传客户端」。 WebDAV 功能已彻底移除 —— 需要 WebDAV 的话直接用 OpenList 自带的
/dav即可。
| 功能 | 说明 |
|---|---|
| OpenList 授权 | 填 OpenList 地址 + 用户名 + 密码(支持 2FA)。密码只用于换 token,应用不存明文,token 失效自动重登 |
| 双栏文件传输 | 左栏本地目录树(聚合全部存储空间)、右栏 OpenList 云端目录树。勾选左侧文件/文件夹,一键上传到右侧当前目录;云端可新建文件夹 |
| 队列上传 | 多任务并发(可调),实时进度条 + 速度;大文件自动走 multipart 分片上传,中断后可断点续传;应用重启未完成任务自动恢复 |
| 完整性校验 | 上传完成后回查云端文件大小比对,不一致判失败(可关) |
| 失败重试 | 自动重试(指数退避,次数可调);重试耗尽转失败,界面上手动重传,也可「全部重试」一键重跑所有失败任务 |
| 队列筛选 | 每行状态彩色徽标(成功绿 / 失败红 / 进行中蓝 / 跳过灰),失败原因完整可读;按「全部 / 失败 / 已完成 / 进行中」筛选 |
| 跳过已存在 | 云端已有同路径同大小文件自动跳过,不重复传(可关) |
| 状态记忆 | 记住浏览位置、对比参数、未保存的设置草稿与队列筛选,刷新 / 重开应用不丢(密码与验证码绝不落盘) |
| 共用传输列表 | 上传与下载任务同列一张表,按方向区分图标与文案(「重下」/「重传」),进度、速度、暂停、取消、重试机制完全一致 |
| 双向比对 | 在传输页左右两栏各勾选一个文件夹,点中间「比对」;差异按仅本地 / 仅云端 / 大小不一致三组展示,每组标出条数与字节数,方向一目了然。两栏各自可以选本地或云端,本地↔本地、本地↔云端、云端↔本地、云端↔云端都能比(云端↔云端走 OpenList 服务端复制,不占用本条链路) |
| 批量与逐行操作 | 一键全选上传、全选下载、全选清除;也可对单个文件上传 / 下载 / 清除本地 / 清除云端 / 互相覆盖。操作成功的行自动从差异列表摘掉 |
| 状态标签筛选 | 差异面板顶部的「仅本地 / 仅云端 / 大小不一致 / 已同步」四个标签可以点:点一下只看对应分类,再点一次恢复完整列表。选中态清晰(底色+描边+加粗+✓),切换即时生效。筛到「已同步」会列出两边大小一致的文件(只读,不提供勾选框与操作)。筛选不影响勾选、单条操作与排序 |
| 清除(不可逆) | 删除云端多余文件或本地多余文件。执行前必须二次确认;本地删除受存储空间白名单双重约束(越界路径拒删、白名单根目录本身拒删),每一次删除都写入本地审计日志 audit.log.jsonl |
| 目录绑定备份 | 把 OpenList 源目录绑定到一个或多个云端目录,增量扫描后只把缺失文件复制过去,可开自动上传。复制由 OpenList 服务端完成,不占用本条链路带宽。只新增,绝不删除云端或本地已有文件 |
不做的事:双向/镜像自动同步(full)、移动(move)模式、WebDAV。
「清除」只在用户显式点下并二次确认后才执行,不存在任何自动删除路径;
「目录绑定备份」依旧是只增不删。
(v2.8.4 起界面上已下线未实现的 full/move 同步模式,避免选了之后被静默降级。)
- 下载
doubao-drive-2.9.4.fpk。 - 飞牛 应用中心 → 手动安装 → 上传该文件(已装 1.0 的直接覆盖升级)。
- 安装依赖
python312(manifest 里已声明,应用中心会自动装)。 - 装好后桌面上出现「OpenList 上传」图标。
桌面入口 type 是 iframe —— 点图标会在飞牛桌面里开一个内嵌小窗,不另起标签页。
想改成新标签页打开:编辑 /vol1/@appcenter/doubao-drive/ui/config,
把 "type": "iframe" 改为 "url",然后执行
systemctl restart trim_app_center.service(不重启这个服务,桌面不会重新读取入口配置)。
统一网关模式要求 fnOS ≥ V1.1.3100。系统更老、网关打不开时,降级到端口直连:
改 /vol1/@appcenter/doubao-drive/etc/settings.conf 的 BIND_HOST=0.0.0.0,
换用端口版入口后浏览器访问 http://NAS_IP:8484/。
应用要读 /vol1/1000/... 这类用户目录,而飞牛的用户目录默认互相不可读,
不以 root 运行就看不到别的用户的数据。本地目录浏览因此做了严格白名单限制
(只允许 TRIM_DATA_ACCESSIBLE_PATHS 下的路径),越权请求一律 400。
- 点顶栏「连接」按钮(首次打开会自动弹出)。
- 填三项:
- OpenList 地址:如
http://192.168.0.10:5244(带不带/api都行,应用会自动规整); - 用户名 / 密码:OpenList 的管理员或有上传权限的账号;
- 两步验证:开了 2FA 才需要填。
- OpenList 地址:如
- 点「登录并连接」。也可以先点「测试连接」做分步自检(网络 → 登录 → 列目录)。
密码不会落盘。应用只保存密码的 StaticHash(
sha256(密码 + 固定盐), 与 OpenList 服务端一致的算法)和登录换来的 token;token 失效时用 StaticHash 自动重登。
- 左栏浏览本地目录(默认聚合全部存储空间,双击进目录,勾选要多传几项勾几项);
- 右栏浏览云端目录,定位到目标位置(可用「新建文件夹」先建好目录);
- 点中间的 「上传 →」,所选内容进入传输列表立即开传;点 「比对 ⇄」 则直接对 两栏选中(或当前所在)的目录做双向比对;
- 页面顶部还有一条比对条(左路径 ⇄ 右路径 + 深度 + 「比对」),每条路径前面都有一枚 「本地 / 云端」切换,点它就能把这一侧换成另一种来源;路径会自动跟随你的浏览位置, 也可以手填;手填之后就不再被自动覆盖。右侧栏的目标位置就是「比对」的右侧来源。
- 左栏也可以不是本地:设置里的「文件传输左侧来源」能把左栏换成 OpenList 路径 (左栏标题会变成「OpenList 源」,点上传走服务端复制)。此时界面上有两个云端栏, 比对条两侧可以各自跟着自己的栏 —— 在左栏进文件夹只改左框、在右栏进文件夹只改右框, 「云端 ↔ 云端」也能两边各自定位。详见 §4.2。
比对完成后,下方就是差异面板:按「仅本地 / 仅云端 / 大小不一致」分组展示, 可直接全选上传、全选下载、全选清除(详见 §4.2)。
传输列表每秒刷新:排队中 / 传输中(进度 + 速度)/ 校验中 / 已完成 / 失败,每行状态带彩色徽标。 上传与下载任务同列一张表,按方向区分图标与文案(下载是「重下」,上传是「重传」)。 失败的任务点 「重传 / 重下」 单独重来;也可点队列头部的 「全部重试」 一键重跑所有失败任务 (大文件会从分片断点续传,不从头再来)。用头部筛选按钮组按 全部 / 失败 / 已完成 / 进行中 只看关心的任务;「清除已完成」清理列表。
界面会记住你上次所在的本地 / 云端目录、比对参数与队列筛选,刷新或重开应用后自动回到原处。
大文件(>4MB)自动走 multipart 分片上传:传一半断了(重启应用、重启 NAS、网络抖动),
下次会继续从断点传 —— OpenList 服务端会保留未完成的分片会话 24 小时。
下载同理:先写 .ddpart 临时文件,断线后带 Range 从断点接着下。
比对不再是独立页签,它就是「文件传输」的起点 —— 先比对,再决定往哪个方向搬。
- 在左右两栏各勾选一个文件夹(各栏只勾一个时以它为准;都没勾就按「当前所在目录」比对), 点两栏中间那个 「比对」 按钮;
- 两栏各自可以选本地或云端 —— 比对条上每一侧前面都有一枚「本地 / 云端」切换按钮, 点一下就把该侧换成另一种来源(路径跟着换成该侧上次用过的目录)。四种组合都成立: 本地↔本地、本地↔云端(默认)、云端↔本地、云端↔云端。切到云端时若还没连 OpenList, 会直接提示「尚未连接 OpenList,请先在「连接」页填写地址与账号」,而不是给一个空列表; 云端目录不存在、无权访问、连接失败、路径不是目录也都会分别说明,并把出问题的那一侧 输入框标红;
- 跟随规则看栏的「实际来源」,不是看栏的名字:右栏恒为云端,左栏的实际来源由
设置里的「文件传输左侧来源」决定(本机文件系统 / OpenList 路径)。比对条上每一侧声明
了「本地」或「云端」,就必须有一个来源相符的浏览栏来带动它,而一个栏绝不服务两侧:
- 左栏是本机文件系统(默认):本地↔云端 → 左框跟左栏、右框跟右栏; 云端↔云端 → 只有一个云端栏(右栏),它只带动右侧,左侧保持不动(请手填); 本地↔本地 → 对称,只有左侧跟随;云端↔本地 → 交叉跟随,两栏各有归属。 此时比对条下方会写明它正在带动哪一侧。
- 左栏被设置换成 OpenList 路径:界面上有两个云端栏,两侧于是各自跟着自己的栏 —— 在左栏进文件夹只改左框、在右栏进文件夹只改右框,「云端↔云端」也能两边各自定位。 这种状态下左侧来源会默认跟着栏变成「云端」(你没手动选过的话),默认路径取左栏自己的 OpenList 起始路径。若你在此时把某一侧选成「本地」,提示会说清「界面上没有本地栏」 并给出补救办法(把该侧也切成云端,或直接手填)。
- 你手动选过的侧别会被记住,分侧记 —— 只动过右侧不影响左侧继续跟栏。 切到「没有栏可跟」的组合时也不会预填一个跟对面一模一样的路径(那样一点比对必然报错), 分不到栏的那一侧留空等你指定;
- 两边用同样的递归深度扫描 —— 深度不齐会把整棵子树误判成差异,所以深度在扫描前统一收敛;
- 结果按三组展示,方向写得很直白(方向按实际来源生成,不只是「上传 / 下载」):
| 分组 | 含义 | 这一组能做什么 |
|---|---|---|
| 仅左侧有 | 左侧有、右侧没有 | 传到右侧 · 清除左侧 |
| 仅右侧有 | 右侧有、左侧没有 | 取到左侧 · 清除右侧 |
| 大小不一致 | 两边都有但大小不同 | 用右侧覆盖左侧 · 用左侧覆盖右侧 |
在默认的「左=本地、右=云端」下,这三组就是惯用的仅本地有 / 仅云端有 / 大小不一致, 按钮分别是「上传 / 下载 / 清除本地 / 清除云端」;当两侧同为云端(或同为本地)时, 文案会自动改成「左侧 / 右侧」并加左右限定,动词变为「复制」,不会出现含糊的 「用云端覆盖云端」这种说法。
每组标出条数与字节数。批量按钮就在分组标题上:全选上传 / 全选下载 / 全选清除 (作用于该组里已勾选的行);也可以点单行上的按钮逐个处理,操作成功后该行会自动从差异列表摘掉。
四种组合各自怎么搬:本地↔云端两个方向走上传 / 下载队列(断点续传、大小校验、 失败重试、进度反馈一应俱全);云端↔云端走 OpenList 服务端复制(同存储同步完成, 跨存储由服务端建后台任务,不占用本条链路带宽);本地↔本地走本地文件复制。 后两者不经过传输队列,接口在动手前会先做完所有校验(目标目录必须在存储空间白名单内、 不允许复制存储根目录、不允许把目标目录设进源目录内部、源目录就是目标目录会被明确拒绝)。
- 条目多的时候用状态标签筛选:差异面板顶部有「仅本地 / 仅云端 / 大小不一致 / 已同步」四个标签, 点一下只看这一类,再点一次恢复完整列表;直接点另一枚就切过去。切换是纯前端重绘、立即生效, 不会重新扫一遍。筛选只影响显示:勾选不会掉(筛走再筛回来还是原来那些)、单条操作照常可用、 顺序不变;「全选」按当前可见范围取值 —— 筛出「仅本地」本来就是为了集中处理它,此时全选只勾这一组。 点「已同步」会列出两边大小一致的文件(只读分组,没有勾选框与操作按钮;两边都存在的目录 只计数、不逐条列出 —— 目录路径存在不代表里面传完了)。
关于下载:流式写入,支持 Range 断点续传;只有大小校验通过才会把 .ddpart 改成正式文件,
所以下载中断绝不会留下一个「看起来正常」的半截文件。跨存储的直链地址不会附带本站 token。
关于清除(不可逆,请看清再点):
- 云端删除由 OpenList 执行,目录是递归删除;是否进回收站取决于 OpenList 存储驱动的配置;
- 本地删除受存储空间白名单双重约束:白名单以外的路径一律拒删,白名单根目录(如
/vol1)本身也拒删; - 批量清除前有二次确认弹层,逐行清除不弹(避免点一下弹一次);
- 每一次删除都会追加写入本地审计日志
<数据目录>/audit.log.jsonl, 记录时间、事件、条数与具体路径 —— 事后至少能查「什么时候删了什么」; - 差异比对本身做了父子归并:目录已列为差异项时不再单列其子项, 否则批量删除会把同一棵子树删两遍、批量入队会出现几十倍冗余。
比对本身是纯只读的:不改动任何一端的文件。改动只发生在你显式点下按钮之后。
| 项 | 默认 | 说明 |
|---|---|---|
| 同时上传数 | 2 | 并发任务数,1–8(下载队列共用同一套并发/重试/暂停机制) |
| 失败重试次数 | 3 | 每次失败后自动重试,退避间隔 2s/4s/8s… |
| 跳过云端已存在 | 开 | 同路径同大小直接跳过 |
| 上传完成后校验 | 开 | 回查云端大小比对 |
| 默认扫描深度 | 3 | 比对时留空深度就用这个值 |
| 扫描并发数 | 8 | 扫描云端时同时向 OpenList 查几个目录,1–32。OpenList 在远端或有限流时调小;本地千兆内网可调大 |
| 排除规则 | *.tmp 等 |
逗号分隔,支持 * 通配,入队与比对都生效(.ddpart 永久排除,不受此项影响) |
| 默认本地/云端目录 | — | 传输页与比对条的初始位置 |
云端目录扫描的耗时几乎全在网络往返上:它每读一个目录就要问一次 OpenList。 所以这一版的优化都奔着「少往返、少重复劳动」去:
- 同层并发:一层里的目录同时查(默认 8 路),层与层之间仍串行(子目录得先知道父目录存在)。 D 个目录从串行 D 次往返压到大约「层数 × ⌈同层目录数 / 并发数⌉」次。
- 大目录自适应分页:一个目录超过一页时,用第一页带回来的
total一次要全, 8000 项的目录从 8 次往返变 2 次。 - 按父目录批量核对:绑定扫描原先是「每个文件 × 每个目标各问一次云端」, 现改成「每个父目录问一次」,5000 文件 × 2 目标从 1 万次请求降到个位数。
- 一次列目录拿全大小:一键上传原来对每个文件
stat三次(遍历 +isfile+getsize), 现在列目录时就顺手把大小带回来了。 - 不重复读盘:凭据与配置按文件修改时间缓存,不再每个请求重复读三四遍。
- 界面不白忙:队列数据没变就不重建 DOM;队列空闲时轮询间隔自动从 2 秒降到 8 秒。
- 内网 http 不再绕远路:飞牛自带 Python 缺
_ssl,旧逻辑会让任何请求都 fork 一个curl子进程;现在按地址协议选后端,http://的请求直接走内置栈。
| 位置 | 内容 |
|---|---|
| 应用界面 →「设置」→ 查看运行日志 | 最近的运行日志 |
${TRIM_PKGVAR}/info.log |
即 /vol1/@appcenter/doubao-drive/var/info.log,完整日志 |
| 连接面板 →「测试连接」 | 分步自检(网络 / 登录 / 列目录) |
命令行自检(SSH 上去):
/var/apps/python312/target/bin/python3 \
/vol1/@appcenter/doubao-drive/target/server/main.py selftest| 现象 | 多半是 |
|---|---|
| 点了图标没反应 | app/ui/config 的入口 key 与 manifest 的 desktop_applaunchname 不一致;或系统版本低于统一网关要求 |
| 页面能打开但数据全空 | 接口地址没探测对(网关前缀),强刷一次 |
| 登录报 401 | 用户名或密码错。注意应用走 StaticHash 登录,OpenList 侧无需额外配置 |
| 登录报 429 | 失败次数过多被锁 5 分钟,等一会儿再试 |
| 上传一直失败 | 看任务里的错误详情;403 disabled 说明服务端关了 multipart(应用会自动降级为整文件直传) |
| 大文件传到一半失败 | 属正常断流,点「重传」会从断点续传,不会从头再来 |
| 下载一直失败 | 云端没给出下载地址(部分存储驱动不支持直链);任务详情里会写明原因 |
| 「清除」点了没反应 | 批量清除会先弹二次确认,确认前不会发出任何请求 |
| 删本地文件被拒,提示「不在允许访问的范围内」 | 该路径不在存储空间白名单内 —— 这是有意的保护,不是 bug |
| 两边文件看起来一样却报「大小不一致」 | 大小确实不同(比对只按大小,不按内容);用任一侧覆盖即可 |
| 按钮点了完全没反应 | 理论上不会 —— 所有弹窗都是自绘的。若真遇到请反馈 |
doubao-drive/
├── manifest 飞牛应用描述
├── config/ privilege(run-as: root)、resource
├── cmd/ 9 个生命周期脚本(必须 LF、必须可执行位)
├── app/
│ ├── ui/ 桌面入口 config 与图标
│ ├── www/index.html 前端(单文件,无构建步骤)
│ └── server/ 后端(纯 Python 标准库)
│ ├── hasher.py SHA-256(hashlib 探测 + 纯 Python 兜底)
│ ├── httpclient.py HTTP 传输 + 流式 PUT / GET(内存恒定)
│ ├── openlist.py OpenList API 客户端(所有网络调用都在这里)
│ ├── upload_queue.py 持久化上传队列(自动重试 / 断点续传)
│ ├── download_queue.py 下载队列(继承上传队列,改 PUT 为流式 GET)
│ ├── verify.py 双向目录比对(含差异父子归并)
│ ├── config.py 配置与 settings 覆盖
│ ├── http_api.py 应用接口 + 网关 Socket
│ └── main.py 入口
├── docs/ OpenList API 参考(契约)、需求与设计
├── tests/ 测试(见下)
└── tools/make_icons.py 图标生成(纯标准库画 PNG)
python tests/test_hasher.py # SHA-256 与 StaticHash 公式
python tests/test_openlist.py # OpenList 客户端契约(登录/翻页/分片/校验)
python tests/test_upload_queue.py # 队列状态机(重试/取消/重启恢复)
python tests/test_download.py # 下载链路(Range 续传 / 大小校验 / 批量删除 / 下载队列)
python tests/test_verify.py # 双向比对三分类 + 父子归并 + 永久排除
python tests/test_api.py # 接口层 + 网关前缀 + 路径白名单 + 删除边界
python tests/test_bindings.py # 绑定备份状态机 + 批量核对 + 任务新鲜度
python tests/test_contract.py # 跨层契约(元素 id / 路由 / manifest / cmd)
node tests/test_ui.js # 前端(jsdom 真跑页面脚本)
node tests/test_bindings_ui.js # 绑定页前端(jsdom)
python tests/run_all.py # 一次跑完(自动发现 tests/test_*.py)
python tests/smoke_e2e.py # 端到端冒烟:真起 HTTP 服务 + 假 OpenListsmoke_e2e.py 不在自动发现里:它要起真实端口,比单元测试重,适合改完接口手动跑一遍。
它专门盯那些 mock 掩盖不住的坑(URL 拼接、认证头、分页、请求形状),
以及「任务被清理 → 不得误判成已同步」这类只在真链路上才暴露的语义;
下载部分同样走真字节:假 OpenList 支持 Range 并会核对续传起点。
反向验证(变异测试):仓库外的 _work/mutcheck.py 会把 26 处关键分支逐条改坏
(例如「下载不校验大小」、「越界删除不拦」、「前端勾选不生效」、「已同步计数退化成明细条数」),
确认对应测试真的变红。测试全绿本身不构成证据 —— 能变红才算数,
它是用来戳破"看起来有防护、其实断言是空的"这种情况的。
python tools/make_icons.py
python <fnos-fpk-app skill>/scripts/build_fpk.py --project .打包脚本会在调用 fnpack 之前做一轮自检:必需文件、JSON 合法性、
cmd 脚本是否 CRLF/带 BOM、图标尺寸、入口 key 与网关前缀是否对得上、Python 能否编译;
打包后还会重写包内权限(Windows 版 fnpack 打出来的 cmd/* 没有可执行位,
装到 NAS 上会 Permission denied,而 fnpack 自己不校验这一点)。
MIT。开发者 / 发布者:ffvz。
OpenList 的接口契约全部来自服务端源码(
OpenListTeam/OpenListv4.2.6)逐文件核对, 见docs/API参考.md。本应用的所有网络调用都收敛在app/server/openlist.py一个文件里, 接口变化时只需要改这一处。