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

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

主要内容:
- 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>
This commit is contained in:
CorrectRoadH
2026-04-13 18:16:22 +08:00
co-authored by Claude Opus 4.6
parent c4b227e26e
commit 8b18e066e2
21 changed files with 1462 additions and 37 deletions
+2
View File
@@ -11,6 +11,7 @@ mod m20250712_080013_add_video_created_at_index;
mod m20250903_094454_add_rule_and_should_download;
mod m20251009_123713_add_use_dynamic_api;
mod m20260324_055217_add_staff;
mod m20260413_000001_add_danmaku_sync_fields;
pub struct Migrator;
@@ -29,6 +30,7 @@ impl MigratorTrait for Migrator {
Box::new(m20250903_094454_add_rule_and_should_download::Migration),
Box::new(m20251009_123713_add_use_dynamic_api::Migration),
Box::new(m20260324_055217_add_staff::Migration),
Box::new(m20260413_000001_add_danmaku_sync_fields::Migration),
]
}
}
@@ -0,0 +1,81 @@
use sea_orm_migration::prelude::*;
use sea_orm_migration::schema::*;
/// 为 page 表新增"弹幕增量更新"所需字段。
///
/// - `danmaku_last_synced_at`: 上次弹幕成功同步的时间戳(含首次下载成功),为空表示从未同步过。
/// - `danmaku_sync_generation`: 弹幕同步阶段标记。0=未开始,1=新鲜期,2=成熟期,3=老化期,4=已冻结。
/// - `danmaku_cid_snapshot`: 上次成功同步时使用的 cid,用于 UP 主换源检测。
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// SQLite 不支持单条 ALTER TABLE 同时修改多列,必须拆分为独立语句。
manager
.alter_table(
Table::alter()
.table(Page::Table)
.add_column(timestamp_null(Page::DanmakuLastSyncedAt))
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Page::Table)
.add_column(
ColumnDef::new(Page::DanmakuSyncGeneration)
.unsigned()
.not_null()
.default(0u32),
)
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Page::Table)
.add_column(big_integer_null(Page::DanmakuCidSnapshot))
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Page::Table)
.drop_column(Page::DanmakuLastSyncedAt)
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Page::Table)
.drop_column(Page::DanmakuSyncGeneration)
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Page::Table)
.drop_column(Page::DanmakuCidSnapshot)
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum Page {
Table,
DanmakuLastSyncedAt,
DanmakuSyncGeneration,
DanmakuCidSnapshot,
}