use std::collections::HashSet; use std::sync::Arc; use anyhow::Result; use axum::extract::{Extension, Path, Query}; use bili_sync_entity::*; use bili_sync_migration::Expr; use sea_orm::{ ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, TransactionTrait, }; use utoipa::OpenApi; use crate::api::auth::OpenAPIAuth; use crate::api::error::InnerApiError; use crate::api::helper::{update_page_download_status, update_video_download_status}; use crate::api::request::{ResetVideoStatusRequest, VideosRequest}; use crate::api::response::{ PageInfo, ResetAllVideosResponse, ResetVideoResponse, ResetVideoStatusResponse, VideoInfo, VideoResponse, VideoSource, VideoSourcesResponse, VideosResponse, }; use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson}; use crate::utils::status::{PageStatus, VideoStatus}; #[derive(OpenApi)] #[openapi( paths(get_video_sources, get_videos, get_video, reset_video, reset_all_videos, reset_video_status), modifiers(&OpenAPIAuth), security( ("Token" = []), ) )] pub struct ApiDoc; /// 列出所有视频来源 #[utoipa::path( get, path = "/api/video-sources", responses( (status = 200, body = ApiResponse), ) )] pub async fn get_video_sources( Extension(db): Extension>, ) -> Result, ApiError> { let (collection, favorite, submission, watch_later) = tokio::try_join!( collection::Entity::find() .select_only() .columns([collection::Column::Id, collection::Column::Name]) .into_model::() .all(db.as_ref()), favorite::Entity::find() .select_only() .columns([favorite::Column::Id, favorite::Column::Name]) .into_model::() .all(db.as_ref()), submission::Entity::find() .select_only() .column(submission::Column::Id) .column_as(submission::Column::UpperName, "name") .into_model::() .all(db.as_ref()), watch_later::Entity::find() .select_only() .column(watch_later::Column::Id) .column_as(Expr::value("稍后再看"), "name") .into_model::() .all(db.as_ref()) )?; Ok(ApiResponse::ok(VideoSourcesResponse { collection, favorite, submission, watch_later, })) } /// 列出视频的基本信息,支持根据视频来源筛选、名称查找和分页 #[utoipa::path( get, path = "/api/videos", params( VideosRequest, ), responses( (status = 200, body = ApiResponse), ) )] pub async fn get_videos( Extension(db): Extension>, Query(params): Query, ) -> Result, ApiError> { let mut query = video::Entity::find(); for (field, column) in [ (params.collection, video::Column::CollectionId), (params.favorite, video::Column::FavoriteId), (params.submission, video::Column::SubmissionId), (params.watch_later, video::Column::WatchLaterId), ] { if let Some(id) = field { query = query.filter(column.eq(id)); } } if let Some(query_word) = params.query { query = query.filter(video::Column::Name.contains(query_word)); } let total_count = query.clone().count(db.as_ref()).await?; let (page, page_size) = if let (Some(page), Some(page_size)) = (params.page, params.page_size) { (page, page_size) } else { (0, 10) }; Ok(ApiResponse::ok(VideosResponse { videos: query .order_by_desc(video::Column::Id) .into_partial_model::() .paginate(db.as_ref(), page_size) .fetch_page(page) .await?, total_count, })) } /// 获取视频详细信息,包括关联的所有 page #[utoipa::path( get, path = "/api/videos/{id}", responses( (status = 200, body = ApiResponse), ) )] pub async fn get_video( Path(id): Path, Extension(db): Extension>, ) -> Result, ApiError> { let (video_info, pages_info) = tokio::try_join!( video::Entity::find_by_id(id) .into_partial_model::() .one(db.as_ref()), page::Entity::find() .filter(page::Column::VideoId.eq(id)) .order_by_asc(page::Column::Cid) .into_partial_model::() .all(db.as_ref()) )?; let Some(video_info) = video_info else { return Err(InnerApiError::NotFound(id).into()); }; Ok(ApiResponse::ok(VideoResponse { video: video_info, pages: pages_info, })) } /// 将某个视频与其所有分页的失败状态清空为未下载状态,这样在下次下载任务中会触发重试 #[utoipa::path( post, path = "/api/videos/{id}/reset", responses( (status = 200, body = ApiResponse), ) )] pub async fn reset_video( Path(id): Path, Extension(db): Extension>, ) -> Result, ApiError> { let (video_info, pages_info) = tokio::try_join!( video::Entity::find_by_id(id) .into_partial_model::() .one(db.as_ref()), page::Entity::find() .filter(page::Column::VideoId.eq(id)) .order_by_asc(page::Column::Cid) .into_partial_model::() .all(db.as_ref()) )?; let Some(mut video_info) = video_info else { return Err(InnerApiError::NotFound(id).into()); }; let resetted_pages_info = pages_info .into_iter() .filter_map(|mut page_info| { let mut page_status = PageStatus::from(page_info.download_status); if page_status.reset_failed() { page_info.download_status = page_status.into(); Some(page_info) } else { None } }) .collect::>(); let mut video_status = VideoStatus::from(video_info.download_status); let mut video_resetted = video_status.reset_failed(); if !resetted_pages_info.is_empty() { video_status.set(4, 0); // 将“分P下载”重置为 0 video_resetted = true; } let resetted_videos_info = if video_resetted { video_info.download_status = video_status.into(); vec![&video_info] } else { vec![] }; let resetted = !resetted_videos_info.is_empty() || !resetted_pages_info.is_empty(); if resetted { let txn = db.begin().await?; if !resetted_videos_info.is_empty() { // 只可能有 1 个元素,所以不用 batch update_video_download_status(&txn, &resetted_videos_info, None).await?; } if !resetted_pages_info.is_empty() { update_page_download_status(&txn, &resetted_pages_info, Some(500)).await?; } txn.commit().await?; } Ok(ApiResponse::ok(ResetVideoResponse { resetted, video: video_info, pages: resetted_pages_info, })) } /// 重置所有视频和页面的失败状态为未下载状态,这样在下次下载任务中会触发重试 #[utoipa::path( post, path = "/api/videos/reset-all", responses( (status = 200, body = ApiResponse), ) )] pub async fn reset_all_videos( Extension(db): Extension>, ) -> Result, ApiError> { // 先查询所有视频和页面数据 let (all_videos, all_pages) = tokio::try_join!( video::Entity::find().into_partial_model::().all(db.as_ref()), page::Entity::find().into_partial_model::().all(db.as_ref()) )?; let resetted_pages_info = all_pages .into_iter() .filter_map(|mut page_info| { let mut page_status = PageStatus::from(page_info.download_status); if page_status.reset_failed() { page_info.download_status = page_status.into(); Some(page_info) } else { None } }) .collect::>(); let video_ids_with_resetted_pages: HashSet = resetted_pages_info.iter().map(|page| page.video_id).collect(); let resetted_videos_info = all_videos .into_iter() .filter_map(|mut video_info| { let mut video_status = VideoStatus::from(video_info.download_status); let mut video_resetted = video_status.reset_failed(); if video_ids_with_resetted_pages.contains(&video_info.id) { video_status.set(4, 0); // 将"分P下载"重置为 0 video_resetted = true; } if video_resetted { video_info.download_status = video_status.into(); Some(video_info) } else { None } }) .collect::>(); let resetted = !(resetted_videos_info.is_empty() && resetted_pages_info.is_empty()); if resetted { let txn = db.begin().await?; if !resetted_videos_info.is_empty() { update_video_download_status(&txn, &resetted_videos_info, Some(500)).await?; } if !resetted_pages_info.is_empty() { update_page_download_status(&txn, &resetted_pages_info, Some(500)).await?; } txn.commit().await?; } Ok(ApiResponse::ok(ResetAllVideosResponse { resetted, resetted_videos_count: resetted_videos_info.len(), resetted_pages_count: resetted_pages_info.len(), })) } /// 重置指定视频及其分页的指定状态位 #[utoipa::path( post, path = "/api/videos/{id}/reset-status", request_body = ResetVideoStatusRequest, responses( (status = 200, body = ApiResponse), ) )] pub async fn reset_video_status( Path(id): Path, Extension(db): Extension>, ValidatedJson(request): ValidatedJson, ) -> Result, ApiError> { let (video_info, mut pages_info) = tokio::try_join!( video::Entity::find_by_id(id) .into_partial_model::() .one(db.as_ref()), page::Entity::find() .filter(page::Column::VideoId.eq(id)) .order_by_asc(page::Column::Cid) .into_partial_model::() .all(db.as_ref()) )?; let Some(mut video_info) = video_info else { return Err(InnerApiError::NotFound(id).into()); }; let mut video_status = VideoStatus::from(video_info.download_status); for update in &request.video_updates { video_status.set(update.status_index, update.status_value); } video_info.download_status = video_status.into(); let mut updated_pages_info = Vec::new(); let mut page_id_map = pages_info .iter_mut() .map(|page| (page.id, page)) .collect::>(); for page_update in &request.page_updates { if let Some(page_info) = page_id_map.remove(&page_update.page_id) { let mut page_status = PageStatus::from(page_info.download_status); for update in &page_update.updates { page_status.set(update.status_index, update.status_value); } page_info.download_status = page_status.into(); updated_pages_info.push(page_info); } } let has_video_updates = !request.video_updates.is_empty(); let has_page_updates = !updated_pages_info.is_empty(); if has_video_updates || has_page_updates { let txn = db.begin().await?; if has_video_updates { update_video_download_status(&txn, &[&video_info], None).await?; } if has_page_updates { update_page_download_status(&txn, &updated_pages_info, None).await?; } txn.commit().await?; } Ok(ApiResponse::ok(ResetVideoStatusResponse { success: has_video_updates || has_page_updates, video: video_info, pages: pages_info, })) }