mirror of
https://github.com/amtoaer/bili-sync.git
synced 2026-05-06 20:42:48 +08:00
chore: 移除手动触发及相关逻辑(和当前的状态编辑功能基本重合)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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} 年前`;
|
||||
}
|
||||
|
||||
@@ -58,9 +58,6 @@ export interface PageInfo {
|
||||
danmaku_cid_snapshot: number | null;
|
||||
}
|
||||
|
||||
export interface RefreshDanmakuResponse {
|
||||
refreshed: number;
|
||||
}
|
||||
|
||||
export interface VideoResponse {
|
||||
video: VideoInfo;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user