Skip to content

feat: 弹幕增量更新(三段式策略 + 换源检测 + 手动刷新) - #703

Open
CorrectRoadH wants to merge 1 commit into
amtoaer:mainfrom
CorrectRoadH:feat/danmaku-incremental-update
Open

feat: 弹幕增量更新(三段式策略 + 换源检测 + 手动刷新)#703
CorrectRoadH wants to merge 1 commit into
amtoaer:mainfrom
CorrectRoadH:feat/danmaku-incremental-update

Conversation

@CorrectRoadH

@CorrectRoadH CorrectRoadH commented Apr 13, 2026

Copy link
Copy Markdown

背景

视频下载完成后弹幕只会被抓取一次。但 B 站弹幕在视频发布后的几天到几周内会持续增长,老用户希望本地副本能"长出来"。本 PR 引入一套三段式弹幕增量更新策略,让 bili-sync 在下载完成后按可配置的节奏继续追踪弹幕,直到视频进入冷冻状态。

默认关闭,启用与否对现有用户完全无感。

close #660

IMAGE-N6XzaioC@2x IMAGE-xQC40LmM@2x IMAGE-7h9Ed0K1@2x

策略设计

依据视频发布年龄分段,符合弹幕密度衰减曲线:

阶段 触发条件 默认间隔
新鲜期 age < fresh_days 每 6 小时
成熟期 fresh_days ≤ age < mature_days 每 3 天
老化期 mature_days ≤ age < cold_days 每 30 天
冷冻 age ≥ cold_days 触发最后一次后永不自动刷新(手动仍可)

默认值 fresh_days=3 / mature_days=30 / cold_days=180,覆盖 "一次性更新" 和 "周期更新" 两种简单需求作为退化配置。

主要内容

后端

  • DB 迁移 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-return
  • 换源检测:每视频拉一次 get_view_info,对比 cid / duration / dimension。cid 变化时保留弹幕位 OK(已用新 cid 写盘),但清掉其他子任务位 + video 完成位,让主流程下一轮重抓 MP4/SRT/封面,避免本地资产与新内容错配
  • 原子写入:弹幕先写 .tmp 再 rename,避免播放器读到半截 ASS
  • API
    • POST /api/videos/{id}/refresh-danmaku — best-effort,返回成功的 page 数
    • POST /api/pages/{id}/refresh-danmaku — 严格模式,任何错误均 4xx/5xx,前端 toast 不会撒谎

前端

  • 设置页 → 弹幕渲染 Tab 底部新增"弹幕增量更新"折叠区,三段配置 + 启用开关联动
  • 视频详情页操作栏新增"刷新弹幕"按钮,每个分页 VideoCard 下方展示阶段 Badge + 相对时间 + 单页刷新按钮

设计细节

  1. disabled 源跳过:候选过滤遵循项目 "disabled = 不动" 的统一约定,关掉的收藏夹/合集/UP 主投稿不会被后台默默拉接口。
  2. policy off 时不写同步字段:避免老视频被 stage_for_age(allow_freeze=true) 误标 Frozen 后再也不被刷新。
  3. 手动刷新 cap 在 Cold:用户主动触发时,generation 按当前年龄计算但不允许写 Frozen。这样不会把 Mature/Cold 视频意外回退成 Fresh,也不会把活跃视频意外冻结。
  4. 首次下载即首次同步:主下载流程在弹幕子任务现下载成功时同步写三个字段,避免下一轮 incremental 把刚下完的 page 误判为"从未同步"再重抓。

视频下载完成后按发布年龄分段持续刷新弹幕:新鲜期高频、成熟期中频、
老化期低频;超过冷冻阈值后触发最后一次更新并冻结,不再自动刷新(手动
仍可触发)。默认关闭,保持向后兼容。

主要内容:
- 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>
@CorrectRoadH
CorrectRoadH marked this pull request as ready for review April 13, 2026 11:21

@amtoaer amtoaer left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

感谢你的贡献,整体看下来问题稍多,主要是因为有些我不太赞同的设计问题,比较重要的是:

  1. migration 中 danmaku_last_synced_at 默认为 null 会导致所有存量视频的弹幕刷新,且由于弹幕下载任务不使用缓存,开启功能后所有存量视频都保底会请求一遍视频详情接口。
  2. 项目核心是视频备份,弹幕更新是辅助功能。在弹幕更新时发现分页有变化就直接把本地视频覆盖成换源后的,有些舍本逐末。
  3. 在现有的逻辑下可以通过操作状态位触发弹幕刷新,按我的想法在下载前根据配置调整下标志位再走到下载逻辑统一处理就好,没必要单独引入一套流程和交互。可能也是 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 {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

和存量视频类似,如果下载成功时只要功能未开启就不更新 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> {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

典型的 AI 做法,不考虑重构当前代码做复用,而是相同逻辑到处复制粘贴,包括上面修改 status 的 reset_non_danmaku_subtasksreset_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>,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DateTime

Comment on lines +56 to +62
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?;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

接口等待下载完全执行完毕才返回结果,感觉不太合适

Comment on lines +26 to +40
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
}
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

其实可以 impl From for State,impl From for u32

Comment on lines +399 to +400
// 原子写入:先写到 .tmp,再 rename,避免播放器读到半截 ASS
let tmp_path = make_tmp_path(&danmaku_path);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

写入 ass 过程中刚好播放到的可能性应该不大,如果想用 tmp 的话可以直接复用现有的 TempFile。

Comment on lines +416 to +424
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);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里我认为不合理,为什么更新弹幕时发现 UP 换了源,默认的行为不是报错而是把本地视频直接覆盖掉呢?

Comment on lines +434 to +440
if duration_changed {
active.duration = Set(fresh.duration);
}
if dimension_changed {
active.width = Set(fresh_width);
active.height = Set(fresh_height);
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

按照 PR 的思路,这两个判断写在这里也有些奇怪。

上面逻辑明确把 cid 变化作为 UP 换源的条件并做了对应处理,那么时长和分辨率的变化自然应该在 cid_changed 里面,把这两个判断写在这儿虽然也能处理 cid 变化表示 UP 换源的情况,但同时埋了视频时长、分辨率发生变化但绕过换源处理的坑。

如果明确换源导致 cid 变化应该理清逻辑把这两个条件写在里面,如果明确换源 cid 维持不变就应该去掉 cid 的判断,仅保留视频时长和分辨率变化的检查。如果拿不准想要同时兼顾两种情况也应该把三个条件在前面分别 Set 对应字段,最后在 if cid_changed || duration_changed || dimension_changed 里做换源处理。现在这样写估计是想兼顾但没考虑清楚。

Comment on lines +503 to +510
/// 解析数据库中 `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))
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

也是典型的 AI 做法,model 定义时支持用 DateTime,serde 会自动做序列化,没必要特意用 String 然后手动搞一份 parse 绕弯。

Comment on lines +317 to +336
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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这样写的行为是:

  1. UP 删掉视频导致编号减少 -> 本地多出来的视频报 warn 不处理;
  2. UP 添加视频导致编号增加 -> 云端多出来的视频无感知,不处理;
  3. UP 就地编辑视频 -> 走到 refresh_one_page 触发换源,直接把本地替换成新视频。

就像我下面 comment 说的,我认为弹幕更新时发现换源就整个替换现有视频不太合理,直接报错是更好的选择。但抛开这点不谈,如果认可换源 -> 替换,这样写也只能处理就地替换,无法处理新增、删除导致的视频数量变化,不算一个完善的实现。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

求弹幕更新

2 participants