From 58ab98a77a45e0c4b3cd6226cca0f080e22dfce7 Mon Sep 17 00:00:00 2001 From: amtoaer Date: Wed, 15 Apr 2026 00:34:48 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E7=A7=BB=E9=99=A4=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E5=8F=8A=E7=9B=B8=E5=85=B3=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=88=E5=92=8C=E5=BD=93=E5=89=8D=E7=9A=84=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=8A=9F=E8=83=BD=E5=9F=BA=E6=9C=AC=E9=87=8D?= =?UTF-8?q?=E5=90=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/bili_sync/src/api/routes/videos/mod.rs | 30 ----- crates/bili_sync/src/workflow_danmaku.rs | 119 ++---------------- web/src/lib/api.ts | 10 -- web/src/lib/consts.ts | 35 ------ web/src/lib/types.ts | 3 - web/src/routes/video/[id]/+page.svelte | 82 ------------ 6 files changed, 8 insertions(+), 271 deletions(-) diff --git a/crates/bili_sync/src/api/routes/videos/mod.rs b/crates/bili_sync/src/api/routes/videos/mod.rs index df4e05b..8e4c497 100644 --- a/crates/bili_sync/src/api/routes/videos/mod.rs +++ b/crates/bili_sync/src/api/routes/videos/mod.rs @@ -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, - Extension(db): Extension, - Extension(bili_client): Extension>, -) -> Result, 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, - Extension(db): Extension, - Extension(bili_client): Extension>, -) -> Result, 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( diff --git a/crates/bili_sync/src/workflow_danmaku.rs b/crates/bili_sync/src/workflow_danmaku.rs index 94ad059..706acd2 100644 --- a/crates/bili_sync/src/workflow_danmaku.rs +++ b/crates/bili_sync/src/workflow_danmaku.rs @@ -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 { - 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 { - 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)>> { +async fn load_candidate_videos(connection: &DatabaseConnection) -> Result)>> { 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); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index b6d622a..6f16b71 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -14,7 +14,6 @@ import type { InsertSubmissionRequest, Notifier, QrcodePollResponse as PollQrcodeResponse, - RefreshDanmakuResponse, ResetFilteredVideosResponse, ResetFilteredVideoStatusRequest, ResetVideoResponse, @@ -288,13 +287,6 @@ class ApiClient { return this.post('/task/download'); } - async refreshDanmakuForVideo(id: number): Promise> { - return this.post(`/videos/${id}/refresh-danmaku`); - } - - async refreshDanmakuForPage(id: number): Promise> { - return this.post(`/pages/${id}/refresh-danmaku`); - } async generateQrcode(): Promise> { return this.post('/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) => diff --git a/web/src/lib/consts.ts b/web/src/lib/consts.ts index 36c824f..c539b56 100644 --- a/web/src/lib/consts.ts +++ b/web/src/lib/consts.ts @@ -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} 年前`; -} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 02f6437..617f21c 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -58,9 +58,6 @@ export interface PageInfo { danmaku_cid_snapshot: number | null; } -export interface RefreshDanmakuResponse { - refreshed: number; -} export interface VideoResponse { video: VideoInfo; diff --git a/web/src/routes/video/[id]/+page.svelte b/web/src/routes/video/[id]/+page.svelte index 1cbfed3..34aa975 100644 --- a/web/src/routes/video/[id]/+page.svelte +++ b/web/src/routes/video/[id]/+page.svelte @@ -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(); 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 @@ 清空重置 - - {/each}