feat: 支持根据筛选条件批量编辑视频的下载状态 (#558)

This commit is contained in:
ᴀᴍᴛᴏᴀᴇʀ
2025-12-06 19:47:16 +08:00
committed by GitHub
parent 930660045f
commit f1703096fd
10 changed files with 761 additions and 114 deletions

View File

@@ -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<T>(
txn: &DatabaseTransaction,
videos: &[impl Borrow<VideoInfo>],
videos: &[impl Borrow<T>],
batch_size: Option<usize>,
) -> 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::<Vec<_>>();
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<T>(
txn: &DatabaseTransaction,
pages: &[impl Borrow<PageInfo>],
pages: &[impl Borrow<T>],
batch_size: Option<usize>,
) -> 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::<Vec<_>>();
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<Item = (i32, u32)>,
) -> 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::<Vec<_>>()
.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<Item = (i32, u32)>,
) -> Result<(), sea_orm::DbErr> {
let values = pages
.map(|p| format!("({}, {})", p.0, p.1))
.collect::<Vec<_>>()
.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::<Vec<_>>()
.join(", ")
values
);
txn.execute_unprepared(&sql).await?;
Ok(())

View File

@@ -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<i32>,
pub favorite: Option<i32>,
pub submission: Option<i32>,
pub watch_later: Option<i32>,
pub query: Option<String>,
#[serde(default)]
pub force: bool,
}
@@ -46,6 +57,21 @@ pub struct UpdateVideoStatusRequest {
pub page_updates: Vec<PageStatusUpdate>,
}
#[derive(Deserialize, Validate)]
pub struct UpdateFilteredVideoStatusRequest {
pub collection: Option<i32>,
pub favorite: Option<i32>,
pub submission: Option<i32>,
pub watch_later: Option<i32>,
pub query: Option<String>,
#[serde(default)]
#[validate(nested)]
pub video_updates: Vec<StatusUpdate>,
#[serde(default)]
#[validate(nested)]
pub page_updates: Vec<StatusUpdate>,
}
#[derive(Deserialize)]
pub struct FollowedCollectionsRequest {
pub page_num: Option<i32>,

View File

@@ -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<PageInfo>,
}
#[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<S>(status: &u32, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,

View File

@@ -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<i32>,
Extension(db): Extension<DatabaseConnection>,
Json(request): Json<ResetRequest>,
Json(request): Json<ResetVideoStatusRequest>,
) -> Result<ApiResponse<ResetVideoResponse>, ApiError> {
let (video_info, pages_info) = tokio::try_join!(
video::Entity::find_by_id(id).into_partial_model::<VideoInfo>().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::<VideoInfo>(&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<DatabaseConnection>,
Json(request): Json<ResetRequest>,
) -> Result<ApiResponse<ResetAllVideosResponse>, ApiError> {
// 先查询所有视频和页面数据
let (all_videos, all_pages) = tokio::try_join!(
video::Entity::find().into_partial_model::<VideoInfo>().all(&db),
page::Entity::find().into_partial_model::<PageInfo>().all(&db)
)?;
Json(request): Json<ResetFilteredVideoStatusRequest>,
) -> Result<ApiResponse<ResetFilteredVideosResponse>, 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::<SimpleVideoInfo>().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::<SimplePageInfo>()
.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::<VideoInfo>(&txn, &[&video_info], None).await?;
}
if has_page_updates {
update_page_download_status(&txn, &updated_pages_info, None).await?;
update_page_download_status::<PageInfo>(&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<DatabaseConnection>,
ValidatedJson(request): ValidatedJson<UpdateFilteredVideoStatusRequest>,
) -> Result<ApiResponse<UpdateFilteredVideoStatusResponse>, 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::<SimpleVideoInfo>().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::<SimplePageInfo>()
.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(),
}))
}

View File

