diff --git a/crates/bili_sync/src/api/request.rs b/crates/bili_sync/src/api/request.rs index a4ec29e..dc52a67 100644 --- a/crates/bili_sync/src/api/request.rs +++ b/crates/bili_sync/src/api/request.rs @@ -11,6 +11,8 @@ pub struct VideosRequest { pub submission: Option, pub watch_later: Option, pub query: Option, + #[serde(default)] + pub failed_only: bool, pub page: Option, pub page_size: Option, } @@ -29,6 +31,8 @@ pub struct ResetFilteredVideoStatusRequest { pub watch_later: Option, pub query: Option, #[serde(default)] + pub failed_only: bool, + #[serde(default)] pub force: bool, } @@ -65,6 +69,8 @@ pub struct UpdateFilteredVideoStatusRequest { pub watch_later: Option, pub query: Option, #[serde(default)] + pub failed_only: bool, + #[serde(default)] #[validate(nested)] pub video_updates: Vec, #[serde(default)] diff --git a/crates/bili_sync/src/api/routes/videos/mod.rs b/crates/bili_sync/src/api/routes/videos/mod.rs index d0f3ce8..7c86d9b 100644 --- a/crates/bili_sync/src/api/routes/videos/mod.rs +++ b/crates/bili_sync/src/api/routes/videos/mod.rs @@ -62,6 +62,9 @@ pub async fn get_videos( .or(video::Column::Bvid.contains(query_word)), ); } + if params.failed_only { + query = query.filter(VideoStatus::query_builder().any_failed()) + } let total_count = query.clone().count(&db).await?; let (page, page_size) = if let (Some(page), Some(page_size)) = (params.page, params.page_size) { (page, page_size) @@ -218,6 +221,9 @@ pub async fn reset_filtered_video_status( .or(video::Column::Bvid.contains(query_word)), ); } + if request.failed_only { + query = query.filter(VideoStatus::query_builder().any_failed()); + } 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))) @@ -351,6 +357,9 @@ pub async fn update_filtered_video_status( .or(video::Column::Bvid.contains(query_word)), ); } + if request.failed_only { + query = query.filter(VideoStatus::query_builder().any_failed()) + } 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))) diff --git a/crates/bili_sync/src/utils/status.rs b/crates/bili_sync/src/utils/status.rs index 597f132..e17519c 100644 --- a/crates/bili_sync/src/utils/status.rs +++ b/crates/bili_sync/src/utils/status.rs @@ -1,3 +1,10 @@ +use std::marker::PhantomData; + +use bili_sync_entity::{page, video}; +use bili_sync_migration::ExprTrait; +use sea_orm::sea_query::Expr; +use sea_orm::{ColumnTrait, Condition}; + use crate::error::ExecutionStatus; pub static STATUS_NOT_STARTED: u32 = 0b000; @@ -11,10 +18,17 @@ pub static STATUS_COMPLETED: u32 = 1 << 31; /// 如果子任务执行成功,将状态设置为 0b111,该值定义为 STATUS_OK。 /// 子任务达到最大失败次数或者执行成功时,认为该子任务已经完成。 /// 当所有子任务都已经完成时,为最高位打上标记 1,表示整个下载任务已经完成。 -#[derive(Clone, Copy, Default)] -pub struct Status(u32); +#[derive(Clone, Copy)] +pub struct Status(u32, PhantomData); -impl Status { +impl Default for Status { + fn default() -> Self { + Self(0, PhantomData) + } +} + +impl Status { + pub(crate) const LEN: usize = N; // 获取最高位的完成标记 pub fn get_completed(&self) -> bool { self.0 >> 31 == 1 @@ -136,20 +150,20 @@ impl Status { } } -impl From for Status { +impl From for Status { fn from(status: u32) -> Self { - Status(status) + Status(status, PhantomData) } } -impl From> for u32 { - fn from(status: Status) -> Self { +impl From> for u32 { + fn from(status: Status) -> Self { status.0 } } -impl From> for [u32; N] { - fn from(status: Status) -> Self { +impl From> for [u32; N] { + fn from(status: Status) -> Self { let mut result = [0; N]; for (i, item) in result.iter_mut().enumerate() { *item = status.get_status(i); @@ -158,9 +172,9 @@ impl From> for [u32; N] { } } -impl From<[u32; N]> for Status { +impl From<[u32; N]> for Status { fn from(status: [u32; N]) -> Self { - let mut result = Status::::default(); + let mut result = Self::default(); for (i, item) in status.iter().enumerate() { assert!(*item < 0b1000, "status should be less than 0b1000"); result.set_status(i, *item); @@ -173,10 +187,45 @@ impl From<[u32; N]> for Status { } /// 包含五个子任务,从前到后依次是:视频封面、视频信息、Up 主头像、Up 主信息、分页下载 -pub type VideoStatus = Status<5>; +pub type VideoStatus = Status<5, video::Column>; + +impl VideoStatus { + pub fn query_builder() -> StatusQueryBuilder<{ Self::LEN }, video::Column> { + StatusQueryBuilder::new(video::Column::DownloadStatus) + } +} /// 包含五个子任务,从前到后分别是:视频封面、视频内容、视频信息、视频弹幕、视频字幕 -pub type PageStatus = Status<5>; +pub type PageStatus = Status<5, page::Column>; + +impl PageStatus { + pub fn query_builder() -> StatusQueryBuilder<{ Self::LEN }, page::Column> { + StatusQueryBuilder::new(page::Column::DownloadStatus) + } +} + +pub struct StatusQueryBuilder { + column: C, +} + +impl StatusQueryBuilder { + fn new(column: C) -> Self { + Self { column } + } + + pub fn any_failed(&self) -> Condition { + let mut condition = Condition::any(); + for offset in 0..N as i32 { + condition = condition.add( + Expr::col(self.column) + .right_shift(offset * 3) + .bit_and(7) + .is_not_in([0, 7]), + ) + } + condition + } +} #[cfg(test)] mod tests { @@ -186,7 +235,7 @@ mod tests { #[test] fn test_status_update() { - let mut status = Status::<3>::default(); + let mut status = Status::<3, video::Column>::default(); assert_eq!(status.should_run(), [true, true, true]); for _ in 0..3 { status.update_status(&[ @@ -217,7 +266,7 @@ mod tests { fn test_status_convert() { let testcases = [[0, 0, 1], [1, 2, 3], [3, 1, 2], [3, 0, 7]]; for testcase in testcases.iter() { - let status = Status::<3>::from(testcase.clone()); + let status = Status::<3, video::Column>::from(testcase.clone()); assert_eq!(<[u32; 3]>::from(status), *testcase); } } @@ -226,7 +275,7 @@ mod tests { fn test_status_convert_and_update() { let testcases = [([0, 0, 1], [1, 7, 7]), ([3, 4, 3], [4, 4, 7]), ([3, 1, 7], [4, 7, 7])]; for (before, after) in testcases.iter() { - let mut status = Status::<3>::from(before.clone()); + let mut status = Status::<3, video::Column>::from(before.clone()); status.update_status(&[ ExecutionStatus::Failed(anyhow!("")), ExecutionStatus::Succeeded, @@ -239,7 +288,7 @@ mod tests { #[test] fn test_status_reset_failed() { // 重置一个出现部分失败但还有重试次数的任务,将所有的失败状态重置为 0 - let mut status = Status::<3>::from([3, 4, 7]); + let mut status = Status::<3, video::Column>::from([3, 4, 7]); assert!(!status.get_completed()); assert!(status.reset_failed()); assert!(!status.get_completed()); @@ -253,12 +302,12 @@ mod tests { assert!(status.force_reset_failed()); assert!(!status.get_completed()); // 重置一个已经成功的任务,没有改变状态,也不会修改标记位 - let mut status = Status::<3>::from([7, 7, 7]); + let mut status = Status::<3, video::Column>::from([7, 7, 7]); assert!(status.get_completed()); assert!(!status.reset_failed()); assert!(status.get_completed()); // 重置一个全部失败的任务,修改状态并且修改标记位 - let mut status = Status::<3>::from([4, 4, 4]); + let mut status = Status::<3, video::Column>::from([4, 4, 4]); assert!(status.get_completed()); assert!(status.reset_failed()); assert!(!status.get_completed()); @@ -268,13 +317,13 @@ mod tests { #[test] fn test_status_set() { // 设置子状态,从 completed 到 uncompleted - let mut status = Status::<5>::from([7, 7, 7, 7, 7]); + let mut status = Status::<5, video::Column>::from([7, 7, 7, 7, 7]); assert!(status.get_completed()); status.set(4, 0); assert!(!status.get_completed()); assert_eq!(<[u32; 5]>::from(status), [7, 7, 7, 7, 0]); // 设置子状态,从 uncompleted 到 completed - let mut status = Status::<5>::from([4, 7, 7, 7, 0]); + let mut status = Status::<5, video::Column>::from([4, 7, 7, 7, 0]); assert!(!status.get_completed()); status.set(4, 7); assert!(status.get_completed()); diff --git a/web/src/lib/stores/filter.ts b/web/src/lib/stores/filter.ts index 9d656f9..09ff421 100644 --- a/web/src/lib/stores/filter.ts +++ b/web/src/lib/stores/filter.ts @@ -7,19 +7,21 @@ export interface AppState { type: string; id: string; } | null; + failedOnly: boolean; } export const appStateStore = writable({ query: '', currentPage: 0, - videoSource: null + videoSource: null, + failedOnly: false }); export const ToQuery = (state: AppState): string => { - const { query, videoSource } = state; + const { query, videoSource, currentPage, failedOnly } = state; const params = new URLSearchParams(); - if (state.currentPage > 0) { - params.set('page', String(state.currentPage)); + if (currentPage > 0) { + params.set('page', String(currentPage)); } if (query.trim()) { params.set('query', query); @@ -27,6 +29,9 @@ export const ToQuery = (state: AppState): string => { if (videoSource && videoSource.type && videoSource.id) { params.set(videoSource.type, videoSource.id); } + if (failedOnly) { + params.set('failed_only', 'true'); + } const queryString = params.toString(); return queryString ? `videos?${queryString}` : 'videos'; }; @@ -40,6 +45,7 @@ export const ToFilterParams = ( favorite?: number; submission?: number; watch_later?: number; + failed_only?: boolean; } => { const params: { query?: string; @@ -47,6 +53,7 @@ export const ToFilterParams = ( favorite?: number; submission?: number; watch_later?: number; + failed_only?: boolean; } = {}; if (state.query.trim()) { @@ -57,13 +64,15 @@ export const ToFilterParams = ( const { type, id } = state.videoSource; params[type as 'collection' | 'favorite' | 'submission' | 'watch_later'] = parseInt(id); } - + if (state.failedOnly) { + params.failed_only = true; + } return params; }; // 检查是否有活动的筛选条件 export const hasActiveFilters = (state: AppState): boolean => { - return !!(state.query.trim() || state.videoSource); + return !!(state.query.trim() || state.videoSource || state.failedOnly); }; export const setQuery = (query: string) => { @@ -94,6 +103,13 @@ export const setCurrentPage = (page: number) => { })); }; +export const setFailedOnly = (failedOnly: boolean) => { + appStateStore.update((state) => ({ + ...state, + failedOnly + })); +}; + export const resetCurrentPage = () => { appStateStore.update((state) => ({ ...state, @@ -104,12 +120,14 @@ export const resetCurrentPage = () => { export const setAll = ( query: string, currentPage: number, - videoSource: { type: string; id: string } | null + videoSource: { type: string; id: string } | null, + failedOnly: boolean ) => { appStateStore.set({ query, currentPage, - videoSource + videoSource, + failedOnly }); }; @@ -117,6 +135,7 @@ export const clearAll = () => { appStateStore.set({ query: '', currentPage: 0, - videoSource: null + videoSource: null, + failedOnly: false }); }; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 3130a35..e46a229 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -9,6 +9,7 @@ export interface VideosRequest { submission?: number; watch_later?: number; query?: string; + failed_only?: boolean; page?: number; page_size?: number; } @@ -106,6 +107,8 @@ export interface UpdateFilteredVideoStatusRequest { submission?: number; watch_later?: number; query?: string; + // 仅更新下载失败 + failed_only?: boolean; video_updates?: StatusUpdate[]; page_updates?: StatusUpdate[]; } @@ -120,6 +123,8 @@ export interface ResetFilteredVideoStatusRequest { submission?: number; watch_later?: number; query?: string; + // 仅重置下载失败 + failed_only?: boolean; force: boolean; } diff --git a/web/src/routes/videos/+page.svelte b/web/src/routes/videos/+page.svelte index 6721cbc..9ea0984 100644 --- a/web/src/routes/videos/+page.svelte +++ b/web/src/routes/videos/+page.svelte @@ -26,6 +26,7 @@ setAll, setCurrentPage, setQuery, + setFailedOnly, ToQuery, ToFilterParams, hasActiveFilters @@ -61,9 +62,13 @@ videoSource = { type: source.type, id: value }; } } + // 支持从 URL 里还原失败筛选 + const failedParam = searchParams.get('failed_only'); + const failedOnly = failedParam === 'true' || failedParam === '1'; return { query: searchParams.get('query') || '', videoSource, + failedOnly, pageNum: parseInt(searchParams.get('page') || '0') }; } @@ -71,11 +76,12 @@ async function loadVideos( query: string, pageNum: number = 0, - filter?: { type: string; id: string } | null + filter?: { type: string; id: string } | null, + failedOnly: boolean = false ) { loading = true; try { - const params: Record = { + const params: Record = { page: pageNum, page_size: pageSize }; @@ -85,6 +91,7 @@ if (filter) { params[filter.type] = parseInt(filter.id); } + params.failed_only = failedOnly; const result = await api.getVideos(params); videosData = result.data; } catch (error) { @@ -103,9 +110,9 @@ } async function handleSearchParamsChange(searchParams: URLSearchParams) { - const { query, videoSource, pageNum } = getApiParams(searchParams); - setAll(query, pageNum, videoSource); - loadVideos(query, pageNum, videoSource); + const { query, videoSource, pageNum, failedOnly } = getApiParams(searchParams); + setAll(query, pageNum, videoSource, failedOnly); + loadVideos(query, pageNum, videoSource, failedOnly); } async function handleResetVideo(id: number, forceReset: boolean) { @@ -116,8 +123,8 @@ toast.success('重置成功', { description: `视频「${data.video.name}」已重置` }); - const { query, currentPage, videoSource } = $appStateStore; - await loadVideos(query, currentPage, videoSource); + const { query, currentPage, videoSource, failedOnly } = $appStateStore; + await loadVideos(query, currentPage, videoSource, failedOnly); } else { toast.info('重置无效', { description: `视频「${data.video.name}」没有失败的状态,无需重置` @@ -144,8 +151,8 @@ description: `视频「${data.video.name}」已清空重置` }); } - const { query, currentPage, videoSource } = $appStateStore; - await loadVideos(query, currentPage, videoSource); + const { query, currentPage, videoSource, failedOnly } = $appStateStore; + await loadVideos(query, currentPage, videoSource, failedOnly); } catch (error) { console.error('清空重置失败:', error); toast.error('清空重置失败', { @@ -168,8 +175,8 @@ toast.success('重置成功', { description: `已重置 ${data.resetted_videos_count} 个视频和 ${data.resetted_pages_count} 个分页` }); - const { query, currentPage, videoSource } = $appStateStore; - await loadVideos(query, currentPage, videoSource); + const { query, currentPage, videoSource, failedOnly } = $appStateStore; + await loadVideos(query, currentPage, videoSource, failedOnly); } else { toast.info('没有需要重置的视频'); } @@ -199,8 +206,8 @@ toast.success('更新成功', { description: `已更新 ${data.updated_videos_count} 个视频和 ${data.updated_pages_count} 个分页` }); - const { query, currentPage, videoSource } = $appStateStore; - await loadVideos(query, currentPage, videoSource); + const { query, currentPage, videoSource, failedOnly } = $appStateStore; + await loadVideos(query, currentPage, videoSource, failedOnly); } else { toast.info('没有视频被更新'); } @@ -234,6 +241,7 @@ } } } + parts.push(`仅失败视频:${state.failedOnly}`); return parts; } @@ -291,15 +299,29 @@ >
筛选: +
+ { + setFailedOnly(value); + resetCurrentPage(); + goto(`/${ToQuery($appStateStore)}`); + }} + /> + +
{ - setAll('', 0, { type, id }); + setAll('', 0, { type, id }, $appStateStore.failedOnly); goto(`/${ToQuery($appStateStore)}`); }} onRemove={() => { - setAll('', 0, null); + setAll('', 0, null, $appStateStore.failedOnly); goto(`/${ToQuery($appStateStore)}`); }} />