Featured image of post NAS辅种工具-AutoReseed,本质上是在比对一段 SHA-1

NAS辅种工具-AutoReseed,本质上是在比对一段 SHA-1

本来想做一个全站辅种工具,要给所有种子重新标记唯一值,最后退而求其次搞了纯 client 端辅种工具

本项目是日常写的娱乐工具,无法替代当前主流辅种,只是不在依赖于服务端来校验辅种,所以此辅种工具不支持很多站点

一份数据,好几个种子

假设你硬盘上有一个 40 GB 的目录,是从某个站点下载的。过一阵你发现另一个站点上也有同样的内容——同样的文件、同样的大小、一个字节都不差,只是种子文件不是同一个。

理论上你不用再下一遍:数据已经在硬盘上了,把新种子加进下载器、指向原来的目录、让它校验一遍,校验通过就能直接做种。这就是辅种(cross-seeding)。

手动做这件事的流程大概是:下载种子 → 加到下载器 → 手动改保存路径 → 强制校验 → 盯着进度条 → 发现只有 3% → 删掉 → 换下一个。种子少的时候还行,几百上千个的时候就是纯体力活,而且大部分尝试都是白费的。

所以我写了 AutoReseed,一个单二进制的自动辅种客户端。这篇讲两件事:辅种成立的条件到底是什么,以及怎么把它跑起来。


不想看原理的帅哥直接看下面都使用方法即可。

原理一:为什么不能直接比对种子

先看.torrent文件里有什么。它是一个 bencode 编码的字典,核心是info这个子字典:

1
2
3
4
5
6
7
info:
 name         "Some.Content.2024"      ← 显示名称
 piece length 4194304                   ← 分片大小,4 MiB
 pieces       <20字节 × N 的二进制串>    ← 每个分片的 SHA-1,拼在一起
 files        [{length, path}, ...]     ← 文件列表与顺序
 source       "SITE-A"                  ← 有些站点会塞私有字段
 private      1

下载器认种子靠info_hash,它是 整个 info 字典 做 bencode 序列化后的 SHA-1。

问题就在这儿:info_hash太敏感了。站点给种子改个name、加一个source字段、把private从 0 改成 1——数据一个字节没动,info_hash就全变了。所以两个站点上同一份内容的种子,info_hash几乎必然不同。 拿它做匹配,永远匹配不上。

那换个思路,按文件名和体积匹配行不行?

不太行。文件名会被站点改写,同名不同内容的情况也存在;体积倒是可靠,但"体积相同"离"字节相同"差得很远——同一部片子不同压制版本,体积差个几 MB 是常事,而按体积模糊匹配又会产生大量假阳性。假阳性的代价是实打实的:每一个都要下载种子、推进下载器、跑一遍全盘校验,40 GB 的东西校验一次要几分钟的磁盘 IO,白跑一百次就是一小时。

原理二:pieces 才是内容指纹

回头看info里那个pieces字段。

BitTorrent 把文件按piece length切成等长分片,每片算一个 SHA-1,然后把这些 20 字节的哈希首尾相接,存成一个大的二进制串。下载器校验数据,校验的就是它——读一片、算一次 SHA-1、跟pieces里对应位置比对。

这意味着pieces的值只取决于三件事:

  1. 文件内容 本身;
  2. 分片大小 piece length
  3. 文件的拼接顺序 (多文件种子会把所有文件首尾相连当成一条连续字节流再切片)。 它完全不受namesourceprivate这些元信息影响。

而这三件事,恰好就是辅种成立的 充要条件 :内容一样但分片大小不同,校验必然失败;内容一样、分片也一样,但文件顺序不同,切出来的片就错位了,同样失败。

所以做法很直接——把pieces整体再做一次 SHA-1,压成一个 40 字符的十六进制串,我把它叫pieces_hash

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// pieces_hash 是 sha1(info.pieces)。pieces 只取决于文件内容、分片长度与文件拼接顺序,
// 三者任一不同都无法辅种,因此它相同即等价于「可以辅种」,不受站点改写 name/source 影响。
func Parse(data []byte) (Meta, error) {
   mi, err := metainfo.Load(bytes.NewReader(data))
   if err != nil {
   	return Meta{}, fmt.Errorf("加载 .torrent 失败: %w", err)
   }
   info, err := mi.UnmarshalInfo()
   if err != nil {
   	return Meta{}, fmt.Errorf("解析 info 失败: %w", err)
   }
   sum := sha1.Sum(info.Pieces)
   return Meta{
   	InfoHash:   mi.HashInfoBytes().HexString(),
   	PiecesHash: hex.EncodeToString(sum[:]),
   	Name:       info.Name,
   	TotalSize:  info.TotalLength(),
   }, nil
}

