diff --git a/crates/bili_sync/src/api/request.rs b/crates/bili_sync/src/api/request.rs index 6bba0c2..75ef2ca 100644 --- a/crates/bili_sync/src/api/request.rs +++ b/crates/bili_sync/src/api/request.rs @@ -150,3 +150,8 @@ pub struct DefaultPathRequest { pub struct PollQrcodeRequest { pub qrcode_key: String, } + +#[derive(Debug, Deserialize)] +pub struct FullSyncVideoSourceRequest { + pub delete_local: bool, +} diff --git a/crates/bili_sync/src/api/response.rs b/crates/bili_sync/src/api/response.rs index ba1b4f4..b77b83e 100644 --- a/crates/bili_sync/src/api/response.rs +++ b/crates/bili_sync/src/api/response.rs @@ -229,3 +229,9 @@ pub struct UpdateVideoSourceResponse { pub type GenerateQrcodeResponse = Qrcode; pub type PollQrcodeResponse = PollStatus; + +#[derive(Serialize)] +pub struct FullSyncVideoSourceResponse { + pub removed_count: usize, + pub warnings: Option>, +} diff --git a/crates/bili_sync/src/api/routes/video_sources/mod.rs b/crates/bili_sync/src/api/routes/video_sources/mod.rs index 4616b83..b7bca39 100644 --- a/crates/bili_sync/src/api/routes/video_sources/mod.rs +++ b/crates/bili_sync/src/api/routes/video_sources/mod.rs @@ -1,12 +1,15 @@ +use std::collections::HashSet; use std::sync::Arc; -use anyhow::Result; -use axum::Router; +use anyhow::{Context, Result}; use axum::extract::{Extension, Path, Query}; use axum::routing::{get, post, put}; +use axum::{Json, Router}; use bili_sync_entity::rule::Rule; use bili_sync_entity::*; use bili_sync_migration::Expr; +use futures::stream::FuturesUnordered; +use futures::{StreamExt, TryStreamExt}; use itertools::Itertools; use sea_orm::ActiveValue::Set; use sea_orm::entity::prelude::*; @@ -15,11 +18,12 @@ use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QuerySelect, QueryTr use crate::adapter::{_ActiveModel, VideoSource as _, VideoSourceEnum}; use crate::api::error::InnerApiError; use crate::api::request::{ - DefaultPathRequest, InsertCollectionRequest, InsertFavoriteRequest, InsertSubmissionRequest, - UpdateVideoSourceRequest, + DefaultPathRequest, FullSyncVideoSourceRequest, InsertCollectionRequest, InsertFavoriteRequest, + InsertSubmissionRequest, UpdateVideoSourceRequest, }; use crate::api::response::{ - UpdateVideoSourceResponse, VideoSource, VideoSourceDetail, VideoSourcesDetailsResponse, VideoSourcesResponse, + FullSyncVideoSourceResponse, UpdateVideoSourceResponse, VideoSource, VideoSourceDetail, + VideoSourcesDetailsResponse, VideoSourcesResponse, }; use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson}; use crate::bilibili::{BiliClient, Collection, CollectionItem, FavoriteList, Submission}; @@ -39,6 +43,7 @@ pub(super) fn router() -> Router { put(update_video_source).delete(remove_video_source), ) .route("/video-sources/{type}/{id}/evaluate", post(evaluate_video_source)) + .route("/video-sources/{type}/{id}/full-sync", post(full_sync_video_source)) .route("/video-sources/favorites", post(insert_favorite)) .route("/video-sources/collections", post(insert_collection)) .route("/video-sources/submissions", post(insert_submission)) @@ -356,6 +361,80 @@ pub async fn evaluate_video_source( Ok(ApiResponse::ok(true)) } +pub async fn full_sync_video_source( + Path((source_type, id)): Path<(String, i32)>, + Extension(db): Extension, + Extension(bili_client): Extension>, + Json(request): Json, +) -> Result, ApiError> { + let video_source: Option = match source_type.as_str() { + "collections" => collection::Entity::find_by_id(id).one(&db).await?.map(Into::into), + "favorites" => favorite::Entity::find_by_id(id).one(&db).await?.map(Into::into), + "submissions" => submission::Entity::find_by_id(id).one(&db).await?.map(Into::into), + "watch_later" => watch_later::Entity::find_by_id(id).one(&db).await?.map(Into::into), + _ => return Err(InnerApiError::BadRequest("Invalid video source type".to_string()).into()), + }; + let Some(video_source) = video_source else { + return Err(InnerApiError::NotFound(id).into()); + }; + let credential = &VersionedConfig::get().read().credential; + let filter_expr = video_source.filter_expr(); + let (_, video_streams) = video_source.refresh(&bili_client, credential, &db).await?; + let all_videos = video_streams + .try_collect::>() + .await + .context("failed to read all videos from video stream")?; + let all_bvids = all_videos.into_iter().map(|v| v.bvid_owned()).collect::>(); + let videos_to_remove = video::Entity::find() + .filter(video::Column::Bvid.is_not_in(all_bvids).and(filter_expr)) + .select_only() + .columns([video::Column::Id, video::Column::Path]) + .into_tuple::<(i32, String)>() + .all(&db) + .await?; + if videos_to_remove.is_empty() { + return Ok(ApiResponse::ok(FullSyncVideoSourceResponse { + removed_count: 0, + warnings: None, + })); + } + let remove_count = videos_to_remove.len(); + let (video_ids, video_paths): (Vec, Vec) = videos_to_remove.into_iter().unzip(); + let txn = db.begin().await?; + page::Entity::delete_many() + .filter(page::Column::VideoId.is_in(video_ids.iter().copied())) + .exec(&txn) + .await?; + video::Entity::delete_many() + .filter(video::Column::Id.is_in(video_ids)) + .exec(&txn) + .await?; + txn.commit().await?; + let warnings = if request.delete_local { + let tasks = video_paths + .into_iter() + .map(|path| async move { + tokio::fs::remove_dir_all(&path) + .await + .with_context(|| format!("failed to remove {path}"))?; + Result::<_, anyhow::Error>::Ok(()) + }) + .collect::>(); + Some( + tasks + .filter_map(|res| futures::future::ready(res.err().map(|e| format!("{:#}", e)))) + .collect::>() + .await, + ) + } else { + None + }; + Ok(ApiResponse::ok(FullSyncVideoSourceResponse { + removed_count: remove_count, + warnings, + })) +} + /// 新增收藏夹订阅 pub async fn insert_favorite( Extension(db): Extension, diff --git a/crates/bili_sync/src/utils/convert.rs b/crates/bili_sync/src/utils/convert.rs index 1379c98..9803faa 100644 --- a/crates/bili_sync/src/utils/convert.rs +++ b/crates/bili_sync/src/utils/convert.rs @@ -182,6 +182,17 @@ impl VideoInfo { VideoInfo::Detail { .. } => unreachable!(), } } + + pub fn bvid_owned(self) -> String { + match self { + VideoInfo::Collection { bvid, .. } + | VideoInfo::Favorite { bvid, .. } + | VideoInfo::WatchLater { bvid, .. } + | VideoInfo::Submission { bvid, .. } + | VideoInfo::Dynamic { bvid, .. } + | VideoInfo::Detail { bvid, .. } => bvid, + } + } } impl PageInfo { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index e4a69ec..7e02098 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -6,6 +6,8 @@ import type { Config, DashBoardResponse, FavoritesResponse, + FullSyncVideoSourceRequest, + FullSyncVideoSourceResponse, QrcodeGenerateResponse as GenerateQrcodeResponse, InsertCollectionRequest, InsertFavoriteRequest, @@ -253,6 +255,14 @@ class ApiClient { return this.post(`/video-sources/${type}/${id}/evaluate`, null); } + async fullSyncVideoSource( + type: string, + id: number, + data: FullSyncVideoSourceRequest + ): Promise> { + return this.post(`/video-sources/${type}/${id}/full-sync`, data); + } + async getDefaultPath(type: string, name: string): Promise> { return this.get(`/video-sources/${type}/default-path`, { name }); } @@ -327,6 +337,8 @@ const api = { removeVideoSource: (type: string, id: number) => apiClient.removeVideoSource(type, id), evaluateVideoSourceRules: (type: string, id: number) => apiClient.evaluateVideoSourceRules(type, id), + fullSyncVideoSource: (type: string, id: number, data: { delete_local: boolean }) => + apiClient.fullSyncVideoSource(type, id, data), getDefaultPath: (type: string, name: string) => apiClient.getDefaultPath(type, name), testNotifier: (notifier: Notifier) => apiClient.testNotifier(notifier), getConfig: () => apiClient.getConfig(), diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 4bdd316..a2f975f 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -89,7 +89,16 @@ export interface UpdateFilteredVideoStatusResponse { export interface ApiError { message: string; - status?: number; + status: number; +} + +export interface FullSyncVideoSourceRequest { + delete_local: boolean; +} + +export interface FullSyncVideoSourceResponse { + removed_count: number; + warnings?: string[]; } export interface StatusUpdate { diff --git a/web/src/routes/video-sources/+page.svelte b/web/src/routes/video-sources/+page.svelte index 4284c55..ba1929c 100644 --- a/web/src/routes/video-sources/+page.svelte +++ b/web/src/routes/video-sources/+page.svelte @@ -18,7 +18,8 @@ InfoIcon, Trash2Icon, CircleCheckBigIcon, - CircleXIcon + CircleXIcon, + RefreshCwIcon } from '@lucide/svelte/icons'; import * as Tooltip from '$lib/components/ui/tooltip/index.js'; import { toast } from 'svelte-sonner'; @@ -58,6 +59,13 @@ let removeIdx: number = 0; let removing = false; + // 全量更新对话框状态 + let showFullSyncDialog = false; + let fullSyncSource: VideoSourceDetail | null = null; + let fullSyncType = ''; + let fullSyncDeleteLocal = false; + let fullSyncing = false; + // 编辑表单数据 let editForm = { path: '', @@ -120,6 +128,44 @@ showRemoveDialog = true; } + function openFullSyncDialog(type: string, source: VideoSourceDetail) { + fullSyncSource = source; + fullSyncType = type; + fullSyncDeleteLocal = false; + showFullSyncDialog = true; + } + + async function fullSyncVideoSource() { + if (!fullSyncSource) return; + fullSyncing = true; + try { + let response = await api.fullSyncVideoSource(fullSyncType, fullSyncSource.id, { + delete_local: fullSyncDeleteLocal + }); + if (response && response.data) { + showFullSyncDialog = false; + toast.success('全量更新成功', { + description: `已移除 ${response.data.removed_count} 个不存在的视频` + }); + if (response.data.warnings && response.data.warnings.length > 0) { + toast.warning('部分本地文件夹删除失败', { + description: response.data.warnings.join('\n'), + duration: 10000, + descriptionClass: 'whitespace-pre-line' + }); + } + } else { + toast.error('全量更新失败'); + } + } catch (error) { + toast.error('全量更新失败', { + description: (error as ApiError).message + }); + } finally { + fullSyncing = false; + } + } + // 保存编辑 async function saveEdit() { if (!editingSource) return; @@ -410,6 +456,21 @@

重新评估规则

+ + + + + +

全量更新视频

+
+
{#if activeTab !== 'watch_later'} @@ -581,6 +642,44 @@ + + + + 全量更新视频 + + 确定要全量更新视频源 "{fullSyncSource?.name}" 吗?
+ 该操作会拉取该视频源下所有当前存在的视频,移除数据库中已不存在于该源的视频及其分页数据,无法撤销
+
+
+
+ + +
+ + { + showFullSyncDialog = false; + }}>取消 + + {fullSyncing ? '全量更新中' : '确认全量更新'} + + +
+
+