@@ -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<VideoResponse>(`/videos/${id}`);
}
async resetVideo(id: number, request: ResetRequest): Promise<ApiResponse<ResetVideoResponse>> {
return this.post<ResetVideoResponse>(`/videos/${id}/reset`, request);
async resetVideoStatus(
id: number,
request: ResetVideoStatusRequest
): Promise<ApiResponse<ResetVideoResponse>> {
return this.post<ResetVideoResponse>(`/videos/${id}/reset-status`, request);
}
async resetAllVideos(request: ResetRequest): Promise<ApiResponse<ResetAllVideosResponse>> {
return this.post<ResetAllVideosResponse>('/videos/reset-all', request);
async resetFilteredVideoStatus(
request: ResetFilteredVideoStatusRequest
): Promise<ApiResponse<ResetFilteredVideosResponse>> {
return this.post<ResetFilteredVideosResponse>('/videos/reset-status', request);
}
async updateVideoStatus(
@@ -167,6 +176,12 @@ class ApiClient {
return this.post<UpdateVideoStatusResponse>(`/videos/${id}/update-status`, request);
}
async updateFilteredVideoStatus(
request: UpdateFilteredVideoStatusRequest
): Promise<ApiResponse<UpdateFilteredVideoStatusResponse>> {
return this.post<UpdateFilteredVideoStatusResponse>('/videos/update-status', request);
}
async getCreatedFavorites(): Promise<ApiResponse<FavoritesResponse>> {
return this.get<FavoritesResponse>('/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),

View File

@@ -0,0 +1,322 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle
} from '$lib/components/ui/sheet/index.js';
import type { StatusUpdate, UpdateFilteredVideoStatusRequest } from '$lib/types';
import { toast } from 'svelte-sonner';
export let open = false;
export let hasFilters = false;
export let loading = false;
export let filterDescriptionParts: string[] = [];
export let onsubmit: (request: UpdateFilteredVideoStatusRequest) => void;
// 视频任务名称(与后端 VideoStatus 对应)
const videoTaskNames = ['视频封面', '视频信息', 'UP 主头像', 'UP 主信息', '分页下载'];
// 分页任务名称(与后端 PageStatus 对应)
const pageTaskNames = ['视频封面', '视频内容', '视频信息', '视频弹幕', '视频字幕'];
// 状态选项null 表示未选择0 表示未开始7 表示已完成
type StatusValue = null | 0 | 7;
// 视频任务状态,默认都是 null未选择
let videoStatuses: StatusValue[] = Array(5).fill(null);
// 分页任务状态,默认都是 null未选择
let pageStatuses: StatusValue[] = Array(5).fill(null);
function setVideoStatus(taskIndex: number, value: StatusValue) {
videoStatuses[taskIndex] = value;
videoStatuses = [...videoStatuses];
}
function setPageStatus(taskIndex: number, value: StatusValue) {
pageStatuses[taskIndex] = value;
pageStatuses = [...pageStatuses];
}
function resetVideoStatus(taskIndex: number) {
videoStatuses[taskIndex] = null;
videoStatuses = [...videoStatuses];
}
function resetPageStatus(taskIndex: number) {
pageStatuses[taskIndex] = null;
pageStatuses = [...pageStatuses];
}
function resetAllStatuses() {
videoStatuses = Array(5).fill(null);
pageStatuses = Array(5).fill(null);
}
function hasAnyChanges(): boolean {
return (
videoStatuses.some((status) => status !== null) ||
pageStatuses.some((status) => status !== null)
);
}
function buildRequest(): UpdateFilteredVideoStatusRequest {
const request: UpdateFilteredVideoStatusRequest = {};
// 添加视频更新
const videoUpdates: StatusUpdate[] = [];
videoStatuses.forEach((status, index) => {
if (status !== null) {
videoUpdates.push({
status_index: index,
status_value: status
});
}
});
if (videoUpdates.length > 0) {
request.video_updates = videoUpdates;
}
// 添加分页更新
const pageUpdates: StatusUpdate[] = [];
pageStatuses.forEach((status, index) => {
if (status !== null) {
pageUpdates.push({
status_index: index,
status_value: status
});
}
});
if (pageUpdates.length > 0) {
request.page_updates = pageUpdates;
}
return request;
}
function handleSubmit() {
if (!hasAnyChanges()) {
toast.info('请至少选择一个状态进行修改');
return;
}
const request = buildRequest();
onsubmit(request);
}
// 当 Sheet 关闭时重置状态
$: if (!open) {
resetAllStatuses();
}
function getStatusInfo(status: StatusValue) {
if (status === 0) {
return { label: '未开始', class: 'text-yellow-600', dotClass: 'bg-yellow-600' };
}
if (status === 7) {
return { label: '已完成', class: 'text-emerald-600', dotClass: 'bg-emerald-600' };
}
return { label: '无修改', class: 'text-muted-foreground', dotClass: 'bg-muted-foreground' };
}
</script>
<Sheet bind:open>
<SheetContent side="right" class="flex w-full flex-col sm:max-w-3xl">
<SheetHeader class="px-6 pb-2">
<SheetTitle class="text-lg">{hasFilters ? '编辑筛选视频' : '编辑全部视频'}</SheetTitle>
<SheetDescription class="text-muted-foreground space-y-2 text-sm"
>批量编辑视频和分页的下载状态。可将任意子任务状态修改为“未开始”或“已完成”。<br />
{#if hasFilters}
正在编辑<strong>符合以下筛选条件</strong>的视频的下载状态:
<div class="bg-muted my-2 rounded-md p-2 text-left">
{#each filterDescriptionParts as part, index (index)}
<div><strong>{part}</strong></div>
{/each}
</div>
{:else}
正在编辑<strong>全部视频</strong>的下载状态。 <br />
{/if}
<div class="leading-relaxed text-orange-600">
⚠️ 仅当分页下载状态不是"已完成"时,程序才会尝试执行分页下载。
</div>
</SheetDescription>
</SheetHeader>
<div class="flex-1 overflow-y-auto px-6">
<div class="space-y-6 py-2">
<!-- 视频状态编辑 -->
<div>
<h3 class="mb-4 text-base font-medium">视频状态</h3>
<div class="bg-card rounded-lg border p-4">
<div class="space-y-3">
{#each videoTaskNames as taskName, index (index)}
{@const statusInfo = getStatusInfo(videoStatuses[index])}
{@const isModified = videoStatuses[index] !== null}
<div
class="bg-background hover:bg-muted/30 flex items-center justify-between rounded-md border p-3 transition-colors {isModified
? 'border-blue-200 ring-2 ring-blue-500/20'
: ''}"
>
<div class="flex items-center gap-3">
<div>
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{taskName}</span>
{#if isModified}
<span class="hidden text-xs font-medium text-blue-600 sm:inline"
>已修改</span
>
<div
class="h-2 w-2 rounded-full bg-blue-500 sm:hidden"
title="已修改"
></div>
{/if}
</div>
<div class="mt-0.5 flex items-center gap-1.5">
<div class="h-1.5 w-1.5 rounded-full {statusInfo.dotClass}"></div>
<span class="text-xs {statusInfo.class}">{statusInfo.label}</span>
</div>
</div>
</div>
<div class="flex gap-1.5">
{#if isModified}
<Button
variant="ghost"
size="sm"
onclick={() => resetVideoStatus(index)}
disabled={loading}
class="h-7 min-w-[60px] cursor-pointer px-3 text-xs text-gray-600 hover:bg-gray-100"
title="恢复到原始状态"
>
重置
</Button>
{/if}
<Button
variant={videoStatuses[index] === 0 ? 'default' : 'outline'}
size="sm"
onclick={() => setVideoStatus(index, 0)}
disabled={loading}
class="h-7 min-w-[60px] cursor-pointer px-3 text-xs {videoStatuses[index] ===
0
? 'border-yellow-600 bg-yellow-600 font-medium text-white hover:bg-yellow-700'
: 'hover:border-yellow-400 hover:bg-yellow-50 hover:text-yellow-700'}"
>
未开始
</Button>
<Button
variant={videoStatuses[index] === 7 ? 'default' : 'outline'}
size="sm"
onclick={() => setVideoStatus(index, 7)}
disabled={loading}
class="h-7 min-w-[60px] cursor-pointer px-3 text-xs {videoStatuses[index] ===
7
? 'border-emerald-600 bg-emerald-600 font-medium text-white hover:bg-emerald-700'
: 'hover:border-emerald-400 hover:bg-emerald-50 hover:text-emerald-700'}"
>
已完成
</Button>
</div>
</div>
{/each}
</div>
</div>
</div>
<!-- 分页状态编辑 -->
<div>
<h3 class="mb-4 text-base font-medium">分页状态</h3>
<div class="bg-card rounded-lg border p-4">
<div class="space-y-3">
{#each pageTaskNames as taskName, index (index)}
{@const statusInfo = getStatusInfo(pageStatuses[index])}
{@const isModified = pageStatuses[index] !== null}
<div
class="bg-background hover:bg-muted/30 flex items-center justify-between rounded-md border p-3 transition-colors {isModified
? 'border-blue-200 ring-2 ring-blue-500/20'
: ''}"
>
<div class="flex items-center gap-3">
<div>
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{taskName}</span>
{#if isModified}
<span class="hidden text-xs font-medium text-blue-600 sm:inline"
>已修改</span
>
<div
class="h-2 w-2 rounded-full bg-blue-500 sm:hidden"
title="已修改"
></div>
{/if}
</div>
<div class="mt-0.5 flex items-center gap-1.5">
<div class="h-1.5 w-1.5 rounded-full {statusInfo.dotClass}"></div>
<span class="text-xs {statusInfo.class}">{statusInfo.label}</span>
</div>
</div>
</div>
<div class="flex gap-1.5">
{#if isModified}
<Button
variant="ghost"
size="sm"
onclick={() => resetPageStatus(index)}
disabled={loading}
class="h-7 min-w-[60px] cursor-pointer px-3 text-xs text-gray-600 hover:bg-gray-100"
title="恢复到原始状态"
>
重置
</Button>
{/if}
<Button
variant={pageStatuses[index] === 0 ? 'default' : 'outline'}
size="sm"
onclick={() => setPageStatus(index, 0)}
disabled={loading}
class="h-7 min-w-[60px] cursor-pointer px-3 text-xs {pageStatuses[index] === 0
? 'border-yellow-600 bg-yellow-600 font-medium text-white hover:bg-yellow-700'
: 'hover:border-yellow-400 hover:bg-yellow-50 hover:text-yellow-700'}"
>
未开始
</Button>
<Button
variant={pageStatuses[index] === 7 ? 'default' : 'outline'}
size="sm"
onclick={() => setPageStatus(index, 7)}
disabled={loading}
class="h-7 min-w-[60px] cursor-pointer px-3 text-xs {pageStatuses[index] === 7
? 'border-emerald-600 bg-emerald-600 font-medium text-white hover:bg-emerald-700'
: 'hover:border-emerald-400 hover:bg-emerald-50 hover:text-emerald-700'}"
>
已完成
</Button>
</div>
</div>
{/each}
</div>
</div>
</div>
</div>
</div>
<SheetFooter class="bg-background flex gap-2 border-t px-6 pt-4">
<Button
variant="outline"
onclick={resetAllStatuses}
disabled={!hasAnyChanges() || loading}
class="flex-1 cursor-pointer"
>
重置所有状态
</Button>
<Button
onclick={handleSubmit}
disabled={loading || !hasAnyChanges()}
class="flex-1 cursor-pointer"
>
{loading ? '提交中...' : '提交更改'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>

View File

@@ -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,

View File

@@ -1,11 +1,8 @@
// API 响应包装器
export interface ApiResponse<T> {
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<T> {
operator: string;
value: T | T[];
@@ -184,7 +191,6 @@ export interface RuleTarget<T> {
export type AndGroup = RuleTarget<string | number | Date>[];
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;

View File

@@ -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 = {

View File

@@ -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<string, Filter> | 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();
</script>
<svelte:head>
@@ -221,6 +294,16 @@
</div>
</div>
<div class="flex items-center gap-2">
<Button
size="sm"
variant="outline"
class="hover:bg-accent hover:text-accent-foreground h-8 cursor-pointer text-xs font-medium"
onclick={() => (updateAllDialogOpen = true)}
disabled={updatingAll || loading}
>
<EditIcon class="mr-1.5 h-3 w-3" />
{hasFilters ? '编辑筛选' : '编辑全部'}
</Button>
<Button
size="sm"
variant="outline"
@@ -229,7 +312,7 @@
disabled={resettingAll || loading}
>
<RotateCcwIcon class="mr-1.5 h-3 w-3 {resettingAll ? 'animate-spin' : ''}" />
重置全部
{hasFilters ? '重置筛选' : '重置全部'}
</Button>
</div>
</div>
@@ -271,16 +354,25 @@
<AlertDialog.Root bind:open={resetAllDialogOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>重置全部视频</AlertDialog.Title>
<AlertDialog.Title>{hasFilters ? '重置筛选视频' : '重置全部视频'}</AlertDialog.Title>
<AlertDialog.Description>
确定要重置<strong>全部视频</strong>的下载状态吗?<br />
{#if hasFilters}
确定要重置<strong>符合以下筛选条件</strong>的视频的下载状态吗?<br />
<div class="bg-muted my-2 rounded-md p-2 text-left">
{#each filterDescriptionParts as part, index (index)}
<div><strong>{part}</strong></div>
{/each}
</div>
{:else}
确定要重置<strong>全部视频</strong>的下载状态吗?<br />
{/if}
此操作会将所有的失败状态重置为未开始,<span class="text-destructive font-medium"
>无法撤销</span
>
</AlertDialog.Description>
</AlertDialog.Header>
<div class="space-y-4 py-4">
<div class="py-2">
<div class="rounded-lg border border-orange-200 bg-orange-50 p-3">
<div class="mb-2 flex items-center space-x-2">
<Checkbox id="force-reset-all" bind:checked={forceReset} />
@@ -317,3 +409,11 @@
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<FilteredStatusEditor
bind:open={updateAllDialogOpen}
{hasFilters}
loading={updatingAll}
filterDescriptionParts={filterDescriptionParts || []}
onsubmit={handleUpdateAllVideos}
/>