diff --git a/Cargo.lock b/Cargo.lock index 1548c21..a063ef0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -501,6 +501,7 @@ dependencies = [ "tracing-subscriber", "utoipa", "utoipa-swagger-ui", + "validator", ] [[package]] @@ -2332,6 +2333,28 @@ dependencies = [ "version_check", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.96", +] + [[package]] name = "proc-macro2" version = "1.0.93" @@ -3981,6 +4004,36 @@ dependencies = [ "serde", ] +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna 1.0.3", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.96", +] + [[package]] name = "valuable" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index c60d4c5..ea78a0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,7 @@ tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["chrono"] } utoipa = { version = "5.3.1", features = ["axum_extras"] } utoipa-swagger-ui = { version = "9.0.0", features = ["axum", "vendored"] } +validator = { version = "0.20.0", features = ["derive"] } [workspace.metadata.release] release = false diff --git a/crates/bili_sync/Cargo.toml b/crates/bili_sync/Cargo.toml index d28b60a..80542c2 100644 --- a/crates/bili_sync/Cargo.toml +++ b/crates/bili_sync/Cargo.toml @@ -52,6 +52,7 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } utoipa = { workspace = true } utoipa-swagger-ui = { workspace = true } +validator = { workspace = true } [dev-dependencies] assert_matches = { workspace = true } diff --git a/crates/bili_sync/src/api/error.rs b/crates/bili_sync/src/api/error.rs index adacbb5..5d7446e 100644 --- a/crates/bili_sync/src/api/error.rs +++ b/crates/bili_sync/src/api/error.rs @@ -4,4 +4,6 @@ use thiserror::Error; pub enum InnerApiError { #[error("Primary key not found: {0}")] NotFound(i32), + #[error("Bad request: {0}")] + BadRequest(String), } diff --git a/crates/bili_sync/src/api/handler.rs b/crates/bili_sync/src/api/handler.rs index c2946a4..762d406 100644 --- a/crates/bili_sync/src/api/handler.rs +++ b/crates/bili_sync/src/api/handler.rs @@ -14,17 +14,17 @@ use utoipa::OpenApi; use crate::api::auth::OpenAPIAuth; use crate::api::error::InnerApiError; use crate::api::helper::{update_page_download_status, update_video_download_status}; -use crate::api::request::VideosRequest; +use crate::api::request::{ResetVideoStatusRequest, VideosRequest}; use crate::api::response::{ - PageInfo, ResetAllVideosResponse, ResetVideoResponse, VideoInfo, VideoResponse, VideoSource, VideoSourcesResponse, - VideosResponse, + PageInfo, ResetAllVideosResponse, ResetVideoResponse, ResetVideoStatusResponse, VideoInfo, VideoResponse, + VideoSource, VideoSourcesResponse, VideosResponse, }; -use crate::api::wrapper::{ApiError, ApiResponse}; +use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson}; use crate::utils::status::{PageStatus, VideoStatus}; #[derive(OpenApi)] #[openapi( - paths(get_video_sources, get_videos, get_video, reset_video, reset_all_videos), + paths(get_video_sources, get_videos, get_video, reset_video, reset_all_videos, reset_video_status), modifiers(&OpenAPIAuth), security( ("Token" = []), @@ -197,7 +197,7 @@ pub async fn reset_video( } let resetted_videos_info = if video_resetted { video_info.download_status = video_status.into(); - vec![video_info.clone()] + vec![&video_info] } else { vec![] }; @@ -283,3 +283,69 @@ pub async fn reset_all_videos( resetted_pages_count: resetted_pages_info.len(), })) } + +/// 重置指定视频及其分页的指定状态位 +#[utoipa::path( + post, + path = "/api/videos/{id}/reset-status", + request_body = ResetVideoStatusRequest, + responses( + (status = 200, body = ApiResponse), + ) +)] +pub async fn reset_video_status( + Path(id): Path, + Extension(db): Extension>, + ValidatedJson(request): ValidatedJson, +) -> Result, ApiError> { + let (video_info, mut pages_info) = tokio::try_join!( + video::Entity::find_by_id(id) + .into_partial_model::() + .one(db.as_ref()), + page::Entity::find() + .filter(page::Column::VideoId.eq(id)) + .order_by_asc(page::Column::Cid) + .into_partial_model::() + .all(db.as_ref()) + )?; + let Some(mut video_info) = video_info else { + return Err(InnerApiError::NotFound(id).into()); + }; + let mut video_status = VideoStatus::from(video_info.download_status); + for update in &request.video_updates { + video_status.set(update.status_index, update.status_value); + } + video_info.download_status = video_status.into(); + let mut updated_pages_info = Vec::new(); + let mut page_id_map = pages_info + .iter_mut() + .map(|page| (page.id, page)) + .collect::>(); + for page_update in &request.page_updates { + if let Some(page_info) = page_id_map.remove(&page_update.page_id) { + let mut page_status = PageStatus::from(page_info.download_status); + for update in &page_update.updates { + page_status.set(update.status_index, update.status_value); + } + page_info.download_status = page_status.into(); + updated_pages_info.push(page_info); + } + } + let has_video_updates = !request.video_updates.is_empty(); + let has_page_updates = !updated_pages_info.is_empty(); + if has_video_updates || has_page_updates { + let txn = db.begin().await?; + if has_video_updates { + update_video_download_status(&txn, &[&video_info], None).await?; + } + if has_page_updates { + update_page_download_status(&txn, &updated_pages_info, None).await?; + } + txn.commit().await?; + } + Ok(ApiResponse::ok(ResetVideoStatusResponse { + success: has_video_updates || has_page_updates, + video: video_info, + pages: pages_info, + })) +} diff --git a/crates/bili_sync/src/api/helper.rs b/crates/bili_sync/src/api/helper.rs index f0c43a9..d20c62f 100644 --- a/crates/bili_sync/src/api/helper.rs +++ b/crates/bili_sync/src/api/helper.rs @@ -1,44 +1,48 @@ +use std::borrow::Borrow; + use sea_orm::{ConnectionTrait, DatabaseTransaction}; use crate::api::response::{PageInfo, VideoInfo}; pub async fn update_video_download_status( txn: &DatabaseTransaction, - videos: &[VideoInfo], + videos: &[impl Borrow], batch_size: Option, ) -> Result<(), sea_orm::DbErr> { if videos.is_empty() { return Ok(()); } + let videos = videos.iter().map(|v| v.borrow()).collect::>(); if let Some(size) = batch_size { for chunk in videos.chunks(size) { execute_video_update_batch(txn, chunk).await?; } } else { - execute_video_update_batch(txn, videos).await?; + execute_video_update_batch(txn, &videos).await?; } Ok(()) } pub async fn update_page_download_status( txn: &DatabaseTransaction, - pages: &[PageInfo], + pages: &[impl Borrow], batch_size: Option, ) -> Result<(), sea_orm::DbErr> { if pages.is_empty() { return Ok(()); } + let pages = pages.iter().map(|v| v.borrow()).collect::>(); if let Some(size) = batch_size { for chunk in pages.chunks(size) { execute_page_update_batch(txn, chunk).await?; } } else { - execute_page_update_batch(txn, pages).await?; + execute_page_update_batch(txn, &pages).await?; } Ok(()) } -async fn execute_video_update_batch(txn: &DatabaseTransaction, videos: &[VideoInfo]) -> Result<(), sea_orm::DbErr> { +async fn execute_video_update_batch(txn: &DatabaseTransaction, videos: &[&VideoInfo]) -> Result<(), sea_orm::DbErr> { if videos.is_empty() { return Ok(()); } @@ -58,7 +62,7 @@ async fn execute_video_update_batch(txn: &DatabaseTransaction, videos: &[VideoIn Ok(()) } -async fn execute_page_update_batch(txn: &DatabaseTransaction, pages: &[PageInfo]) -> Result<(), sea_orm::DbErr> { +async fn execute_page_update_batch(txn: &DatabaseTransaction, pages: &[&PageInfo]) -> Result<(), sea_orm::DbErr> { if pages.is_empty() { return Ok(()); } diff --git a/crates/bili_sync/src/api/request.rs b/crates/bili_sync/src/api/request.rs index defb54d..90b9fac 100644 --- a/crates/bili_sync/src/api/request.rs +++ b/crates/bili_sync/src/api/request.rs @@ -1,6 +1,6 @@ use serde::Deserialize; -use utoipa::IntoParams; - +use utoipa::{IntoParams, ToSchema}; +use validator::Validate; #[derive(Deserialize, IntoParams)] pub struct VideosRequest { pub collection: Option, @@ -11,3 +11,28 @@ pub struct VideosRequest { pub page: Option, pub page_size: Option, } + +#[derive(Deserialize, Validate, ToSchema)] +pub struct StatusUpdate { + #[validate(range(min = 0, max = 4))] + pub status_index: usize, + #[validate(custom(function = "crate::utils::validation::validate_status_value"))] + pub status_value: u32, +} + +#[derive(Deserialize, ToSchema, Validate)] +pub struct PageStatusUpdate { + pub page_id: i32, + #[validate(nested)] + pub updates: Vec, +} + +#[derive(Deserialize, ToSchema, Validate)] +pub struct ResetVideoStatusRequest { + #[serde(default)] + #[validate(nested)] + pub video_updates: Vec, + #[serde(default)] + #[validate(nested)] + pub page_updates: Vec, +} diff --git a/crates/bili_sync/src/api/response.rs b/crates/bili_sync/src/api/response.rs index 41cf9eb..66a334e 100644 --- a/crates/bili_sync/src/api/response.rs +++ b/crates/bili_sync/src/api/response.rs @@ -39,13 +39,20 @@ pub struct ResetAllVideosResponse { pub resetted_pages_count: usize, } +#[derive(Serialize, ToSchema)] +pub struct ResetVideoStatusResponse { + pub success: bool, + pub video: VideoInfo, + pub pages: Vec, +} + #[derive(FromQueryResult, Serialize, ToSchema)] pub struct VideoSource { id: i32, name: String, } -#[derive(Serialize, ToSchema, DerivePartialModel, FromQueryResult, Clone)] +#[derive(Serialize, ToSchema, DerivePartialModel, FromQueryResult)] #[sea_orm(entity = "video::Entity")] pub struct VideoInfo { pub id: i32, diff --git a/crates/bili_sync/src/api/wrapper.rs b/crates/bili_sync/src/api/wrapper.rs index 7ca7b82..c64a146 100644 --- a/crates/bili_sync/src/api/wrapper.rs +++ b/crates/bili_sync/src/api/wrapper.rs @@ -1,9 +1,13 @@ use anyhow::Error; use axum::Json; +use axum::extract::rejection::JsonRejection; +use axum::extract::{FromRequest, Request}; use axum::response::IntoResponse; use reqwest::StatusCode; use serde::Serialize; +use serde::de::DeserializeOwned; use utoipa::ToSchema; +use validator::Validate; use crate::api::error::InnerApiError; @@ -18,6 +22,10 @@ impl ApiResponse { Self { status_code: 200, data } } + pub fn bad_request(data: T) -> Self { + Self { status_code: 400, data } + } + pub fn unauthorized(data: T) -> Self { Self { status_code: 401, data } } @@ -57,8 +65,31 @@ impl IntoResponse for ApiError { if let Some(inner_error) = self.0.downcast_ref::() { match inner_error { InnerApiError::NotFound(_) => return ApiResponse::not_found(self.0.to_string()).into_response(), + InnerApiError::BadRequest(_) => { + return ApiResponse::bad_request(self.0.to_string()).into_response(); + } } } ApiResponse::internal_server_error(self.0.to_string()).into_response() } } + +#[derive(Debug, Clone, Copy, Default)] +pub struct ValidatedJson(pub T); + +impl FromRequest for ValidatedJson +where + T: DeserializeOwned + Validate, + S: Send + Sync, + Json: FromRequest, +{ + type Rejection = ApiError; + + async fn from_request(req: Request, state: &S) -> Result { + let Json(value) = Json::::from_request(req, state).await?; + value + .validate() + .map_err(|e| ApiError::from(InnerApiError::BadRequest(e.to_string())))?; + Ok(ValidatedJson(value)) + } +} diff --git a/crates/bili_sync/src/task/http_server.rs b/crates/bili_sync/src/task/http_server.rs index 8b92131..92a0e1c 100644 --- a/crates/bili_sync/src/task/http_server.rs +++ b/crates/bili_sync/src/task/http_server.rs @@ -13,7 +13,9 @@ use utoipa::OpenApi; use utoipa_swagger_ui::{Config, SwaggerUi}; use crate::api::auth; -use crate::api::handler::{ApiDoc, get_video, get_video_sources, get_videos, reset_all_videos, reset_video}; +use crate::api::handler::{ + ApiDoc, get_video, get_video_sources, get_videos, reset_all_videos, reset_video, reset_video_status, +}; use crate::config::CONFIG; #[derive(Embed)] @@ -26,6 +28,7 @@ pub async fn http_server(database_connection: Arc) -> Result .route("/api/videos", get(get_videos)) .route("/api/videos/{id}", get(get_video)) .route("/api/videos/{id}/reset", post(reset_video)) + .route("/api/videos/{id}/reset-status", post(reset_video_status)) .route("/api/videos/reset-all", post(reset_all_videos)) .merge( SwaggerUi::new("/swagger-ui/") diff --git a/crates/bili_sync/src/utils/mod.rs b/crates/bili_sync/src/utils/mod.rs index e40c0f8..c4dd23e 100644 --- a/crates/bili_sync/src/utils/mod.rs +++ b/crates/bili_sync/src/utils/mod.rs @@ -5,7 +5,7 @@ pub mod model; pub mod nfo; pub mod signal; pub mod status; - +pub mod validation; use tracing_subscriber::util::SubscriberInitExt; pub fn init_logger(log_level: &str) { diff --git a/crates/bili_sync/src/utils/status.rs b/crates/bili_sync/src/utils/status.rs index 44e15d2..5dfcc8d 100644 --- a/crates/bili_sync/src/utils/status.rs +++ b/crates/bili_sync/src/utils/status.rs @@ -1,5 +1,6 @@ use crate::error::ExecutionStatus; +pub static STATUS_NOT_STARTED: u32 = 0b000; pub(super) static STATUS_MAX_RETRY: u32 = 0b100; pub static STATUS_OK: u32 = 0b111; pub static STATUS_COMPLETED: u32 = 1 << 31; @@ -34,7 +35,7 @@ impl Status { for i in 0..N { let status = self.get_status(i); if !(status < STATUS_MAX_RETRY || status == STATUS_OK) { - self.set_status(i, 0); + self.set_status(i, STATUS_NOT_STARTED); changed = true; } } diff --git a/crates/bili_sync/src/utils/validation.rs b/crates/bili_sync/src/utils/validation.rs new file mode 100644 index 0000000..4bb8bb7 --- /dev/null +++ b/crates/bili_sync/src/utils/validation.rs @@ -0,0 +1,13 @@ +use validator::ValidationError; + +use crate::utils::status::{STATUS_NOT_STARTED, STATUS_OK}; + +pub fn validate_status_value(value: u32) -> Result<(), ValidationError> { + if value == STATUS_OK || value == STATUS_NOT_STARTED { + Ok(()) + } else { + Err(ValidationError::new( + "status_value must be either STATUS_OK or STATUS_NOT_STARTED", + )) + } +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 813ce28..5f5f89a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -6,6 +6,8 @@ import type { VideoResponse, ResetVideoResponse, ResetAllVideosResponse, + ResetVideoStatusRequest, + ResetVideoStatusResponse, ApiError } from './types'; @@ -154,6 +156,18 @@ class ApiClient { async resetAllVideos(): Promise> { return this.post('/videos/reset-all'); } + + /** + * 重置视频状态位 + * @param id 视频 ID + * @param request 重置请求参数 + */ + async resetVideoStatus( + id: number, + request: ResetVideoStatusRequest + ): Promise> { + return this.post(`/videos/${id}/reset-status`, request); + } } // 创建默认的 API 客户端实例 @@ -186,6 +200,12 @@ export const api = { */ resetAllVideos: () => apiClient.resetAllVideos(), + /** + * 重置视频状态位 + */ + resetVideoStatus: (id: number, request: ResetVideoStatusRequest) => + apiClient.resetVideoStatus(id, request), + /** * 设置认证 token */ diff --git a/web/src/lib/components/status-editor.svelte b/web/src/lib/components/status-editor.svelte new file mode 100644 index 0000000..7009e07 --- /dev/null +++ b/web/src/lib/components/status-editor.svelte @@ -0,0 +1,250 @@ + + + + + + 编辑状态 + +
修改视频和分页的下载状态。可以将任务重置为未开始状态,或者标记为已完成。
+
+ ⚠️ 已完成任务被重置为未开始,任务重新执行时会覆盖现存文件。 +
+
+
+ +
+
+ +
+

视频状态

+
+
+ {#each videoTaskNames as taskName, index (index)} + handleVideoStatusChange(index, newStatus)} + onReset={() => resetVideoTask(index)} + disabled={loading} + /> + {/each} +
+
+
+ + + {#if pages.length > 0} +
+

分页状态

+
+ {#each pages as page (page.id)} +
+
+

P{page.pid}: {page.name}

+
+
+ {#each pageTaskNames as taskName, index (index)} + + handlePageStatusChange(page.id, index, newStatus)} + onReset={() => resetPageTask(page.id, index)} + disabled={loading} + /> + {/each} +
+
+ {/each} +
+
+ {/if} +
+
+ + + + + +
+
diff --git a/web/src/lib/components/status-task-card.svelte b/web/src/lib/components/status-task-card.svelte new file mode 100644 index 0000000..37d789f --- /dev/null +++ b/web/src/lib/components/status-task-card.svelte @@ -0,0 +1,80 @@ + + + +
+
+
+
+ {taskName} + {#if isModified} + +
+ {/if} +
+
+
+ {statusInfo.label} +
+
+
+
+ {#if isModified} + + {/if} + + +
+
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 5bc773b..c076dc1 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -77,3 +77,28 @@ export interface ApiError { message: string; status?: number; } + +// 状态更新类型 +export interface StatusUpdate { + status_index: number; + status_value: number; +} + +// 页面状态更新类型 +export interface PageStatusUpdate { + page_id: number; + updates: StatusUpdate[]; +} + +// 重置视频状态请求类型 +export interface ResetVideoStatusRequest { + video_updates?: StatusUpdate[]; + page_updates?: PageStatusUpdate[]; +} + +// 重置视频状态响应类型 +export interface ResetVideoStatusResponse { + success: boolean; + video: VideoInfo; + pages: PageInfo[]; +} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index cb8c0bf..794c0f2 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -201,7 +201,7 @@ +
+ + +
@@ -175,4 +220,15 @@
{/if} + + + {#if videoData} + + {/if} {/if} diff --git a/web/vite.config.ts b/web/vite.config.ts index 8cfd7df..b9e51c2 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ server: { proxy: { '/api': 'http://localhost:12345' - } + }, + host: true } });