feat: 弹幕增量更新(三段式策略 + 换源检测 + 手动刷新) - #703
Conversation
视频下载完成后按发布年龄分段持续刷新弹幕:新鲜期高频、成熟期中频、
老化期低频;超过冷冻阈值后触发最后一次更新并冻结,不再自动刷新(手动
仍可触发)。默认关闭,保持向后兼容。
主要内容:
- DB: page 表新增 danmaku_last_synced_at / danmaku_sync_generation /
danmaku_cid_snapshot 三列(SQLite 拆分独立 ALTER)
- 配置: DanmakuUpdatePolicy 三段式参数 + 校验
- 决策: 纯函数 should_sync_danmaku + stage_for_age helper,覆盖单元测试
- 调度: 主下载结束后挂一次 refresh_danmaku_incremental(策略关时零开销)
- 换源检测: 每视频拉一次 view_info,对比 cid/duration/dimension;cid
变化时保留弹幕位 OK 但清掉其他子任务位 + video 完成位,让主流程重抓
MP4/SRT 等本地资产,避免本地资产与新内容错配
- 弹幕写入: 原子 rename,避免播放器读到半截 ASS
- API: POST /api/videos/{id}/refresh-danmaku(best-effort)
POST /api/pages/{id}/refresh-danmaku(严格模式,失败 4xx/5xx)
- 前端: 设置页弹幕 Tab 增加策略表单;视频详情页加"刷新弹幕"按钮 +
分页层弹幕阶段 Badge + 单页刷新
设计细节:
- 候选过滤遵循项目"disabled = 不动"的统一约定,跳过未启用源的视频
- 仅在策略启用时才在主下载路径写同步元数据,避免老视频被误标 Frozen
- 手动刷新 cap 在 Cold 阶段,不会把 Mature/Cold 视频意外回退或冻结
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
amtoaer
left a comment
There was a problem hiding this comment.
感谢你的贡献,整体看下来问题稍多,主要是因为有些我不太赞同的设计问题,比较重要的是:
- migration 中 danmaku_last_synced_at 默认为 null 会导致所有存量视频的弹幕刷新,且由于弹幕下载任务不使用缓存,开启功能后所有存量视频都保底会请求一遍视频详情接口。
- 项目核心是视频备份,弹幕更新是辅助功能。在弹幕更新时发现分页有变化就直接把本地视频覆盖成换源后的,有些舍本逐末。
- 在现有的逻辑下可以通过操作状态位触发弹幕刷新,按我的想法在下载前根据配置调整下标志位再走到下载逻辑统一处理就好,没必要单独引入一套流程和交互。可能也是 AI 的老毛病了,不考虑复用现有逻辑一味堆新功能。
因为设计调整要改的部分可能比较多,PR 可以先留着,等我有空在这个的基础上做修改。
| let mut page_active_model: page::ActiveModel = page_model.into(); | ||
| page_active_model.download_status = Set(status.into()); | ||
| page_active_model.path = Set(Some(video_path.to_string_lossy().to_string())); | ||
| if danmaku_just_succeeded && cx.config.danmaku_update_policy.enabled { |
There was a problem hiding this comment.
和存量视频类似,如果下载成功时只要功能未开启就不更新 danmaku_last_synced_at 让它保持 null,会导致开启时这些视频全部被重新刷一遍。
| /// 与 [`crate::workflow::download_page`] 的拼接规则保持一致: | ||
| /// - 单页视频: `{base_path}/{base_name}.zh-CN.default.ass` | ||
| /// - 多页视频: `{base_path}/Season 1/{base_name} - S01E{pid}.zh-CN.default.ass` | ||
| fn resolve_danmaku_path(video_model: &video::Model, page_model: &page::Model) -> Result<PathBuf> { |
There was a problem hiding this comment.
典型的 AI 做法,不考虑重构当前代码做复用,而是相同逻辑到处复制粘贴,包括上面修改 status 的 reset_non_danmaku_subtasks、reset_video_for_page_redownload,甚至包括 workflow_danmaku 这个文件本身。
这个需求在我看来最好的做法是下载前变更弹幕任务标志位,后面统一走现有的下载流程,但 AI 会专门引入一个大模块和大流程来做,理解不能。
| pub image: Option<String>, | ||
| pub download_status: u32, | ||
| pub created_at: String, | ||
| pub danmaku_last_synced_at: Option<String>, |
| pub async fn refresh_video_danmaku( | ||
| Path(id): Path<i32>, | ||
| Extension(db): Extension<DatabaseConnection>, | ||
| Extension(bili_client): Extension<Arc<BiliClient>>, | ||
| ) -> Result<ApiResponse<RefreshDanmakuResponse>, ApiError> { | ||
| let config = VersionedConfig::get().snapshot(); | ||
| let refreshed = refresh_danmaku_for_video(id, &bili_client, &db, &config).await?; |
| impl Stage { | ||
| pub fn from_generation(g: u32) -> Self { | ||
| match g { | ||
| 0 => Stage::Initial, | ||
| 1 => Stage::Fresh, | ||
| 2 => Stage::Mature, | ||
| 3 => Stage::Cold, | ||
| _ => Stage::Frozen, | ||
| } | ||
| } | ||
|
|
||
| pub fn as_generation(self) -> u32 { | ||
| self as u32 | ||
| } | ||
| } |
There was a problem hiding this comment.
其实可以 impl From for State,impl From for u32
| // 原子写入:先写到 .tmp,再 rename,避免播放器读到半截 ASS | ||
| let tmp_path = make_tmp_path(&danmaku_path); |
There was a problem hiding this comment.
写入 ass 过程中刚好播放到的可能性应该不大,如果想用 tmp 的话可以直接复用现有的 TempFile。
| if cid_changed { | ||
| // cid 变化 = UP 主把这页换成了不同内容(不是简单修正)。本地的 MP4/SRT/封面/NFO 都还指向 | ||
| // 旧 cid 的内容,必须让主下载流程重抓一次。这里: | ||
| // 1. 清掉 page 的非弹幕子任务位(弹幕已经用新 cid 写盘,保留 OK,避免下一轮 incremental 又跑一次)。 | ||
| // 2. 同时清掉所属 video 的"分页下载"子任务 + STATUS_COMPLETED 高位,让 video 重新被 | ||
| // filter_unhandled_video_pages 选中。否则 page 标记是"未完成"也没用,video 高位拦着。 | ||
| active.cid = Set(fresh.cid); | ||
| active.download_status = Set(reset_non_danmaku_subtasks(db_page.download_status)); | ||
| let new_video_status = reset_video_for_page_redownload(video_model.download_status); |
There was a problem hiding this comment.
这里我认为不合理,为什么更新弹幕时发现 UP 换了源,默认的行为不是报错而是把本地视频直接覆盖掉呢?
| if duration_changed { | ||
| active.duration = Set(fresh.duration); | ||
| } | ||
| if dimension_changed { | ||
| active.width = Set(fresh_width); | ||
| active.height = Set(fresh_height); | ||
| } |
There was a problem hiding this comment.
按照 PR 的思路,这两个判断写在这里也有些奇怪。
上面逻辑明确把 cid 变化作为 UP 换源的条件并做了对应处理,那么时长和分辨率的变化自然应该在 cid_changed 里面,把这两个判断写在这儿虽然也能处理 cid 变化表示 UP 换源的情况,但同时埋了视频时长、分辨率发生变化但绕过换源处理的坑。
如果明确换源导致 cid 变化应该理清逻辑把这两个条件写在里面,如果明确换源 cid 维持不变就应该去掉 cid 的判断,仅保留视频时长和分辨率变化的检查。如果拿不准想要同时兼顾两种情况也应该把三个条件在前面分别 Set 对应字段,最后在 if cid_changed || duration_changed || dimension_changed 里做换源处理。现在这样写估计是想兼顾但没考虑清楚。
| /// 解析数据库中 `danmaku_last_synced_at` 字符串(NaiveDateTime::to_string 的格式,例如 "2026-04-13 10:20:30")。 | ||
| fn parse_stored_datetime(s: &str) -> Option<DateTime<Utc>> { | ||
| // NaiveDateTime::to_string() 产出 "YYYY-MM-DD HH:MM:SS[.fraction]" | ||
| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") | ||
| .or_else(|_| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")) | ||
| .ok() | ||
| .map(|naive| Utc.from_utc_datetime(&naive)) | ||
| } |
There was a problem hiding this comment.
也是典型的 AI 做法,model 定义时支持用 DateTime,serde 会自动做序列化,没必要特意用 String 然后手动搞一份 parse 绕弯。
| for (db_page, next_stage) in selected { | ||
| let fresh = fresh_pages.iter().find(|p| p.page == db_page.pid); | ||
| let Some(fresh) = fresh else { | ||
| warn!( | ||
| "视频「{}」({}) 的分页 pid={} 在新拉取的 view_info 中不存在,跳过", | ||
| video_model.name, video_model.bvid, db_page.pid | ||
| ); | ||
| continue; | ||
| }; | ||
| if let Err(e) = refresh_one_page( | ||
| &bili_video, | ||
| connection, | ||
| config, | ||
| video_model, | ||
| db_page, | ||
| fresh, | ||
| next_stage, | ||
| now, | ||
| ) | ||
| .await |
There was a problem hiding this comment.
这样写的行为是:
- UP 删掉视频导致编号减少 -> 本地多出来的视频报 warn 不处理;
- UP 添加视频导致编号增加 -> 云端多出来的视频无感知,不处理;
- UP 就地编辑视频 -> 走到 refresh_one_page 触发换源,直接把本地替换成新视频。
就像我下面 comment 说的,我认为弹幕更新时发现换源就整个替换现有视频不太合理,直接报错是更好的选择。但抛开这点不谈,如果认可换源 -> 替换,这样写也只能处理就地替换,无法处理新增、删除导致的视频数量变化,不算一个完善的实现。
背景
视频下载完成后弹幕只会被抓取一次。但 B 站弹幕在视频发布后的几天到几周内会持续增长,老用户希望本地副本能"长出来"。本 PR 引入一套三段式弹幕增量更新策略,让 bili-sync 在下载完成后按可配置的节奏继续追踪弹幕,直到视频进入冷冻状态。
默认关闭,启用与否对现有用户完全无感。
close #660
策略设计
依据视频发布年龄分段,符合弹幕密度衰减曲线:
age < fresh_daysfresh_days ≤ age < mature_daysmature_days ≤ age < cold_daysage ≥ cold_days默认值
fresh_days=3 / mature_days=30 / cold_days=180,覆盖 "一次性更新" 和 "周期更新" 两种简单需求作为退化配置。主要内容
后端
m20260413_000001:page 表新增danmaku_last_synced_at/danmaku_sync_generation/danmaku_cid_snapshot(SQLite 拆分独立 ALTER)DanmakuUpdatePolicy:enabled + 三段阈值/间隔,启动时校验单调性should_sync_danmaku+stage_for_age:纯函数 + 16 个单元测试覆盖所有边界refresh_danmaku_incremental,策略关闭时零开销 early-returnget_view_info,对比 cid / duration / dimension。cid 变化时保留弹幕位 OK(已用新 cid 写盘),但清掉其他子任务位 + video 完成位,让主流程下一轮重抓 MP4/SRT/封面,避免本地资产与新内容错配.tmp再 rename,避免播放器读到半截 ASSPOST /api/videos/{id}/refresh-danmaku— best-effort,返回成功的 page 数POST /api/pages/{id}/refresh-danmaku— 严格模式,任何错误均 4xx/5xx,前端 toast 不会撒谎前端
设计细节
stage_for_age(allow_freeze=true)误标 Frozen 后再也不被刷新。