From f1703096fd04b0c46a640a9760333139e7c62001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=B4=80=E1=B4=8D=E1=B4=9B=E1=B4=8F=E1=B4=80=E1=B4=87?= =?UTF-8?q?=CA=80?= Date: Sat, 6 Dec 2025 19:47:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=A0=B9=E6=8D=AE?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E6=9D=A1=E4=BB=B6=E6=89=B9=E9=87=8F=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E8=A7=86=E9=A2=91=E7=9A=84=E4=B8=8B=E8=BD=BD=E7=8A=B6?= =?UTF-8?q?=E6=80=81=20(#558)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/bili_sync/src/api/helper.rs | 94 +++-- crates/bili_sync/src/api/request.rs | 28 +- crates/bili_sync/src/api/response.rs | 24 +- crates/bili_sync/src/api/routes/videos/mod.rs | 122 +++++-- web/src/lib/api.ts | 37 +- .../components/filtered-status-editor.svelte | 322 ++++++++++++++++++ web/src/lib/stores/filter.ts | 35 ++ web/src/lib/types.ts | 95 +++--- web/src/routes/video/[id]/+page.svelte | 2 +- web/src/routes/videos/+page.svelte | 116 ++++++- 10 files changed, 761 insertions(+), 114 deletions(-) create mode 100644 web/src/lib/components/filtered-status-editor.svelte diff --git a/crates/bili_sync/src/api/helper.rs b/crates/bili_sync/src/api/helper.rs index d20c62f..9c535b3 100644 --- a/crates/bili_sync/src/api/helper.rs +++ b/crates/bili_sync/src/api/helper.rs @@ -1,49 +1,90 @@ use std::borrow::Borrow; +use itertools::Itertools; use sea_orm::{ConnectionTrait, DatabaseTransaction}; -use crate::api::response::{PageInfo, VideoInfo}; +use crate::api::response::{PageInfo, SimplePageInfo, SimpleVideoInfo, VideoInfo}; -pub async fn update_video_download_status( +pub trait VideoRecord { + fn as_id_status_tuple(&self) -> (i32, u32); +} + +pub trait PageRecord { + fn as_id_status_tuple(&self) -> (i32, u32); +} + +impl VideoRecord for VideoInfo { + fn as_id_status_tuple(&self) -> (i32, u32) { + (self.id, self.download_status) + } +} + +impl VideoRecord for SimpleVideoInfo { + fn as_id_status_tuple(&self) -> (i32, u32) { + (self.id, self.download_status) + } +} + +impl PageRecord for PageInfo { + fn as_id_status_tuple(&self) -> (i32, u32) { + (self.id, self.download_status) + } +} + +impl PageRecord for SimplePageInfo { + fn as_id_status_tuple(&self) -> (i32, u32) { + (self.id, self.download_status) + } +} + +pub async fn update_video_download_status( txn: &DatabaseTransaction, - videos: &[impl Borrow], + videos: &[impl Borrow], batch_size: Option, -) -> Result<(), sea_orm::DbErr> { +) -> Result<(), sea_orm::DbErr> +where + T: VideoRecord, +{ 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?; + execute_video_update_batch(txn, chunk.iter().map(|v| v.borrow().as_id_status_tuple())).await?; } } else { - execute_video_update_batch(txn, &videos).await?; + execute_video_update_batch(txn, videos.iter().map(|v| v.borrow().as_id_status_tuple())).await?; } Ok(()) } -pub async fn update_page_download_status( +pub async fn update_page_download_status( txn: &DatabaseTransaction, - pages: &[impl Borrow], + pages: &[impl Borrow], batch_size: Option, -) -> Result<(), sea_orm::DbErr> { +) -> Result<(), sea_orm::DbErr> +where + T: PageRecord, +{ 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?; + execute_page_update_batch(txn, chunk.iter().map(|v| v.borrow().as_id_status_tuple())).await?; } } else { - execute_page_update_batch(txn, &pages).await?; + execute_page_update_batch(txn, pages.iter().map(|v| v.borrow().as_id_status_tuple())).await?; } Ok(()) } -async fn execute_video_update_batch(txn: &DatabaseTransaction, videos: &[&VideoInfo]) -> Result<(), sea_orm::DbErr> { - if videos.is_empty() { +async fn execute_video_update_batch( + txn: &DatabaseTransaction, + videos: impl Iterator, +) -> Result<(), sea_orm::DbErr> { + let values = videos.map(|v| format!("({}, {})", v.0, v.1)).join(", "); + if values.is_empty() { return Ok(()); } let sql = format!( @@ -52,18 +93,21 @@ async fn execute_video_update_batch(txn: &DatabaseTransaction, videos: &[&VideoI SET download_status = tempdata.download_status \ FROM tempdata \ WHERE video.id = tempdata.id", - videos - .iter() - .map(|v| format!("({}, {})", v.id, v.download_status)) - .collect::>() - .join(", ") + values ); txn.execute_unprepared(&sql).await?; Ok(()) } -async fn execute_page_update_batch(txn: &DatabaseTransaction, pages: &[&PageInfo]) -> Result<(), sea_orm::DbErr> { - if pages.is_empty() { +async fn execute_page_update_batch( + txn: &DatabaseTransaction, + pages: impl Iterator, +) -> Result<(), sea_orm::DbErr> { + let values = pages + .map(|p| format!("({}, {})", p.0, p.1)) + .collect::>() + .join(", "); + if values.is_empty() { return Ok(()); } let sql = format!( @@ -72,11 +116,7 @@ async fn execute_page_update_batch(txn: &DatabaseTransaction, pages: &[&PageInfo SET download_status = tempdata.download_status \ FROM tempdata \ WHERE page.id = tempdata.id", - pages - .iter() - .map(|p| format!("({}, {})", p.id, p.download_status)) - .collect::>() - .join(", ") + values ); txn.execute_unprepared(&sql).await?; Ok(()) diff --git a/crates/bili_sync/src/api/request.rs b/crates/bili_sync/src/api/request.rs index 27a7490..672d19c 100644 --- a/crates/bili_sync/src/api/request.rs +++ b/crates/bili_sync/src/api/request.rs @@ -16,7 +16,18 @@ pub struct VideosRequest { } #[derive(Deserialize)] -pub struct ResetRequest { +pub struct ResetVideoStatusRequest { + #[serde(default)] + pub force: bool, +} + +#[derive(Deserialize)] +pub struct ResetFilteredVideoStatusRequest { + pub collection: Option, + pub favorite: Option, + pub submission: Option, + pub watch_later: Option, + pub query: Option, #[serde(default)] pub force: bool, } @@ -46,6 +57,21 @@ pub struct UpdateVideoStatusRequest { pub page_updates: Vec, } +#[derive(Deserialize, Validate)] +pub struct UpdateFilteredVideoStatusRequest { + pub collection: Option, + pub favorite: Option, + pub submission: Option, + pub watch_later: Option, + pub query: Option, + #[serde(default)] + #[validate(nested)] + pub video_updates: Vec, + #[serde(default)] + #[validate(nested)] + pub page_updates: Vec, +} + #[derive(Deserialize)] pub struct FollowedCollectionsRequest { pub page_num: Option, diff --git a/crates/bili_sync/src/api/response.rs b/crates/bili_sync/src/api/response.rs index e7c3313..3b2a558 100644 --- a/crates/bili_sync/src/api/response.rs +++ b/crates/bili_sync/src/api/response.rs @@ -33,7 +33,7 @@ pub struct ResetVideoResponse { } #[derive(Serialize)] -pub struct ResetAllVideosResponse { +pub struct ResetFilteredVideosResponse { pub resetted: bool, pub resetted_videos_count: usize, pub resetted_pages_count: usize, @@ -46,6 +46,13 @@ pub struct UpdateVideoStatusResponse { pub pages: Vec, } +#[derive(Serialize)] +pub struct UpdateFilteredVideoStatusResponse { + pub success: bool, + pub updated_videos_count: usize, + pub updated_pages_count: usize, +} + #[derive(FromQueryResult, Serialize)] pub struct VideoSource { pub id: i32, @@ -75,6 +82,21 @@ pub struct PageInfo { pub download_status: u32, } +#[derive(Serialize, DerivePartialModel, FromQueryResult, Clone, Copy)] +#[sea_orm(entity = "video::Entity")] +pub struct SimpleVideoInfo { + pub id: i32, + pub download_status: u32, +} + +#[derive(Serialize, DerivePartialModel, FromQueryResult, Clone, Copy)] +#[sea_orm(entity = "page::Entity")] +pub struct SimplePageInfo { + pub id: i32, + pub video_id: i32, + pub download_status: u32, +} + fn serde_video_download_status(status: &u32, serializer: S) -> Result where S: serde::Serializer, diff --git a/crates/bili_sync/src/api/routes/videos/mod.rs b/crates/bili_sync/src/api/routes/videos/mod.rs index d0f04c1..7e1e97b 100644 --- a/crates/bili_sync/src/api/routes/videos/mod.rs +++ b/crates/bili_sync/src/api/routes/videos/mod.rs @@ -11,10 +11,13 @@ use sea_orm::{ use crate::api::error::InnerApiError; use crate::api::helper::{update_page_download_status, update_video_download_status}; -use crate::api::request::{ResetRequest, UpdateVideoStatusRequest, VideosRequest}; +use crate::api::request::{ + ResetFilteredVideoStatusRequest, ResetVideoStatusRequest, UpdateFilteredVideoStatusRequest, + UpdateVideoStatusRequest, VideosRequest, +}; use crate::api::response::{ - PageInfo, ResetAllVideosResponse, ResetVideoResponse, UpdateVideoStatusResponse, VideoInfo, VideoResponse, - VideosResponse, + PageInfo, ResetFilteredVideosResponse, ResetVideoResponse, SimplePageInfo, SimpleVideoInfo, + UpdateFilteredVideoStatusResponse, UpdateVideoStatusResponse, VideoInfo, VideoResponse, VideosResponse, }; use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson}; use crate::utils::status::{PageStatus, VideoStatus}; @@ -23,9 +26,10 @@ pub(super) fn router() -> Router { Router::new() .route("/videos", get(get_videos)) .route("/videos/{id}", get(get_video)) - .route("/videos/{id}/reset", post(reset_video)) - .route("/videos/reset-all", post(reset_all_videos)) + .route("/videos/{id}/reset-status", post(reset_video_status)) .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)) } /// 列出视频的基本信息,支持根据视频来源筛选、名称查找和分页 @@ -89,10 +93,10 @@ pub async fn get_video( })) } -pub async fn reset_video( +pub async fn reset_video_status( Path(id): Path, Extension(db): Extension, - Json(request): Json, + Json(request): Json, ) -> Result, ApiError> { let (video_info, pages_info) = tokio::try_join!( video::Entity::find_by_id(id).into_partial_model::().one(&db), @@ -134,7 +138,7 @@ pub async fn reset_video( let txn = db.begin().await?; if !resetted_videos_info.is_empty() { // 只可能有 1 个元素,所以不用 batch - update_video_download_status(&txn, &resetted_videos_info, None).await?; + update_video_download_status::(&txn, &resetted_videos_info, None).await?; } if !resetted_pages_info.is_empty() { update_page_download_status(&txn, &resetted_pages_info, Some(500)).await?; @@ -148,15 +152,34 @@ pub async fn reset_video( })) } -pub async fn reset_all_videos( +pub async fn reset_filtered_video_status( Extension(db): Extension, - Json(request): Json, -) -> Result, ApiError> { - // 先查询所有视频和页面数据 - let (all_videos, all_pages) = tokio::try_join!( - video::Entity::find().into_partial_model::().all(&db), - page::Entity::find().into_partial_model::().all(&db) - )?; + Json(request): Json, +) -> Result, ApiError> { + let mut query = video::Entity::find(); + for (field, column) in [ + (request.collection, video::Column::CollectionId), + (request.favorite, video::Column::FavoriteId), + (request.submission, video::Column::SubmissionId), + (request.watch_later, video::Column::WatchLaterId), + ] { + if let Some(id) = field { + query = query.filter(column.eq(id)); + } + } + if let Some(query_word) = request.query { + query = query.filter( + video::Column::Name + .contains(&query_word) + .or(video::Column::Bvid.contains(query_word)), + ); + } + let all_videos = query.into_partial_model::().all(&db).await?; + let all_pages = page::Entity::find() + .filter(page::Column::VideoId.is_in(all_videos.iter().map(|v| v.id))) + .into_partial_model::() + .all(&db) + .await?; let resetted_pages_info = all_pages .into_iter() .filter_map(|mut page_info| { @@ -200,7 +223,7 @@ pub async fn reset_all_videos( } txn.commit().await?; } - Ok(ApiResponse::ok(ResetAllVideosResponse { + Ok(ApiResponse::ok(ResetFilteredVideosResponse { resetted: has_video_updates || has_page_updates, resetted_videos_count: resetted_videos_info.len(), resetted_pages_count: resetted_pages_info.len(), @@ -248,10 +271,10 @@ pub async fn update_video_status( 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?; + update_video_download_status::(&txn, &[&video_info], None).await?; } if has_page_updates { - update_page_download_status(&txn, &updated_pages_info, None).await?; + update_page_download_status::(&txn, &updated_pages_info, None).await?; } txn.commit().await?; } @@ -261,3 +284,64 @@ pub async fn update_video_status( pages: pages_info, })) } + +pub async fn update_filtered_video_status( + Extension(db): Extension, + ValidatedJson(request): ValidatedJson, +) -> Result, ApiError> { + let mut query = video::Entity::find(); + for (field, column) in [ + (request.collection, video::Column::CollectionId), + (request.favorite, video::Column::FavoriteId), + (request.submission, video::Column::SubmissionId), + (request.watch_later, video::Column::WatchLaterId), + ] { + if let Some(id) = field { + query = query.filter(column.eq(id)); + } + } + if let Some(query_word) = request.query { + query = query.filter( + video::Column::Name + .contains(&query_word) + .or(video::Column::Bvid.contains(query_word)), + ); + } + let mut all_videos = query.into_partial_model::().all(&db).await?; + let mut all_pages = page::Entity::find() + .filter(page::Column::VideoId.is_in(all_videos.iter().map(|v| v.id))) + .into_partial_model::() + .all(&db) + .await?; + for video_info in all_videos.iter_mut() { + 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(); + } + for page_info in all_pages.iter_mut() { + let mut page_status = PageStatus::from(page_info.download_status); + for update in &request.page_updates { + page_status.set(update.status_index, update.status_value); + } + page_info.download_status = page_status.into(); + } + let has_video_updates = !all_videos.is_empty(); + let has_page_updates = !all_pages.is_empty(); + if has_video_updates || has_page_updates { + let txn = db.begin().await?; + if has_video_updates { + update_video_download_status(&txn, &all_videos, Some(500)).await?; + } + if has_page_updates { + update_page_download_status(&txn, &all_pages, Some(500)).await?; + } + txn.commit().await?; + } + Ok(ApiResponse::ok(UpdateFilteredVideoStatusResponse { + success: has_video_updates || has_page_updates, + updated_videos_count: all_videos.len(), + updated_pages_count: all_pages.len(), + })) +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index af4101b..5293cbc 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -5,7 +5,7 @@ import type { VideosResponse, VideoResponse, ResetVideoResponse, - ResetAllVideosResponse, + ResetFilteredVideosResponse, UpdateVideoStatusRequest, UpdateVideoStatusResponse, ApiError, @@ -21,8 +21,12 @@ import type { DashBoardResponse, SysInfo, TaskStatus, - ResetRequest, - UpdateVideoSourceResponse + ResetVideoStatusRequest, + UpdateVideoSourceResponse, + Notifier, + UpdateFilteredVideoStatusRequest, + UpdateFilteredVideoStatusResponse, + ResetFilteredVideoStatusRequest } from './types'; import { wsManager } from './ws'; @@ -152,12 +156,17 @@ class ApiClient { return this.get(`/videos/${id}`); } - async resetVideo(id: number, request: ResetRequest): Promise> { - return this.post(`/videos/${id}/reset`, request); + async resetVideoStatus( + id: number, + request: ResetVideoStatusRequest + ): Promise> { + return this.post(`/videos/${id}/reset-status`, request); } - async resetAllVideos(request: ResetRequest): Promise> { - return this.post('/videos/reset-all', request); + async resetFilteredVideoStatus( + request: ResetFilteredVideoStatusRequest + ): Promise> { + return this.post('/videos/reset-status', request); } async updateVideoStatus( @@ -167,6 +176,12 @@ class ApiClient { return this.post(`/videos/${id}/update-status`, request); } + async updateFilteredVideoStatus( + request: UpdateFilteredVideoStatusRequest + ): Promise> { + return this.post('/videos/update-status', request); + } + async getCreatedFavorites(): Promise> { return this.get('/me/favorites'); } @@ -268,10 +283,14 @@ const api = { getVideoSources: () => apiClient.getVideoSources(), getVideos: (params?: VideosRequest) => apiClient.getVideos(params), getVideo: (id: number) => apiClient.getVideo(id), - resetVideo: (id: number, request: ResetRequest) => apiClient.resetVideo(id, request), - resetAllVideos: (request: ResetRequest) => apiClient.resetAllVideos(request), + resetVideoStatus: (id: number, request: ResetVideoStatusRequest) => + apiClient.resetVideoStatus(id, request), + resetFilteredVideoStatus: (request: ResetFilteredVideoStatusRequest) => + apiClient.resetFilteredVideoStatus(request), updateVideoStatus: (id: number, request: UpdateVideoStatusRequest) => apiClient.updateVideoStatus(id, request), + updateFilteredVideoStatus: (request: UpdateFilteredVideoStatusRequest) => + apiClient.updateFilteredVideoStatus(request), getCreatedFavorites: () => apiClient.getCreatedFavorites(), getFollowedCollections: (pageNum?: number, pageSize?: number) => apiClient.getFollowedCollections(pageNum, pageSize), diff --git a/web/src/lib/components/filtered-status-editor.svelte b/web/src/lib/components/filtered-status-editor.svelte new file mode 100644 index 0000000..18aef8a --- /dev/null +++ b/web/src/lib/components/filtered-status-editor.svelte @@ -0,0 +1,322 @@ + + + + + + {hasFilters ? '编辑筛选视频' : '编辑全部视频'} + 批量编辑视频和分页的下载状态。可将任意子任务状态修改为“未开始”或“已完成”。
+ {#if hasFilters} + 正在编辑符合以下筛选条件的视频的下载状态: +
+ {#each filterDescriptionParts as part, index (index)} +
{part}
+ {/each} +
+ {:else} + 正在编辑全部视频的下载状态。
+ {/if} +
+ ⚠️ 仅当分页下载状态不是"已完成"时,程序才会尝试执行分页下载。 +
+
+
+ +
+
+ +
+

视频状态

+
+
+ {#each videoTaskNames as taskName, index (index)} + {@const statusInfo = getStatusInfo(videoStatuses[index])} + {@const isModified = videoStatuses[index] !== null} +
+
+
+
+ {taskName} + {#if isModified} + +
+ {/if} +
+
+
+ {statusInfo.label} +
+
+
+
+ {#if isModified} + + {/if} + + +
+
+ {/each} +
+
+
+ + +
+

分页状态

+
+
+ {#each pageTaskNames as taskName, index (index)} + {@const statusInfo = getStatusInfo(pageStatuses[index])} + {@const isModified = pageStatuses[index] !== null} +
+
+
+
+ {taskName} + {#if isModified} + +
+ {/if} +
+
+
+ {statusInfo.label} +
+
+
+
+ {#if isModified} + + {/if} + + +
+
+ {/each} +
+
+
+
+
+ + + + + +
+
diff --git a/web/src/lib/stores/filter.ts b/web/src/lib/stores/filter.ts index 0867805..9d656f9 100644 --- a/web/src/lib/stores/filter.ts +++ b/web/src/lib/stores/filter.ts @@ -31,6 +31,41 @@ export const ToQuery = (state: AppState): string => { return queryString ? `videos?${queryString}` : 'videos'; }; +// 将 AppState 转换为请求体中的筛选参数 +export const ToFilterParams = ( + state: AppState +): { + query?: string; + collection?: number; + favorite?: number; + submission?: number; + watch_later?: number; +} => { + const params: { + query?: string; + collection?: number; + favorite?: number; + submission?: number; + watch_later?: number; + } = {}; + + if (state.query.trim()) { + params.query = state.query; + } + + if (state.videoSource && state.videoSource.type && state.videoSource.id) { + const { type, id } = state.videoSource; + params[type as 'collection' | 'favorite' | 'submission' | 'watch_later'] = parseInt(id); + } + + return params; +}; + +// 检查是否有活动的筛选条件 +export const hasActiveFilters = (state: AppState): boolean => { + return !!(state.query.trim() || state.videoSource); +}; + export const setQuery = (query: string) => { appStateStore.update((state) => ({ ...state, diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 0ee05cd..2122a1e 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1,11 +1,8 @@ -// API 响应包装器 - export interface ApiResponse { status_code: number; data: T; } -// 请求参数类型 export interface VideosRequest { collection?: number; favorite?: number; @@ -16,13 +13,11 @@ export interface VideosRequest { page_size?: number; } -// 视频来源类型 export interface VideoSource { id: number; name: string; } -// 视频来源响应类型 export interface VideoSourcesResponse { collection: VideoSource[]; favorite: VideoSource[]; @@ -30,7 +25,6 @@ export interface VideoSourcesResponse { watch_later: VideoSource[]; } -// 视频信息类型 export interface VideoInfo { id: number; bvid: string; @@ -40,13 +34,11 @@ export interface VideoInfo { download_status: [number, number, number, number, number]; } -// 视频列表响应类型 export interface VideosResponse { videos: VideoInfo[]; total_count: number; } -// 分页信息类型 export interface PageInfo { id: number; pid: number; @@ -54,59 +46,75 @@ export interface PageInfo { download_status: [number, number, number, number, number]; } -// 单个视频响应类型 export interface VideoResponse { video: VideoInfo; pages: PageInfo[]; } -// 重置视频响应类型 export interface ResetVideoResponse { resetted: boolean; video: VideoInfo; pages: PageInfo[]; } -// 重置所有视频响应类型 -export interface ResetAllVideosResponse { +export interface ResetFilteredVideosResponse { resetted: boolean; resetted_videos_count: number; resetted_pages_count: number; } -// API 错误类型 -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 UpdateVideoStatusRequest { - video_updates?: StatusUpdate[]; - page_updates?: PageStatusUpdate[]; -} - -// 重置视频状态响应类型 export interface UpdateVideoStatusResponse { success: boolean; video: VideoInfo; pages: PageInfo[]; } -// 重置请求类型 -export interface ResetRequest { +export interface UpdateFilteredVideoStatusResponse { + success: boolean; + updated_videos_count: number; + updated_pages_count: number; +} + +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 UpdateVideoStatusRequest { + video_updates?: StatusUpdate[]; + page_updates?: PageStatusUpdate[]; +} + +export interface UpdateFilteredVideoStatusRequest { + collection?: number; + favorite?: number; + submission?: number; + watch_later?: number; + query?: string; + video_updates?: StatusUpdate[]; + page_updates?: StatusUpdate[]; +} + +export interface ResetVideoStatusRequest { + force: boolean; +} + +export interface ResetFilteredVideoStatusRequest { + collection?: number; + favorite?: number; + submission?: number; + watch_later?: number; + query?: string; force: boolean; } @@ -170,7 +178,6 @@ export interface InsertSubmissionRequest { path: string; } -// Rule 相关类型 export interface Condition { operator: string; value: T | T[]; @@ -184,7 +191,6 @@ export interface RuleTarget { export type AndGroup = RuleTarget[]; export type Rule = AndGroup[]; -// 视频源详细信息类型 export interface VideoSourceDetail { id: number; name: string; @@ -195,7 +201,6 @@ export interface VideoSourceDetail { enabled: boolean; } -// 视频源详细信息响应类型 export interface VideoSourcesDetailsResponse { collections: VideoSourceDetail[]; favorites: VideoSourceDetail[]; @@ -203,7 +208,6 @@ export interface VideoSourcesDetailsResponse { watch_later: VideoSourceDetail[]; } -// 更新视频源请求类型 export interface UpdateVideoSourceRequest { path: string; enabled: boolean; @@ -211,7 +215,6 @@ export interface UpdateVideoSourceRequest { useDynamicApi?: boolean | null; } -// 配置相关类型 export interface Credential { sessdata: string; bili_jct: string; @@ -273,7 +276,6 @@ export interface ConcurrentLimit { download: ConcurrentDownloadLimit; } -// Notifier 相关类型 export interface TelegramNotifier { type: 'telegram'; bot_token: string; @@ -312,13 +314,11 @@ export interface Config { version: number; } -// 日期计数对类型 export interface DayCountPair { day: string; cnt: number; } -// 仪表盘响应类型 export interface DashBoardResponse { enabled_favorites: number; enabled_collections: number; @@ -327,7 +327,6 @@ export interface DashBoardResponse { videos_by_day: DayCountPair[]; } -// 系统信息响应类型 export interface SysInfo { total_memory: number; used_memory: number; diff --git a/web/src/routes/video/[id]/+page.svelte b/web/src/routes/video/[id]/+page.svelte index a9e5c1a..a5ecf57 100644 --- a/web/src/routes/video/[id]/+page.svelte +++ b/web/src/routes/video/[id]/+page.svelte @@ -166,7 +166,7 @@ bind:resetting onReset={async (forceReset: boolean) => { try { - const result = await api.resetVideo(videoData!.video.id, { force: forceReset }); + const result = await api.resetVideoStatus(videoData!.video.id, { force: forceReset }); const data = result.data; if (data.resetted) { videoData = { diff --git a/web/src/routes/videos/+page.svelte b/web/src/routes/videos/+page.svelte index b5bc4df..5a45173 100644 --- a/web/src/routes/videos/+page.svelte +++ b/web/src/routes/videos/+page.svelte @@ -3,11 +3,18 @@ import Pagination from '$lib/components/pagination.svelte'; import { Button } from '$lib/components/ui/button/index.js'; import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js'; + import EditIcon from '@lucide/svelte/icons/edit'; import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'; import api from '$lib/api'; import { Checkbox } from '$lib/components/ui/checkbox/index.js'; import { Label } from '$lib/components/ui/label/index.js'; - import type { VideosResponse, VideoSourcesResponse, ApiError, VideoSource } from '$lib/types'; + import type { + VideosResponse, + VideoSourcesResponse, + ApiError, + VideoSource, + UpdateFilteredVideoStatusRequest + } from '$lib/types'; import { onMount } from 'svelte'; import { page } from '$app/stores'; import { goto } from '$app/navigation'; @@ -19,11 +26,14 @@ setAll, setCurrentPage, setQuery, - ToQuery + ToQuery, + ToFilterParams, + hasActiveFilters } from '$lib/stores/filter'; import { toast } from 'svelte-sonner'; import DropdownFilter, { type Filter } from '$lib/components/dropdown-filter.svelte'; import SearchBar from '$lib/components/search-bar.svelte'; + import FilteredStatusEditor from '$lib/components/filtered-status-editor.svelte'; const pageSize = 20; @@ -37,6 +47,9 @@ let forceReset = false; + let updateAllDialogOpen = false; + let updatingAll = false; + let videoSources: VideoSourcesResponse | null = null; let filters: Record | null = null; @@ -97,7 +110,7 @@ async function handleResetVideo(id: number, forceReset: boolean) { try { - const result = await api.resetVideo(id, { force: forceReset }); + const result = await api.resetVideoStatus(id, { force: forceReset }); const data = result.data; if (data.resetted) { toast.success('重置成功', { @@ -121,7 +134,12 @@ async function handleResetAllVideos() { resettingAll = true; try { - const result = await api.resetAllVideos({ force: forceReset }); + // 获取筛选参数 + const filterParams = ToFilterParams($appStateStore); + const result = await api.resetFilteredVideoStatus({ + ...filterParams, + force: forceReset + }); const data = result.data; if (data.resetted) { toast.success('重置成功', { @@ -143,6 +161,59 @@ } } + async function handleUpdateAllVideos(request: UpdateFilteredVideoStatusRequest) { + updatingAll = true; + try { + // 获取筛选参数并合并 + const filterParams = ToFilterParams($appStateStore); + const fullRequest = { + ...filterParams, + ...request + }; + const result = await api.updateFilteredVideoStatus(fullRequest); + const data = result.data; + if (data.success) { + toast.success('更新成功', { + description: `已更新 ${data.updated_videos_count} 个视频和 ${data.updated_pages_count} 个分页` + }); + const { query, currentPage, videoSource } = $appStateStore; + await loadVideos(query, currentPage, videoSource); + } else { + toast.info('没有视频被更新'); + } + } catch (error) { + console.error('更新失败:', error); + toast.error('更新失败', { + description: (error as ApiError).message + }); + } finally { + updatingAll = false; + updateAllDialogOpen = false; + } + } + + // 获取筛选条件的显示数组 + function getFilterDescriptionParts(): string[] { + const state = $appStateStore; + const parts: string[] = []; + if (state.query.trim()) { + parts.push(`搜索词:"${state.query}"`); + } + if (state.videoSource && videoSources) { + const sourceType = state.videoSource.type; + const sourceId = parseInt(state.videoSource.id); + const sourceConfig = Object.values(VIDEO_SOURCES).find((s) => s.type === sourceType); + if (sourceConfig) { + const sourceList = videoSources[sourceType as keyof VideoSourcesResponse] as VideoSource[]; + const source = sourceList.find((s) => s.id === sourceId); + if (source) { + parts.push(`${sourceConfig.title}:${source.name}`); + } + } + } + return parts; + } + $: if ($page.url.search !== lastSearch) { lastSearch = $page.url.search; handleSearchParamsChange($page.url.searchParams); @@ -177,6 +248,8 @@ }); $: totalPages = videosData ? Math.ceil(videosData.total_count / pageSize) : 0; + $: hasFilters = hasActiveFilters($appStateStore); + $: filterDescriptionParts = videoSources && $appStateStore && getFilterDescriptionParts(); @@ -221,6 +294,16 @@
+
@@ -271,16 +354,25 @@ - 重置全部视频 + {hasFilters ? '重置筛选视频' : '重置全部视频'} - 确定要重置全部视频的下载状态吗?
+ {#if hasFilters} + 确定要重置符合以下筛选条件的视频的下载状态吗?
+
+ {#each filterDescriptionParts as part, index (index)} +
{part}
+ {/each} +
+ {:else} + 确定要重置全部视频的下载状态吗?
+ {/if} 此操作会将所有的失败状态重置为未开始,无法撤销
-
+
@@ -317,3 +409,11 @@ + +