chore: 移除手动触发及相关逻辑(和当前的状态编辑功能基本重合)

This commit is contained in:
amtoaer
2026-04-15 00:34:48 +08:00
parent 8b18e066e2
commit 58ab98a77a
6 changed files with 8 additions and 271 deletions

View File

@@ -28,7 +28,6 @@ use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
use crate::bilibili::BiliClient;
use crate::config::VersionedConfig;
use crate::utils::status::{PageStatus, VideoStatus};
use crate::workflow_danmaku::{refresh_danmaku_for_page, refresh_danmaku_for_video};
pub(super) fn router() -> Router {
Router::new()
@@ -42,37 +41,8 @@ pub(super) fn router() -> Router {
.route("/videos/{id}/update-status", post(update_video_status))
.route("/videos/reset-status", post(reset_filtered_video_status))
.route("/videos/update-status", post(update_filtered_video_status))
.route("/videos/{id}/refresh-danmaku", post(refresh_video_danmaku))
.route("/pages/{id}/refresh-danmaku", post(refresh_page_danmaku))
}
#[derive(Serialize)]
pub struct RefreshDanmakuResponse {
/// 本次实际刷新成功的 page 数量page 级接口始终为 0 或 1。
pub refreshed: usize,
}
/// 手动触发:刷新某个视频所有 page 的弹幕。忽略策略,强制执行。
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?;
Ok(ApiResponse::ok(RefreshDanmakuResponse { refreshed }))
}
/// 手动触发:刷新单个 page 的弹幕。忽略策略,强制执行;走严格模式,任何错误都直接 4xx/5xx。
pub async fn refresh_page_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_page(id, &bili_client, &db, &config).await?;
Ok(ApiResponse::ok(RefreshDanmakuResponse { refreshed }))
}
/// 列出视频的基本信息,支持根据视频来源筛选、名称查找和分页
pub async fn get_videos(

View File

@@ -50,10 +50,7 @@ pub async fn refresh_danmaku_incremental(
.into_iter()
.filter_map(|page| {
let pubtime = video_model.pubtime.and_utc();
let last_synced = page
.danmaku_last_synced_at
.as_deref()
.and_then(parse_stored_datetime);
let last_synced = page.danmaku_last_synced_at.as_deref().and_then(parse_stored_datetime);
match should_sync_danmaku(
&config.danmaku_update_policy,
pubtime,
@@ -82,101 +79,16 @@ pub async fn refresh_danmaku_incremental(
}
}
}
info!(
"弹幕增量更新结束:处理视频 {} 个,刷新分页 {} 个",
processed, refreshed
);
info!("弹幕增量更新结束:处理视频 {} 个,刷新分页 {} 个", processed, refreshed);
Ok(())
}
/// 手动触发:刷新某个视频所有 page 的弹幕(忽略策略,强制执行)。
pub async fn refresh_danmaku_for_video(
video_id: i32,
bili_client: &BiliClient,
connection: &DatabaseConnection,
config: &Config,
) -> Result<usize> {
let video_model = video::Entity::find_by_id(video_id)
.one(connection)
.await?
.ok_or_else(|| anyhow!("video {} 不存在", video_id))?;
let pages = page::Entity::find()
.filter(page::Column::VideoId.eq(video_id))
.all(connection)
.await?;
if pages.is_empty() {
return Ok(0);
}
let now = Utc::now();
// 手动触发next_stage 传 None让 refresh_one_page 内部按 age 计算(且不会冻结)。
// 这样不会把 Mature/Cold 视频回退成 Fresh也不会把活跃视频意外冻结。
let selected = pages.into_iter().map(|p| (p, None)).collect();
refresh_video_pages(bili_client, connection, config, &video_model, selected, now).await
}
/// 手动触发:刷新单个 page 的弹幕(忽略策略,强制执行)。
///
/// 与 [`refresh_danmaku_for_video`] 的 best-effort 模式不同,本接口走严格模式:
/// 只要存在任何错误page 不存在、view_info 拉取失败、新 view_info 中 pid 不再出现、
/// 弹幕写入失败等)都直接 bail确保 API 调用方不会收到"假成功"。
///
/// 成功时返回刷新成功的 page 数(恒为 1
pub async fn refresh_danmaku_for_page(
page_id: i32,
bili_client: &BiliClient,
connection: &DatabaseConnection,
config: &Config,
) -> Result<usize> {
let page_model = page::Entity::find_by_id(page_id)
.one(connection)
.await?
.ok_or_else(|| anyhow!("page {} 不存在", page_id))?;
let video_model = video::Entity::find_by_id(page_model.video_id)
.one(connection)
.await?
.ok_or_else(|| anyhow!("page {} 的宿主 video 不存在", page_id))?;
let now = Utc::now();
let bili_video = Video::new(bili_client, video_model.bvid.as_str(), &config.credential);
let view_info = bili_video
.get_view_info()
.await
.with_context(|| format!("获取视频 {} 的 view_info 失败", video_model.bvid))?;
let VideoInfo::Detail { pages: fresh_pages, .. } = view_info else {
bail!("view_info 返回了非 Detail 类型,无法刷新弹幕");
};
let fresh = fresh_pages
.iter()
.find(|p| p.page == page_model.pid)
.ok_or_else(|| {
anyhow!(
"视频「{}」({}) 的分页 pid={} 在最新的 view_info 中已不存在",
video_model.name,
video_model.bvid,
page_model.pid
)
})?;
refresh_one_page(
&bili_video,
connection,
config,
&video_model,
page_model,
fresh,
None,
now,
)
.await?;
Ok(1)
}
/// 候选视频:有效 + 有路径(至少下载过) + 至少存在一个 page 的 download_status 弹幕位已成功,
/// 且**所属源仍处于启用状态**。
///
/// 与项目里其他流程保持一致disabled 源被视为"用户主动暂停处理",弹幕增量也不再触碰它的内容,
/// 避免后台默默地继续请求 B 站接口和改写本地 ASS 文件。
async fn load_candidate_videos(
connection: &DatabaseConnection,
) -> Result<Vec<(video::Model, Vec<page::Model>)>> {
async fn load_candidate_videos(connection: &DatabaseConnection) -> Result<Vec<(video::Model, Vec<page::Model>)>> {
use sea_orm::{Condition, QuerySelect};
// 一次性取齐四类启用源的 id 集合
@@ -214,11 +126,7 @@ async fn load_candidate_videos(
.context("load enabled watch_later ids failed")?;
// 至少一个外键命中启用集合,才纳入候选;全部为空时直接 early-return 避免无意义查询。
if favorite_ids.is_empty()
&& collection_ids.is_empty()
&& submission_ids.is_empty()
&& watch_later_ids.is_empty()
{
if favorite_ids.is_empty() && collection_ids.is_empty() && submission_ids.is_empty() && watch_later_ids.is_empty() {
return Ok(Vec::new());
}
let mut source_filter = Condition::any();
@@ -487,10 +395,7 @@ fn resolve_danmaku_path(video_model: &video::Model, page_model: &page::Model) ->
.0;
Ok(base_path
.join("Season 1")
.join(format!(
"{} - S01E{:0>2}.zh-CN.default.ass",
base_name, page_model.pid
)))
.join(format!("{} - S01E{:0>2}.zh-CN.default.ass", base_name, page_model.pid)))
}
}
@@ -525,10 +430,7 @@ mod tests {
#[test]
fn reset_non_danmaku_subtasks_keeps_only_danmaku_ok() {
// 五个子任务都 OK + 完成位
let all_ok_completed: u32 = (1u32 << 31)
| (0..5)
.map(|i| STATUS_OK << (i * 3))
.fold(0u32, |a, b| a | b);
let all_ok_completed: u32 = (1u32 << 31) | (0..5).map(|i| STATUS_OK << (i * 3)).fold(0u32, |a, b| a | b);
let reset = reset_non_danmaku_subtasks(all_ok_completed);
// 弹幕位保留
assert_eq!((reset >> 9) & 0b111, STATUS_OK);
@@ -543,10 +445,7 @@ mod tests {
#[test]
fn reset_video_for_page_redownload_clears_subtask_4_and_completed_bit() {
// 五个子任务都 OK + 完成位
let video_done: u32 = (1u32 << 31)
| (0..5)
.map(|i| STATUS_OK << (i * 3))
.fold(0u32, |a, b| a | b);
let video_done: u32 = (1u32 << 31) | (0..5).map(|i| STATUS_OK << (i * 3)).fold(0u32, |a, b| a | b);
let reset = reset_video_for_page_redownload(video_done);
// offset 4分页下载子任务被清零
assert_eq!((reset >> 12) & 0b111, 0);
@@ -560,9 +459,7 @@ mod tests {
#[test]
fn parse_stored_datetime_roundtrip() {
let now = chrono::Utc
.with_ymd_and_hms(2026, 4, 13, 10, 20, 30)
.unwrap();
let now = chrono::Utc.with_ymd_and_hms(2026, 4, 13, 10, 20, 30).unwrap();
let s = now.naive_utc().to_string();
let parsed = parse_stored_datetime(&s).expect("parse ok");
assert_eq!(parsed, now);

View File

@@ -14,7 +14,6 @@ import type {
InsertSubmissionRequest,
Notifier,
QrcodePollResponse as PollQrcodeResponse,
RefreshDanmakuResponse,
ResetFilteredVideosResponse,
ResetFilteredVideoStatusRequest,
ResetVideoResponse,
@@ -288,13 +287,6 @@ class ApiClient {
return this.post<boolean>('/task/download');
}
async refreshDanmakuForVideo(id: number): Promise<ApiResponse<RefreshDanmakuResponse>> {
return this.post<RefreshDanmakuResponse>(`/videos/${id}/refresh-danmaku`);
}
async refreshDanmakuForPage(id: number): Promise<ApiResponse<RefreshDanmakuResponse>> {
return this.post<RefreshDanmakuResponse>(`/pages/${id}/refresh-danmaku`);
}
async generateQrcode(): Promise<ApiResponse<GenerateQrcodeResponse>> {
return this.post<GenerateQrcodeResponse>('/login/qrcode/generate');
@@ -354,8 +346,6 @@ const api = {
updateConfig: (config: Config) => apiClient.updateConfig(config),
getDashboard: () => apiClient.getDashboard(),
triggerDownloadTask: () => apiClient.triggerDownloadTask(),
refreshDanmakuForVideo: (id: number) => apiClient.refreshDanmakuForVideo(id),
refreshDanmakuForPage: (id: number) => apiClient.refreshDanmakuForPage(id),
generateQrcode: () => apiClient.generateQrcode(),
pollQrcode: (qrcodeKey: string) => apiClient.pollQrcode(qrcodeKey),
subscribeToSysInfo: (onMessage: (data: SysInfo) => void) =>

View File

@@ -6,38 +6,3 @@ export const VIDEO_SOURCES = {
SUBMISSION: { type: 'submission', title: '用户投稿', icon: UserIcon },
WATCH_LATER: { type: 'watch_later', title: '稍后再看', icon: ClockIcon }
};
/**
* 弹幕同步阶段标签映射,对应 Rust 端 `danmaku_sync_generation` 字段。
* 0=未开始;首次同步前不展示 badge。
*/
export const DANMAKU_GENERATION_LABELS: Record<
number,
{ text: string; variant: 'default' | 'secondary' | 'outline' | 'destructive' }
> = {
0: { text: '待更新', variant: 'outline' },
1: { text: '新鲜期', variant: 'default' },
2: { text: '成熟期', variant: 'secondary' },
3: { text: '老化期', variant: 'outline' },
4: { text: '已冻结', variant: 'outline' }
};
/** 将任意可解析为日期的字符串格式化为相对时间("2 小时前")。 */
export function formatRelativeTime(input: string | Date | null | undefined): string {
if (!input) return '从未同步';
const then = typeof input === 'string' ? new Date(input.replace(' ', 'T') + 'Z') : input;
const diff = Date.now() - then.getTime();
if (Number.isNaN(diff)) return '从未同步';
if (diff < 0) return '刚刚';
const minutes = Math.floor(diff / 60_000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days} 天前`;
const months = Math.floor(days / 30);
if (months < 12) return `${months} 个月前`;
const years = Math.floor(days / 365);
return `${years} 年前`;
}

View File

@@ -58,9 +58,6 @@ export interface PageInfo {
danmaku_cid_snapshot: number | null;
}
export interface RefreshDanmakuResponse {
refreshed: number;
}
export interface VideoResponse {
video: VideoInfo;

View File

@@ -17,7 +17,6 @@
import VideoCard from '$lib/components/video-card.svelte';
import StatusEditor from '$lib/components/status-editor.svelte';
import { Badge } from '$lib/components/ui/badge/index.js';
import { DANMAKU_GENERATION_LABELS, formatRelativeTime } from '$lib/consts';
import { toast } from 'svelte-sonner';
let videoData: VideoResponse | null = null;
@@ -29,8 +28,6 @@
let clearAndResetting = false;
let statusEditorOpen = false;
let statusEditorLoading = false;
let refreshingDanmaku = false;
let refreshingPageDanmaku = new Set<number>();
async function loadVideoDetail() {
const videoId = parseInt($page.params.id!);
@@ -122,44 +119,6 @@
}
}
async function handleRefreshDanmaku() {
if (!videoData || refreshingDanmaku) return;
refreshingDanmaku = true;
try {
const result = await api.refreshDanmakuForVideo(videoData.video.id);
toast.success('弹幕刷新完成', {
description: `已成功刷新 ${result.data.refreshed} 个分页`
});
await loadVideoDetail();
} catch (error) {
console.error('弹幕刷新失败:', error);
toast.error('弹幕刷新失败', {
description: (error as ApiError).message
});
} finally {
refreshingDanmaku = false;
}
}
async function handleRefreshPageDanmaku(pageId: number) {
if (refreshingPageDanmaku.has(pageId)) return;
refreshingPageDanmaku = new Set([...refreshingPageDanmaku, pageId]);
try {
await api.refreshDanmakuForPage(pageId);
toast.success('弹幕刷新完成');
await loadVideoDetail();
} catch (error) {
console.error('弹幕刷新失败:', error);
toast.error('弹幕刷新失败', {
description: (error as ApiError).message
});
} finally {
const next = new Set(refreshingPageDanmaku);
next.delete(pageId);
refreshingPageDanmaku = next;
}
}
async function handleClearAndReset() {
if (!videoData) return;
try {
@@ -243,17 +202,6 @@
<BrushCleaningIcon class="mr-2 h-4 w-4 {clearAndResetting ? 'animate-spin' : ''}" />
清空重置
</Button>
<Button
size="sm"
variant="outline"
class="shrink-0 cursor-pointer "
onclick={handleRefreshDanmaku}
disabled={refreshingDanmaku || resetting || clearAndResetting}
title="立即重新拉取所有分页的弹幕(忽略更新策略)"
>
<RefreshCwIcon class="mr-2 h-4 w-4 {refreshingDanmaku ? 'animate-spin' : ''}" />
刷新弹幕
</Button>
<Button
size="sm"
variant="outline"
@@ -315,36 +263,6 @@
customSubtitle=""
taskNames={['视频封面', '视频内容', '视频信息', '视频弹幕', '视频字幕']}
/>
<div
class="text-muted-foreground flex items-center justify-between gap-2 px-2 text-xs"
>
<div class="flex items-center gap-2">
{#if pageInfo.danmaku_sync_generation > 0}
{@const label = DANMAKU_GENERATION_LABELS[pageInfo.danmaku_sync_generation]}
<Badge variant={label?.variant ?? 'outline'} class="text-[10px]">
弹幕 · {label?.text ?? '—'}
</Badge>
{/if}
<span title={pageInfo.danmaku_last_synced_at ?? ''}>
{formatRelativeTime(pageInfo.danmaku_last_synced_at)}
</span>
</div>
<Button
size="sm"
variant="ghost"
class="h-6 px-2"
disabled={refreshingPageDanmaku.has(pageInfo.id)}
onclick={() => handleRefreshPageDanmaku(pageInfo.id)}
title="仅刷新该分页的弹幕"
>
<RefreshCwIcon
class="mr-1 h-3 w-3 {refreshingPageDanmaku.has(pageInfo.id)
? 'animate-spin'
: ''}"
/>
刷新
</Button>
</div>
</div>
{/each}
</div>