mirror of
https://github.com/amtoaer/bili-sync.git
synced 2026-09-05 23:49:53 +08:00
feat: 支持根据筛选条件批量编辑视频的下载状态 (#558)
This commit is contained in:
@@ -1,49 +1,90 @@
|
|||||||
use std::borrow::Borrow;
|
use std::borrow::Borrow;
|
||||||
|
|
||||||
|
use itertools::Itertools;
|
||||||
use sea_orm::{ConnectionTrait, DatabaseTransaction};
|
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,
|
txn: &DatabaseTransaction,
|
||||||
videos: &[impl Borrow<VideoInfo>],
|
videos: &[impl Borrow<T>],
|
||||||
batch_size: Option<usize>,
|
batch_size: Option<usize>,
|
||||||
) -> Result<(), sea_orm::DbErr> {
|
) -> Result<(), sea_orm::DbErr>
|
||||||
|
where
|
||||||
|
T: VideoRecord,
|
||||||
|
{
|
||||||
if videos.is_empty() {
|
if videos.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let videos = videos.iter().map(|v| v.borrow()).collect::<Vec<_>>();
|
|
||||||
if let Some(size) = batch_size {
|
if let Some(size) = batch_size {
|
||||||
for chunk in videos.chunks(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 {
|
} 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_page_download_status(
|
pub async fn update_page_download_status<T>(
|
||||||
txn: &DatabaseTransaction,
|
txn: &DatabaseTransaction,
|
||||||
pages: &[impl Borrow<PageInfo>],
|
pages: &[impl Borrow<T>],
|
||||||
batch_size: Option<usize>,
|
batch_size: Option<usize>,
|
||||||
) -> Result<(), sea_orm::DbErr> {
|
) -> Result<(), sea_orm::DbErr>
|
||||||
|
where
|
||||||
|
T: PageRecord,
|
||||||
|
{
|
||||||
if pages.is_empty() {
|
if pages.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let pages = pages.iter().map(|v| v.borrow()).collect::<Vec<_>>();
|
|
||||||
if let Some(size) = batch_size {
|
if let Some(size) = batch_size {
|
||||||
for chunk in pages.chunks(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 {
|
} 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute_video_update_batch(txn: &DatabaseTransaction, videos: &[&VideoInfo]) -> Result<(), sea_orm::DbErr> {
|
async fn execute_video_update_batch(
|
||||||
if videos.is_empty() {
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
@@ -52,18 +93,21 @@ async fn execute_video_update_batch(txn: &DatabaseTransaction, videos: &[&VideoI
|
|||||||
SET download_status = tempdata.download_status \
|
SET download_status = tempdata.download_status \
|
||||||
FROM tempdata \
|
FROM tempdata \
|
||||||
WHERE video.id = tempdata.id",
|
WHERE video.id = tempdata.id",
|
||||||
videos
|
values
|
||||||
.iter()
|
|
||||||
.map(|v| format!("({}, {})", v.id, v.download_status))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ")
|
|
||||||
);
|
);
|
||||||
txn.execute_unprepared(&sql).await?;
|
txn.execute_unprepared(&sql).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute_page_update_batch(txn: &DatabaseTransaction, pages: &[&PageInfo]) -> Result<(), sea_orm::DbErr> {
|
async fn execute_page_update_batch(
|
||||||
if pages.is_empty() {
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
@@ -72,11 +116,7 @@ async fn execute_page_update_batch(txn: &DatabaseTransaction, pages: &[&PageInfo
|
|||||||
SET download_status = tempdata.download_status \
|
SET download_status = tempdata.download_status \
|
||||||
FROM tempdata \
|
FROM tempdata \
|
||||||
WHERE page.id = tempdata.id",
|
WHERE page.id = tempdata.id",
|
||||||
pages
|
values
|
||||||
.iter()
|
|
||||||
.map(|p| format!("({}, {})", p.id, p.download_status))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ")
|
|
||||||
);
|
);
|
||||||
txn.execute_unprepared(&sql).await?;
|
txn.execute_unprepared(&sql).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -16,7 +16,18 @@ pub struct VideosRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[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)]
|
#[serde(default)]
|
||||||
pub force: bool,
|
pub force: bool,
|
||||||
}
|
}
|
||||||
@@ -46,6 +57,21 @@ pub struct UpdateVideoStatusRequest {
|
|||||||
pub page_updates: Vec<PageStatusUpdate>,
|
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)]
|
#[derive(Deserialize)]
|
||||||
pub struct FollowedCollectionsRequest {
|
pub struct FollowedCollectionsRequest {
|
||||||
pub page_num: Option<i32>,
|
pub page_num: Option<i32>,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ pub struct ResetVideoResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct ResetAllVideosResponse {
|
pub struct ResetFilteredVideosResponse {
|
||||||
pub resetted: bool,
|
pub resetted: bool,
|
||||||
pub resetted_videos_count: usize,
|
pub resetted_videos_count: usize,
|
||||||
pub resetted_pages_count: usize,
|
pub resetted_pages_count: usize,
|
||||||
@@ -46,6 +46,13 @@ pub struct UpdateVideoStatusResponse {
|
|||||||
pub pages: Vec<PageInfo>,
|
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)]
|
#[derive(FromQueryResult, Serialize)]
|
||||||
pub struct VideoSource {
|
pub struct VideoSource {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
@@ -75,6 +82,21 @@ pub struct PageInfo {
|
|||||||
pub download_status: u32,
|
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>
|
fn serde_video_download_status<S>(status: &u32, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
where
|
where
|
||||||
S: serde::Serializer,
|
S: serde::Serializer,
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ use sea_orm::{
|
|||||||
|
|
||||||
use crate::api::error::InnerApiError;
|
use crate::api::error::InnerApiError;
|
||||||
use crate::api::helper::{update_page_download_status, update_video_download_status};
|
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::{
|
use crate::api::response::{
|
||||||
PageInfo, ResetAllVideosResponse, ResetVideoResponse, UpdateVideoStatusResponse, VideoInfo, VideoResponse,
|
PageInfo, ResetFilteredVideosResponse, ResetVideoResponse, SimplePageInfo, SimpleVideoInfo,
|
||||||
VideosResponse,
|
UpdateFilteredVideoStatusResponse, UpdateVideoStatusResponse, VideoInfo, VideoResponse, VideosResponse,
|
||||||
};
|
};
|
||||||
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
|
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
|
||||||
use crate::utils::status::{PageStatus, VideoStatus};
|
use crate::utils::status::{PageStatus, VideoStatus};
|
||||||
@@ -23,9 +26,10 @@ pub(super) fn router() -> Router {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/videos", get(get_videos))
|
.route("/videos", get(get_videos))
|
||||||
.route("/videos/{id}", get(get_video))
|
.route("/videos/{id}", get(get_video))
|
||||||
.route("/videos/{id}/reset", post(reset_video))
|
.route("/videos/{id}/reset-status", post(reset_video_status))
|
||||||
.route("/videos/reset-all", post(reset_all_videos))
|
|
||||||
.route("/videos/{id}/update-status", post(update_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>,
|
Path(id): Path<i32>,
|
||||||
Extension(db): Extension<DatabaseConnection>,
|
Extension(db): Extension<DatabaseConnection>,
|
||||||
Json(request): Json<ResetRequest>,
|
Json(request): Json<ResetVideoStatusRequest>,
|
||||||
) -> Result<ApiResponse<ResetVideoResponse>, ApiError> {
|
) -> Result<ApiResponse<ResetVideoResponse>, ApiError> {
|
||||||
let (video_info, pages_info) = tokio::try_join!(
|
let (video_info, pages_info) = tokio::try_join!(
|
||||||
video::Entity::find_by_id(id).into_partial_model::<VideoInfo>().one(&db),
|
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?;
|
let txn = db.begin().await?;
|
||||||
if !resetted_videos_info.is_empty() {
|
if !resetted_videos_info.is_empty() {
|
||||||
// 只可能有 1 个元素,所以不用 batch
|
// 只可能有 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() {
|
if !resetted_pages_info.is_empty() {
|
||||||
update_page_download_status(&txn, &resetted_pages_info, Some(500)).await?;
|
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>,
|
Extension(db): Extension<DatabaseConnection>,
|
||||||
Json(request): Json<ResetRequest>,
|
Json(request): Json<ResetFilteredVideoStatusRequest>,
|
||||||
) -> Result<ApiResponse<ResetAllVideosResponse>, ApiError> {
|
) -> Result<ApiResponse<ResetFilteredVideosResponse>, ApiError> {
|
||||||
// 先查询所有视频和页面数据
|
let mut query = video::Entity::find();
|
||||||
let (all_videos, all_pages) = tokio::try_join!(
|
for (field, column) in [
|
||||||
video::Entity::find().into_partial_model::<VideoInfo>().all(&db),
|
(request.collection, video::Column::CollectionId),
|
||||||
page::Entity::find().into_partial_model::<PageInfo>().all(&db)
|
(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
|
let resetted_pages_info = all_pages
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|mut page_info| {
|
.filter_map(|mut page_info| {
|
||||||
@@ -200,7 +223,7 @@ pub async fn reset_all_videos(
|
|||||||
}
|
}
|
||||||
txn.commit().await?;
|
txn.commit().await?;
|
||||||
}
|
}
|
||||||
Ok(ApiResponse::ok(ResetAllVideosResponse {
|
Ok(ApiResponse::ok(ResetFilteredVideosResponse {
|
||||||
resetted: has_video_updates || has_page_updates,
|
resetted: has_video_updates || has_page_updates,
|
||||||
resetted_videos_count: resetted_videos_info.len(),
|
resetted_videos_count: resetted_videos_info.len(),
|
||||||
resetted_pages_count: resetted_pages_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 {
|
if has_video_updates || has_page_updates {
|
||||||
let txn = db.begin().await?;
|
let txn = db.begin().await?;
|
||||||
if has_video_updates {
|
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 {
|
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?;
|
txn.commit().await?;
|
||||||
}
|
}
|
||||||
@@ -261,3 +284,64 @@ pub async fn update_video_status(
|
|||||||
pages: pages_info,
|
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(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
+28
-9
@@ -5,7 +5,7 @@ import type {
|
|||||||
VideosResponse,
|
VideosResponse,
|
||||||
VideoResponse,
|
VideoResponse,
|
||||||
ResetVideoResponse,
|
ResetVideoResponse,
|
||||||
ResetAllVideosResponse,
|
ResetFilteredVideosResponse,
|
||||||
UpdateVideoStatusRequest,
|
UpdateVideoStatusRequest,
|
||||||
UpdateVideoStatusResponse,
|
UpdateVideoStatusResponse,
|
||||||
ApiError,
|
ApiError,
|
||||||
@@ -21,8 +21,12 @@ import type {
|
|||||||
DashBoardResponse,
|
DashBoardResponse,
|
||||||
SysInfo,
|
SysInfo,
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
ResetRequest,
|
ResetVideoStatusRequest,
|
||||||
UpdateVideoSourceResponse
|
UpdateVideoSourceResponse,
|
||||||
|
Notifier,
|
||||||
|
UpdateFilteredVideoStatusRequest,
|
||||||
|
UpdateFilteredVideoStatusResponse,
|
||||||
|
ResetFilteredVideoStatusRequest
|
||||||
} from './types';
|
} from './types';
|
||||||
import { wsManager } from './ws';
|
import { wsManager } from './ws';
|
||||||
|
|
||||||
@@ -152,12 +156,17 @@ class ApiClient {
|
|||||||
return this.get<VideoResponse>(`/videos/${id}`);
|
return this.get<VideoResponse>(`/videos/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async resetVideo(id: number, request: ResetRequest): Promise<ApiResponse<ResetVideoResponse>> {
|
async resetVideoStatus(
|
||||||
return this.post<ResetVideoResponse>(`/videos/${id}/reset`, request);
|
id: number,
|
||||||
|
request: ResetVideoStatusRequest
|
||||||
|
): Promise<ApiResponse<ResetVideoResponse>> {
|
||||||
|
return this.post<ResetVideoResponse>(`/videos/${id}/reset-status`, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
async resetAllVideos(request: ResetRequest): Promise<ApiResponse<ResetAllVideosResponse>> {
|
async resetFilteredVideoStatus(
|
||||||
return this.post<ResetAllVideosResponse>('/videos/reset-all', request);
|
request: ResetFilteredVideoStatusRequest
|
||||||
|
): Promise<ApiResponse<ResetFilteredVideosResponse>> {
|
||||||
|
return this.post<ResetFilteredVideosResponse>('/videos/reset-status', request);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateVideoStatus(
|
async updateVideoStatus(
|
||||||
@@ -167,6 +176,12 @@ class ApiClient {
|
|||||||
return this.post<UpdateVideoStatusResponse>(`/videos/${id}/update-status`, request);
|
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>> {
|
async getCreatedFavorites(): Promise<ApiResponse<FavoritesResponse>> {
|
||||||
return this.get<FavoritesResponse>('/me/favorites');
|
return this.get<FavoritesResponse>('/me/favorites');
|
||||||
}
|
}
|
||||||
@@ -268,10 +283,14 @@ const api = {
|
|||||||
getVideoSources: () => apiClient.getVideoSources(),
|
getVideoSources: () => apiClient.getVideoSources(),
|
||||||
getVideos: (params?: VideosRequest) => apiClient.getVideos(params),
|
getVideos: (params?: VideosRequest) => apiClient.getVideos(params),
|
||||||
getVideo: (id: number) => apiClient.getVideo(id),
|
getVideo: (id: number) => apiClient.getVideo(id),
|
||||||
resetVideo: (id: number, request: ResetRequest) => apiClient.resetVideo(id, request),
|
resetVideoStatus: (id: number, request: ResetVideoStatusRequest) =>
|
||||||
resetAllVideos: (request: ResetRequest) => apiClient.resetAllVideos(request),
|
apiClient.resetVideoStatus(id, request),
|
||||||
|
resetFilteredVideoStatus: (request: ResetFilteredVideoStatusRequest) =>
|
||||||
|
apiClient.resetFilteredVideoStatus(request),
|
||||||
updateVideoStatus: (id: number, request: UpdateVideoStatusRequest) =>
|
updateVideoStatus: (id: number, request: UpdateVideoStatusRequest) =>
|
||||||
apiClient.updateVideoStatus(id, request),
|
apiClient.updateVideoStatus(id, request),
|
||||||
|
updateFilteredVideoStatus: (request: UpdateFilteredVideoStatusRequest) =>
|
||||||
|
apiClient.updateFilteredVideoStatus(request),
|
||||||
getCreatedFavorites: () => apiClient.getCreatedFavorites(),
|
getCreatedFavorites: () => apiClient.getCreatedFavorites(),
|
||||||
getFollowedCollections: (pageNum?: number, pageSize?: number) =>
|
getFollowedCollections: (pageNum?: number, pageSize?: number) =>
|
||||||
apiClient.getFollowedCollections(pageNum, pageSize),
|
apiClient.getFollowedCollections(pageNum, pageSize),
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -31,6 +31,41 @@ export const ToQuery = (state: AppState): string => {
|
|||||||
return queryString ? `videos?${queryString}` : 'videos';
|
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) => {
|
export const setQuery = (query: string) => {
|
||||||
appStateStore.update((state) => ({
|
appStateStore.update((state) => ({
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
+47
-48
@@ -1,11 +1,8 @@
|
|||||||
// API 响应包装器
|
|
||||||
|
|
||||||
export interface ApiResponse<T> {
|
export interface ApiResponse<T> {
|
||||||
status_code: number;
|
status_code: number;
|
||||||
data: T;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 请求参数类型
|
|
||||||
export interface VideosRequest {
|
export interface VideosRequest {
|
||||||
collection?: number;
|
collection?: number;
|
||||||
favorite?: number;
|
favorite?: number;
|
||||||
@@ -16,13 +13,11 @@ export interface VideosRequest {
|
|||||||
page_size?: number;
|
page_size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 视频来源类型
|
|
||||||
export interface VideoSource {
|
export interface VideoSource {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 视频来源响应类型
|
|
||||||
export interface VideoSourcesResponse {
|
export interface VideoSourcesResponse {
|
||||||
collection: VideoSource[];
|
collection: VideoSource[];
|
||||||
favorite: VideoSource[];
|
favorite: VideoSource[];
|
||||||
@@ -30,7 +25,6 @@ export interface VideoSourcesResponse {
|
|||||||
watch_later: VideoSource[];
|
watch_later: VideoSource[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 视频信息类型
|
|
||||||
export interface VideoInfo {
|
export interface VideoInfo {
|
||||||
id: number;
|
id: number;
|
||||||
bvid: string;
|
bvid: string;
|
||||||
@@ -40,13 +34,11 @@ export interface VideoInfo {
|
|||||||
download_status: [number, number, number, number, number];
|
download_status: [number, number, number, number, number];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 视频列表响应类型
|
|
||||||
export interface VideosResponse {
|
export interface VideosResponse {
|
||||||
videos: VideoInfo[];
|
videos: VideoInfo[];
|
||||||
total_count: number;
|
total_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分页信息类型
|
|
||||||
export interface PageInfo {
|
export interface PageInfo {
|
||||||
id: number;
|
id: number;
|
||||||
pid: number;
|
pid: number;
|
||||||
@@ -54,59 +46,75 @@ export interface PageInfo {
|
|||||||
download_status: [number, number, number, number, number];
|
download_status: [number, number, number, number, number];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 单个视频响应类型
|
|
||||||
export interface VideoResponse {
|
export interface VideoResponse {
|
||||||
video: VideoInfo;
|
video: VideoInfo;
|
||||||
pages: PageInfo[];
|
pages: PageInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置视频响应类型
|
|
||||||
export interface ResetVideoResponse {
|
export interface ResetVideoResponse {
|
||||||
resetted: boolean;
|
resetted: boolean;
|
||||||
video: VideoInfo;
|
video: VideoInfo;
|
||||||
pages: PageInfo[];
|
pages: PageInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置所有视频响应类型
|
export interface ResetFilteredVideosResponse {
|
||||||
export interface ResetAllVideosResponse {
|
|
||||||
resetted: boolean;
|
resetted: boolean;
|
||||||
resetted_videos_count: number;
|
resetted_videos_count: number;
|
||||||
resetted_pages_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 {
|
export interface UpdateVideoStatusResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
video: VideoInfo;
|
video: VideoInfo;
|
||||||
pages: PageInfo[];
|
pages: PageInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置请求类型
|
export interface UpdateFilteredVideoStatusResponse {
|
||||||
export interface ResetRequest {
|
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;
|
force: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +178,6 @@ export interface InsertSubmissionRequest {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rule 相关类型
|
|
||||||
export interface Condition<T> {
|
export interface Condition<T> {
|
||||||
operator: string;
|
operator: string;
|
||||||
value: T | T[];
|
value: T | T[];
|
||||||
@@ -184,7 +191,6 @@ export interface RuleTarget<T> {
|
|||||||
export type AndGroup = RuleTarget<string | number | Date>[];
|
export type AndGroup = RuleTarget<string | number | Date>[];
|
||||||
export type Rule = AndGroup[];
|
export type Rule = AndGroup[];
|
||||||
|
|
||||||
// 视频源详细信息类型
|
|
||||||
export interface VideoSourceDetail {
|
export interface VideoSourceDetail {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -195,7 +201,6 @@ export interface VideoSourceDetail {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 视频源详细信息响应类型
|
|
||||||
export interface VideoSourcesDetailsResponse {
|
export interface VideoSourcesDetailsResponse {
|
||||||
collections: VideoSourceDetail[];
|
collections: VideoSourceDetail[];
|
||||||
favorites: VideoSourceDetail[];
|
favorites: VideoSourceDetail[];
|
||||||
@@ -203,7 +208,6 @@ export interface VideoSourcesDetailsResponse {
|
|||||||
watch_later: VideoSourceDetail[];
|
watch_later: VideoSourceDetail[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新视频源请求类型
|
|
||||||
export interface UpdateVideoSourceRequest {
|
export interface UpdateVideoSourceRequest {
|
||||||
path: string;
|
path: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -211,7 +215,6 @@ export interface UpdateVideoSourceRequest {
|
|||||||
useDynamicApi?: boolean | null;
|
useDynamicApi?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 配置相关类型
|
|
||||||
export interface Credential {
|
export interface Credential {
|
||||||
sessdata: string;
|
sessdata: string;
|
||||||
bili_jct: string;
|
bili_jct: string;
|
||||||
@@ -273,7 +276,6 @@ export interface ConcurrentLimit {
|
|||||||
download: ConcurrentDownloadLimit;
|
download: ConcurrentDownloadLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notifier 相关类型
|
|
||||||
export interface TelegramNotifier {
|
export interface TelegramNotifier {
|
||||||
type: 'telegram';
|
type: 'telegram';
|
||||||
bot_token: string;
|
bot_token: string;
|
||||||
@@ -312,13 +314,11 @@ export interface Config {
|
|||||||
version: number;
|
version: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 日期计数对类型
|
|
||||||
export interface DayCountPair {
|
export interface DayCountPair {
|
||||||
day: string;
|
day: string;
|
||||||
cnt: number;
|
cnt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 仪表盘响应类型
|
|
||||||
export interface DashBoardResponse {
|
export interface DashBoardResponse {
|
||||||
enabled_favorites: number;
|
enabled_favorites: number;
|
||||||
enabled_collections: number;
|
enabled_collections: number;
|
||||||
@@ -327,7 +327,6 @@ export interface DashBoardResponse {
|
|||||||
videos_by_day: DayCountPair[];
|
videos_by_day: DayCountPair[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 系统信息响应类型
|
|
||||||
export interface SysInfo {
|
export interface SysInfo {
|
||||||
total_memory: number;
|
total_memory: number;
|
||||||
used_memory: number;
|
used_memory: number;
|
||||||
|
|||||||
@@ -166,7 +166,7 @@
|
|||||||
bind:resetting
|
bind:resetting
|
||||||
onReset={async (forceReset: boolean) => {
|
onReset={async (forceReset: boolean) => {
|
||||||
try {
|
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;
|
const data = result.data;
|
||||||
if (data.resetted) {
|
if (data.resetted) {
|
||||||
videoData = {
|
videoData = {
|
||||||
|
|||||||
@@ -3,11 +3,18 @@
|
|||||||
import Pagination from '$lib/components/pagination.svelte';
|
import Pagination from '$lib/components/pagination.svelte';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import * as AlertDialog from '$lib/components/ui/alert-dialog/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 RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
|
||||||
import api from '$lib/api';
|
import api from '$lib/api';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||||
import { Label } from '$lib/components/ui/label/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 { onMount } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
@@ -19,11 +26,14 @@
|
|||||||
setAll,
|
setAll,
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
setQuery,
|
setQuery,
|
||||||
ToQuery
|
ToQuery,
|
||||||
|
ToFilterParams,
|
||||||
|
hasActiveFilters
|
||||||
} from '$lib/stores/filter';
|
} from '$lib/stores/filter';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import DropdownFilter, { type Filter } from '$lib/components/dropdown-filter.svelte';
|
import DropdownFilter, { type Filter } from '$lib/components/dropdown-filter.svelte';
|
||||||
import SearchBar from '$lib/components/search-bar.svelte';
|
import SearchBar from '$lib/components/search-bar.svelte';
|
||||||
|
import FilteredStatusEditor from '$lib/components/filtered-status-editor.svelte';
|
||||||
|
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
|
|
||||||
@@ -37,6 +47,9 @@
|
|||||||
|
|
||||||
let forceReset = false;
|
let forceReset = false;
|
||||||
|
|
||||||
|
let updateAllDialogOpen = false;
|
||||||
|
let updatingAll = false;
|
||||||
|
|
||||||
let videoSources: VideoSourcesResponse | null = null;
|
let videoSources: VideoSourcesResponse | null = null;
|
||||||
let filters: Record<string, Filter> | null = null;
|
let filters: Record<string, Filter> | null = null;
|
||||||
|
|
||||||
@@ -97,7 +110,7 @@
|
|||||||
|
|
||||||
async function handleResetVideo(id: number, forceReset: boolean) {
|
async function handleResetVideo(id: number, forceReset: boolean) {
|
||||||
try {
|
try {
|
||||||
const result = await api.resetVideo(id, { force: forceReset });
|
const result = await api.resetVideoStatus(id, { force: forceReset });
|
||||||
const data = result.data;
|
const data = result.data;
|
||||||
if (data.resetted) {
|
if (data.resetted) {
|
||||||
toast.success('重置成功', {
|
toast.success('重置成功', {
|
||||||
@@ -121,7 +134,12 @@
|
|||||||
async function handleResetAllVideos() {
|
async function handleResetAllVideos() {
|
||||||
resettingAll = true;
|
resettingAll = true;
|
||||||
try {
|
try {
|
||||||
const result = await api.resetAllVideos({ force: forceReset });
|
// 获取筛选参数
|
||||||
|
const filterParams = ToFilterParams($appStateStore);
|
||||||
|
const result = await api.resetFilteredVideoStatus({
|
||||||
|
...filterParams,
|
||||||
|
force: forceReset
|
||||||
|
});
|
||||||
const data = result.data;
|
const data = result.data;
|
||||||
if (data.resetted) {
|
if (data.resetted) {
|
||||||
toast.success('重置成功', {
|
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) {
|
$: if ($page.url.search !== lastSearch) {
|
||||||
lastSearch = $page.url.search;
|
lastSearch = $page.url.search;
|
||||||
handleSearchParamsChange($page.url.searchParams);
|
handleSearchParamsChange($page.url.searchParams);
|
||||||
@@ -177,6 +248,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$: totalPages = videosData ? Math.ceil(videosData.total_count / pageSize) : 0;
|
$: totalPages = videosData ? Math.ceil(videosData.total_count / pageSize) : 0;
|
||||||
|
$: hasFilters = hasActiveFilters($appStateStore);
|
||||||
|
$: filterDescriptionParts = videoSources && $appStateStore && getFilterDescriptionParts();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -221,6 +294,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -229,7 +312,7 @@
|
|||||||
disabled={resettingAll || loading}
|
disabled={resettingAll || loading}
|
||||||
>
|
>
|
||||||
<RotateCcwIcon class="mr-1.5 h-3 w-3 {resettingAll ? 'animate-spin' : ''}" />
|
<RotateCcwIcon class="mr-1.5 h-3 w-3 {resettingAll ? 'animate-spin' : ''}" />
|
||||||
重置全部
|
{hasFilters ? '重置筛选' : '重置全部'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -271,16 +354,25 @@
|
|||||||
<AlertDialog.Root bind:open={resetAllDialogOpen}>
|
<AlertDialog.Root bind:open={resetAllDialogOpen}>
|
||||||
<AlertDialog.Content>
|
<AlertDialog.Content>
|
||||||
<AlertDialog.Header>
|
<AlertDialog.Header>
|
||||||
<AlertDialog.Title>重置全部视频</AlertDialog.Title>
|
<AlertDialog.Title>{hasFilters ? '重置筛选视频' : '重置全部视频'}</AlertDialog.Title>
|
||||||
<AlertDialog.Description>
|
<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 class="text-destructive font-medium"
|
||||||
>无法撤销</span
|
>无法撤销</span
|
||||||
>。
|
>。
|
||||||
</AlertDialog.Description>
|
</AlertDialog.Description>
|
||||||
</AlertDialog.Header>
|
</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="rounded-lg border border-orange-200 bg-orange-50 p-3">
|
||||||
<div class="mb-2 flex items-center space-x-2">
|
<div class="mb-2 flex items-center space-x-2">
|
||||||
<Checkbox id="force-reset-all" bind:checked={forceReset} />
|
<Checkbox id="force-reset-all" bind:checked={forceReset} />
|
||||||
@@ -317,3 +409,11 @@
|
|||||||
</AlertDialog.Footer>
|
</AlertDialog.Footer>
|
||||||
</AlertDialog.Content>
|
</AlertDialog.Content>
|
||||||
</AlertDialog.Root>
|
</AlertDialog.Root>
|
||||||
|
|
||||||
|
<FilteredStatusEditor
|
||||||
|
bind:open={updateAllDialogOpen}
|
||||||
|
{hasFilters}
|
||||||
|
loading={updatingAll}
|
||||||
|
filterDescriptionParts={filterDescriptionParts || []}
|
||||||
|
onsubmit={handleUpdateAllVideos}
|
||||||
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user