从「一坨文件」到「只读视频索引」:一个本地优先的工程实践
速览(TL;DR)
- 本地优先的只读索引:扫描多目录视频,构建可查询、可去重的媒体数据集,绝不修改原文件。
- 内容去重:blake3 精确哈希(
Full)+ 首尾 1MB 快速哈希(Fast)双模式;(size, mtime)指纹增量扫描避免全量重扫。 - 安全预览:Range 请求原生播放 + 签名短时 token(id 绑定 / TTL 3600s / 常量时间比较);转码与缩略图产物隔离在缓存目录。
- 隐私护栏:未设
API_KEY拒绝监听非回环地址;路径脱敏($HOME→~)仅用于展示字段。 - 纯本地、不传云,单二进制即可运行。
出发点:视频库「一坨文件」的查询困境
我的外接硬盘里堆着几千个视频文件,散落在多个磁盘、多个目录里。没有拍摄时间、没有标签、没有统一的命名规则。我想找一个「H.265 编码、1080p、时长在 20 到 40 分钟之间、而且跟另一个文件字节完全相同」的视频,只能靠眼睛在文件管理器里一页页翻。更糟糕的是,很多文件是从不同渠道收集来的,名字跟内容完全对不上,内容重复的文件占着好几倍的磁盘空间,我却没任何工具可以按「内容」去重。
我的另一个项目 photo-library 已经有了一套(EXIF + 感知哈希)方案,但视频没有经典的 CV 等价物——没有 EXIF 头,也没有成熟的感知哈希库能直接套用。视频的元数据(编码格式、分辨率、码率、音轨信息)只有 ffprobe 能可靠给出。
这让我面对一个核心设计张力:索引必须独立于文件树、绝不修改原文件,但视频的元数据又只能靠外部探测器(ffprobe)来获取。一旦索引层与原文件耦合,或者扫描过程动了原文件,整个工具的定位就崩了。
所以从最开始,我的设计原则就定死了三件事:纯本地(数据不出本机,SQLite 存索引)、只读(任何操作都不碰原文件内容)、安全护栏(暴露到网络前必须有鉴权)。这个「只读」原则贯穿了后面每一个工程决策。
启动入口就体现了这条原则。服务启动时检查安全配置,未设置 API_KEY 时禁止监听非回环地址:
let is_loopback = matches!(config.host.as_str(), "127.0.0.1" | "::1" | "localhost");
let allow_insecure = std::env::var("ALLOW_INSECURE_BIND")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("yes"))
.unwrap_or(false);
if !config.is_secured() && !is_loopback && !allow_insecure {
eprintln!(
"拒绝启动:未设置 API_KEY 却要监听非回环地址 `{}`(将暴露全部视频索引与原文件)。...",
config.host
);
std::process::exit(1);
}
这只是一个「只读但敏感」服务的起点——索引里存着所有视频的绝对路径、元数据和内容哈希,如果被未经授权的人访问,等于把整个视频库的清单交出去了。
踩坑:只读原则遭遇的第一个坑——「文件名像视频」不等于「是视频」
扫描的第一步是枚举文件。我最初的想法很简单:按扩展名过滤。但现实马上给了我一巴掌——.ts 既是 MPEG 传输流(我支持的视频格式之一)又是 TypeScript 源码的扩展名。一个 .d.ts 文件会被我的扩展名过滤器误判成视频。
这只是表象。更深的问题是:扩展名只能说明「文件名像视频」,不代表文件内容真的是视频。一个 .mp4 结尾的文件,内容可能只是几行文本(有些人会在下载时故意改后缀,或者下载工具把错误响应存成了 .mp4)。
所以在文件收集逻辑里,我做了一个特判:凡是 .d.ts 结尾的文件(TypeScript 声明文件,末段扩展名恰好也是 ts)直接跳过,避免被当成视频;然后跳过隐藏目录。
光靠文件名还不够,真正的判定必须交给 ffprobe。探测函数返回一个三态结果——一个枚举,区分「确实是视频」「不是视频」「探测失败但可能是视频」:
Ok:ffprobe 确认有视频流,入库。
这个三态设计是「只读探测」原则的落地:ffprobe 只读文件内容,从不写回;即使单个文件坏了,也只会降级跳过,不会让整个扫描流程崩掉。
另外一个细节:元数据抽取失败时,container 字段回退到扩展名。这样即使 ffprobe 对某个罕见封装格式识别失败,索引里也有一条记录,用户至少能按文件名找到它。绝不因为一个坏文件就放弃整个目录的入库。
调整:增量扫描 + 精确哈希——既要内容去重,又不想每次重扫全库
扫描能入库了,下一个问题接踵而至:内容去重怎么做?
我最初想得很美:全量 blake3 哈希,逐字节比对,绝对精确。但几千个大视频,每个动辄几个 GB,全量读一遍要跑几个小时——这违背了「不想每次重扫全库」的直觉需求。
而如果走模糊去重(感知哈希、二进制相似度)路线,对视频来说没有可靠的近邻算法可以做,这条路走不通。
所以我把决策拆成两条腿并行:
第一条腿:指纹增量扫描。 用 (size_bytes, mtime) 作为文件指纹。mtime(文件修改时间)和 size_bytes(文件大小)都存在 Video 结构里:
pub(crate) size_bytes: u64,
/// 文件修改时间(Unix 秒)。增量扫描用它 + size 判定文件是否变化。
pub(crate) mtime: i64,
pub(crate) content_hash: String,
扫描时先比较指纹,没变的文件不重新哈希;只有指纹变了才重新读内容。这样既保证了增量扫描的效率,又不牺牲精确性。
第二条腿:两种哈希模式任选。 一个枚举给出了精确与速度的权衡:
/// 流式整文件 blake3:任意比特变化都会改变哈希,去重最精确,但大文件慢。
#[default]
Full,
/// 仅哈希文件首尾各 1MB + 尺寸 + mtime:极快,但仅能发现「完全相同或仅中段不同」的近似重复。
Fast,
}
默认是 Full(整文件 blake3,任意比特变化都会改变哈希)。我用 content_hash 字段存哈希值,重复检测就是按这个字段分组——只要哈希相同,内容就完全相同。
但去重的最终决策不能交给机器——哈希只负责「找出内容相同的组」,留哪份、删哪份必须由人决定。一个枚举就是干这个的:
#[default]
Undecided,
/// 人工指定保留。
Keep,
/// 人工指定删除(应用清理时从索引移除)。
Remove,
}
注意这个注释:「应用清理时从索引移除」。删除操作只影响索引记录,磁盘上的原文件一个字节都不动——「只读」原则在这里再次显现。
验证:Range 预览 + 转码 + 安全护栏——只读原则的最后一公里
索引建好了,去重也能做了,最后的问题是:怎么让用户预览?直接往 <video> 标签里塞一个文件路径?不行。浏览器无法读取服务器文件系统,而且我也不想让前端直接拿绝对路径。
预览也分两层,都严格遵循「只读」:
第一层:原生播放(Range 请求)。 浏览器播放视频需要 HTTP Range 支持(拖动进度条、流式播放都依赖它)。file_url 是后端签发的带 token 的 URL,前端直接作为 <video src> 使用。但 <video> 标签无法为跨源请求附带自定义头,所以不能用 x-api-key 鉴权——这促成了签名 token 的设计:短时签名 token,放在查询参数里。
pub(crate) fn make_token(id: u64, secret: &[u8; 32]) -> String {
let exp = now_secs() + TOKEN_TTL_SECS;
let payload = format!("{id}:{exp}");
let hash = blake3::keyed_hash(secret, payload.as_bytes());
format!("{exp}.{}", to_hex(hash.as_bytes()))
}
token 有效期 1 小时(TOKEN_TTL_SECS = 3600),校验时用常量时间比较避免时序侧信道。这个 token 只授权访问指定视频的字节流,有效期一过就失效。
第二层:转码 + 缩略图。 有些旧编码(如某些 .avi 里的 MPEG-4 Part 2)浏览器根本无法播放。这两个缓存目录都是索引层的产物,与原文件完全隔离。
路径脱敏也是「只读」原则的一部分——用户不想看到自己的用户名 mary 出现在 URL 里。路径序列化时把 $HOME 前缀替换成 ~:
fn serialize_masked_path<S: serde::Serializer>(value: &str, serializer: S) -> Result<S::Ok, S::Error> {
let masked = if let Ok(home) = std::env::var("HOME") {
if !home.is_empty() && value.starts_with(&home) {
format!("~{}", &value[home.len()..])
} else {
value.to_string()
}
} else {
value.to_string()
};
serializer.serialize_str(&masked)
}
注意函数注释里的关键提醒:「仅用于纯展示字段;凡前端会回传用作查询过滤的字段不能脱敏,否则回传值无法匹配数据库里的真实路径」。这个细节说明「脱敏」不是「丢失」——服务端内部仍持有真实路径用于文件读取,只是对外展示时做替换。
静态文件托管(前端构建产物)也有只读意识:静态文件服务里做了路径穿越防护,请求路径规整后必须仍位于前端目录内:
let candidate = frontend_dir.join(rel);
let Ok(candidate) = candidate.canonicalize() else {
return (StatusCode::NOT_FOUND, "not found").into_response();
};
let Ok(root) = frontend_dir.canonicalize() else {
return (StatusCode::NOT_FOUND, "not found").into_response();
};
if !candidate.starts_with(&root) {
return (StatusCode::FORBIDDEN, "forbidden").into_response();
}
结果:一个「碰不到原文件」的工具,反而更可靠
回看整个工程演进,我可以总结出这条主线的所有落地环节:
| 原则 | 落地位置 | 具体手段 |
|---|---|---|
| 指纹增量 | (size_bytes, mtime) |
未变的文件不重复哈希 |
| 精确去重 | content_hash(blake3) |
Full 模式任意比特变化即改变哈希 |
| 预览隔离 | 签名 token + 转码缓存目录 | token 短时签名;转码产物在缓存目录 |
| 路径脱敏 | 路径序列化函数 | 对外 ~ 前缀,内部真实路径 |
| 启动护栏 | 启动时非回环检查 | 未设 API_KEY 拒绝监听外网 |
这套东西我自己用了几个月,效果符合预期:新增一个外接盘,只需在界面上选一下目录,增量扫描在后台跑;重复文件一目了然,按 duplicate_only 过滤就能看到所有哈希冲突的视频;想找「H.265 1080p 带 AAC 音轨」的视频,按 codec + resolution + has_audio 过滤即可。
最让我放心的反而是「碰不到原文件」这件事。因为索引层完全独立,我可以随时删掉索引数据库重建索引,原文件一个字节都不会变。即便某次扫描或转码出了 bug,最坏的情况也就是损坏几个缓存文件,绝不会污染原始素材。一个「碰不到原文件」的工具,反而因为它碰不到而更可靠——这句话听起来有点绕,但用过之后就会明白,这正是本地视频管理该有的样子。
源码导航
- 启动入口 — 启动入口;安全护栏(未设
API_KEY禁止非回环监听) - 配置模块 — 全部环境变量配置;
- 存储模块 — 文件收集、指纹存储
- 元数据模块 — ffprobe 元数据抽取
- 签名 token 模块 — 视频端点短时签名 token
- 数据模型模块 — 数据模型;
- API 路由模块 — HTTP 路由
- 静态文件模块 — 前端静态托管;路径穿越防护
为什么视频去重必须用精确哈希(blake3),不能用感知哈希或模糊匹配?
视频没有可靠的感知近邻算法可做(不像图片有感知哈希),模糊匹配要么漏报(内容相同但特征不同)要么误报(内容不同但特征相近)。
问:HASH_MODE=fast 和 HASH_MODE=full 的区别是什么?我该用哪个?
答:full(默认)流式读整个文件做 blake3 哈希,去重最精确,但大文件慢;fast 只哈希首尾各 1MB 加文件尺寸和 mtime,极快,但只能发现「完全相同或仅中段不同」的近似重复。如果你在意精确性就保持默认,如果库很大、可接受漏掉「中段修改过」的文件,可以用 fast。
问:为什么视频文件端点的鉴权不用 x-api-key 请求头,而用查询参数 token?
答:浏览器 <video> 标签无法为跨源请求附带自定义头(不能设置 x-api-key),所以视频文件端点改用后端签发的查询参数 token。token 里包含过期时间(1 小时)和 blake3 keyed_hash 签名,校验用常量时间比较,防止时序侧信道。
问:只读原则下,转码和缩略图生成会不会修改原文件?
答:不会。首次访问时才按需生成并缓存,原文件始终只被 ffprobe/ffmpeg 读取,从不写入。
问:只读原则下,删除操作会不会物理删除原文件?
答:不会。设计上「删除」的语义是「从索引和视图中移除」,不是物理删除;磁盘上的原文件一个字节都不动。
问:为什么路径脱敏只用于展示字段,不能用于查询过滤字段?
答:路径脱敏把 $HOME 前缀替换成 ~,这只对「纯展示」有效。如果前端把 ~ 前缀的路径回传用作查询过滤(如 root 参数),就拿不到真实匹配,因为数据库里的路径存的是脱敏前的真实路径。所以这种会被回传的字段不能脱敏。
相关项目
video-library 与 markdown-library、photo-library 是同一作者的本地优先媒体库三件套:
- markdown-library — 本地优先 Markdown 只读索引:frontmatter/标题/链接/断链检查,零依赖自包含渲染
- photo-library — 本地优先照片库索引:内容哈希精确去重 + 感知哈希近重 + EXIF 维度索引
发表回复