一句话总结: 两个种子的 pieces_hash 相同,就等价于"这两个种子能共用同一份硬盘数据" 。不是"可能能",是"一定能"。

剩下的问题就变成了工程问题:怎么拿到站点那边所有种子的pieces_hash。答案是站点侧提供了批量匹配接口——你把本地一批pieces_hash发过去,它返回哪些命中了、对应站内的种子编号是多少。工具要做的就是把这个接口用好、用得不过分。

——————–## 自动化拆成三步 整个流程拆成三个独立阶段,可以整体跑,也能单独跑某一个:

阶段 干什么
discover 发现 列出下载器里所有已完成的种子 → 算出各自的pieces_hash→ 分批发给各站点匹配 → 命中的写进「辅种列表」,状态pending
push 推送 从辅种列表取pending→ 按站点限速下载候选种子文件 → 以源种子的保存路径、暂停状态 加进下载器 → 打标签 → 触发强制校验,状态转checking
finalize 收尾 轮询校验结果 → 完整就恢复做种(seeding),不完整就停止并打失败标签(mismatch
push 那一步的三个动作顺序很关键,缺一不可:
  • 复用源种子的保存路径 ——不是新建目录,是直接指到已有数据上,否则就变成重新下载了;

  • 暂停添加 ——加进去先别动,不然下载器会立刻开始向站点汇报、开始补下缺失分片;

  • 然后才触发校验 ——让下载器自己去比对硬盘上的数据和新种子的pieces

校验结果就是最终裁决。pieces_hash匹配保证了"种子层面能对上",但硬盘上的文件可能被你改过名、删过一集、动过目录结构,这些只有校验能发现。所以工具不替下载器下结论,只看它的进度:≥ 99.9% 判完整,其余判不完整。

候选的状态流转是这样:

1
2
3
4
5
6
┌──→ skipped   (无候选 / 下载器里已存在)
 pending ──push──→ ├──→ failed    (种子下载失败 / 解析失败 / 推送失败)
                   └──→ checking ──finalize──→ seeding   (校验完整,开始做种 ✓)
                                            └→ mismatch  (校验不完整,已停止 ✗)

六种状态在界面上一目了然,失败的会带上具体原因,不用去翻日志。

——————–## 用起来 项目地址:https://github.com/doododo/auto-reseed

单个静态二进制,前端已经编译进去了,没有外部依赖。Docker 方式:

1
2
cp .env.example .env    # 改一下密码和端口
docker compose up -d --build

.env里真正需要你动的就三处:

1
2
3
4
5
6
7
CLIENT_PORT=8095                  # 网页端口
CLIENT_ADMIN_PASSWORD=改成你的密码   # 不设的话每次启动随机生成、打在日志里
TORRENT_DIR=/path/to/torrents     # Transmission 的种子目录,qBittorrent 可以不管

# Linux 用户注意:不设这两个,容器写不进 ./data,会报 "unable to open database file"
PUID=1000                         # 填 id -u 的结果
PGID=1000                         # 填 id -g 的结果

装完访问http://你的IP:8095。数据库落在宿主机./data/client.db,重建容器不会丢配置。

关于那个TORRENT_DIR:工具需要读到下载器里每个种子的原始.torrent文件才能算pieces_hash。qBittorrent 4.5.0 以上提供了在线导出接口,可以直接问它要,所以不用配;Transmission 的接口只肯告诉你种子文件在服务端的路径,不给内容,所以必须把那个目录挂进来让工具自己读。

五步跑通

第一步:加站点

站点页填三样:地址、Cookie、站点密钥。保存后会 自动检测 这个站点能不能用,结果直接标在列表上:

辅种待选种子不是根据添加站点来的,如对源种进行过滤去下载器设置。当前添加的站点主要是辅种目标站。

  • 可辅种 —— 接口正常,可以作为辅种目标;

  • 无接口 —— 这个站点没提供批量匹配能力,它只能当"数据来源",不能往它上面辅;

  • 被拦截 —— 撞上了 CDN 防护。这种情况需要在浏览器里正常访问一次过盾,然后把 完整 Cookie 和该浏览器的 User-Agent 一起 填进来,两者必须来自同一个浏览器、同一个出口 IP,只填 Cookie 不填 UA 是无效的;

  • 未检测 —— 还没点过检测按钮。

第一次用的时候建议把每个站点都点一遍检测。如果所有站点都是"未检测",跑任务会直接报错告诉你去检测,而不是假装成功然后一无所获。

第二步:加下载器

填地址、用户名、密码,Transmission 记得填种子目录。这一页还有几个可选项,种子多的话很有用:

选项 作用
最小体积(GB) 小于这个体积的种子不参与辅种。几十 MB 的小东西辅种收益低、还占查询配额
不辅种标签 打了这些标签的种子跳过,比如「临时」「测试」
不辅种目录 某些目录下的数据不参与,比如/data/tmp
辅种到 主辅分离:源种子在 A 下载器,辅种产物统一落到 B 下载器。不需要就选「本下载器」
配完点一下「测试连接」确认能通。

第三步:手动跑一次

任务页点「立即辅种」,三个阶段会连着跑完。跑完展开任务明细,每一颗种子为什么没辅上都写着:

  • 无候选 —— 所有目标站点都没有这份数据,正常现象;

  • 源站 —— 这颗种子本来就来自那个站点,不用重复查;

  • 已存在 —— 下载器里已经有这个种子了;

  • 以及各种具体的失败原因。

如果第一次跑出来"扫描到 0 个种子"或者直接报错说找不到任何种子,八成是种子目录配错了或者挂载失效——这种情况工具会 显式报错 ,不会给你一个绿色的"成功"让你以为是本来就没有可辅的。

第四步:看辅种列表

这里是每个候选的完整记录,六种状态前面讲过了。重点看两类:

  • mismatch(校验不完整) :数据对不上。常见原因是你改过文件名、删过其中某个文件、或者目录结构变了。这个候选确实辅不了,可以直接忽略掉,忽略后不会再被「重试失败」卷回队列。

  • failed(失败) :种子下载不下来、解析不了、推送出错。这类多半是临时问题(站点抽风、密钥过期),点「重试失败」会把它们全部打回pending,下轮重新来过。

重点说一下失败的种子如何处理,点击重试失败会改变种子状态为待推送,然后点推送,会自动启动增量任务去处理;如果点击种子 ID 跳转后发现确实种子有问题,可以点忽略,则不会参与重试失败

第五步:配定时计划

计划页填每天触发的时间点,逗号分隔的HH:MM,比如03:00,15:00。两种模式:

  • 增量 —— 只查上次没查过的种子。日常用这个,请求量小;

  • 全量 —— 全部重查一遍。站点上新增了你之前查过但当时没有的资源时才需要,建议一周跑一次就够。

配完就不用管了。后台每分钟检查一次计划,同时也会自动去收尾那些"还在校验中"的候选——所以校验完成后不用等到下一个计划时间点,一两分钟内就会自动转成做种。

——————–## 出问题了看这里

现象 大概率原因
一个都没匹配到 站点状态全是「未检测」;或者你的种子确实只有这一个站点有
扫描数为 0 种子目录配错 / 挂载失效;Transmission 没填种子目录
站点显示「被拦截」 Cookie 和 UA 不是同一个浏览器的,或者出口 IP 变了
大量 mismatch 硬盘上的文件被改过名或动过结构,这类是真的辅不了
会不会重复占硬盘? 不会。辅种复用的是源种子的保存路径,硬盘上只有一份数据,只是多了几个种子在做种
会不会请求太猛把号搞没? 见下
最后一条值得单独说。这类工具最容易出事的地方不是功能,是"请求发太快"。所以限速是从头设计进去的,不是补丁:
  • 匹配查询按站点的批量上限分批,同一个站点两批之间强制间隔 6.5 秒;

  • 每个站点一个独立协程,互不影响——某个站点被限流了只终止它自己这一轮,不会拖累其他站点;

  • 单站触发限流立即停止本轮,保留已有结果,下轮再来;

  • 推送候选种子可以按站点配「最小间隔秒数」和「每日上限」,单轮推送也有硬上限,剩下的留到下一轮。

增量模式其实也是一种限速。 已经查过的pieces_hash会记在本地,下次直接跳过。种子越多这个优化越明显——第一次跑可能要发几十批请求,之后每天的增量通常就是个位数。

写这个东西最大的体会是: 辅种的核心判据其实特别简单 ,就是一段 SHA-1 相不相等,二十行代码能讲完。剩下 8000 行都在处理"站点会返回什么奇怪的东西"、“用户会怎么配错”、“怎么不把人家服务器打疼"这些事。

技术栈:Go 1.25 + Gin + GORM + SQLite(纯 Go 实现,免 CGO)+ Vue 3 + TypeScript + TailwindCSS 4,前端产物用go:embed内嵌进二进制。

MIT 协议。

📎 参考文章

Licensed under CC BY-NC-SA 4.0
最后更新于 Aug 10, 2026 03:06 CST