From a627584fb0164b6df44d8685e17fcac846105d02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=B4=80=E1=B4=8D=E1=B4=9B=E1=B4=8F=E1=B4=80=E1=B4=87?= =?UTF-8?q?=CA=80?= Date: Mon, 7 Jul 2025 01:51:40 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=A0=B9=E6=8D=AE=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E5=88=86=E5=89=B2=20api=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E5=8D=95=E6=96=87=E4=BB=B6=E5=86=85=E5=AE=B9=E8=BF=87=E5=A4=9A?= =?UTF-8?q?=20(#376)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/bili_sync/src/api/auth.rs | 24 - crates/bili_sync/src/api/handler.rs | 691 ------------------ crates/bili_sync/src/api/mod.rs | 6 +- crates/bili_sync/src/api/routes/config/mod.rs | 36 + crates/bili_sync/src/api/routes/me/mod.rs | 146 ++++ crates/bili_sync/src/api/routes/mod.rs | 83 +++ .../src/api/routes/video_sources/mod.rs | 257 +++++++ crates/bili_sync/src/api/routes/videos/mod.rs | 263 +++++++ crates/bili_sync/src/task/http_server.rs | 11 +- 9 files changed, 792 insertions(+), 725 deletions(-) delete mode 100644 crates/bili_sync/src/api/auth.rs delete mode 100644 crates/bili_sync/src/api/handler.rs create mode 100644 crates/bili_sync/src/api/routes/config/mod.rs create mode 100644 crates/bili_sync/src/api/routes/me/mod.rs create mode 100644 crates/bili_sync/src/api/routes/mod.rs create mode 100644 crates/bili_sync/src/api/routes/video_sources/mod.rs create mode 100644 crates/bili_sync/src/api/routes/videos/mod.rs diff --git a/crates/bili_sync/src/api/auth.rs b/crates/bili_sync/src/api/auth.rs deleted file mode 100644 index fcef48f..0000000 --- a/crates/bili_sync/src/api/auth.rs +++ /dev/null @@ -1,24 +0,0 @@ -use axum::extract::Request; -use axum::http::HeaderMap; -use axum::middleware::Next; -use axum::response::{IntoResponse, Response}; -use reqwest::StatusCode; - -use crate::api::wrapper::ApiResponse; -use crate::config::VersionedConfig; - -pub async fn auth(headers: HeaderMap, request: Request, next: Next) -> Result { - if request.uri().path().starts_with("/api/") - && get_token(&headers).is_none_or(|token| token != VersionedConfig::get().load().auth_token) - { - return Ok(ApiResponse::<()>::unauthorized("auth token does not match").into_response()); - } - Ok(next.run(request).await) -} - -fn get_token(headers: &HeaderMap) -> Option { - headers - .get("Authorization") - .and_then(|v| v.to_str().ok()) - .map(Into::into) -} diff --git a/crates/bili_sync/src/api/handler.rs b/crates/bili_sync/src/api/handler.rs deleted file mode 100644 index bc4608d..0000000 --- a/crates/bili_sync/src/api/handler.rs +++ /dev/null @@ -1,691 +0,0 @@ -use std::collections::HashSet; -use std::sync::Arc; - -use anyhow::Result; -use axum::Router; -use axum::body::Body; -use axum::extract::{Extension, Path, Query}; -use axum::response::Response; -use axum::routing::{get, post, put}; -use bili_sync_entity::*; -use bili_sync_migration::Expr; -use reqwest::{Method, StatusCode, header}; -use sea_orm::ActiveValue::Set; -use sea_orm::{ - ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, - TransactionTrait, -}; - -use super::request::ImageProxyParams; -use crate::adapter::_ActiveModel; -use crate::api::error::InnerApiError; -use crate::api::helper::{update_page_download_status, update_video_download_status}; -use crate::api::request::{ - FollowedCollectionsRequest, FollowedUppersRequest, InsertCollectionRequest, InsertFavoriteRequest, - InsertSubmissionRequest, UpdateVideoSourceRequest, UpdateVideoStatusRequest, VideosRequest, -}; -use crate::api::response::{ - CollectionWithSubscriptionStatus, CollectionsResponse, FavoriteWithSubscriptionStatus, FavoritesResponse, PageInfo, - ResetAllVideosResponse, ResetVideoResponse, UpdateVideoStatusResponse, UpperWithSubscriptionStatus, UppersResponse, - VideoInfo, VideoResponse, VideoSource, VideoSourceDetail, VideoSourcesDetailsResponse, VideoSourcesResponse, - VideosResponse, -}; -use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson}; -use crate::bilibili::{BiliClient, Collection, CollectionItem, FavoriteList, Me, Submission}; -use crate::config::{Config, VersionedConfig}; -use crate::task::DOWNLOADER_TASK_RUNNING; -use crate::utils::status::{PageStatus, VideoStatus}; - -pub fn api_router() -> Router { - Router::new() - .route("/api/video-sources", get(get_video_sources)) - .route("/api/video-sources/details", get(get_video_sources_details)) - .route("/api/video-sources/{type}/{id}", put(update_video_source)) - .route("/api/video-sources/collections", post(insert_collection)) - .route("/api/video-sources/favorites", post(insert_favorite)) - .route("/api/video-sources/submissions", post(insert_submission)) - .route("/api/videos", get(get_videos)) - .route("/api/videos/{id}", get(get_video)) - .route("/api/videos/{id}/reset", post(reset_video)) - .route("/api/videos/reset-all", post(reset_all_videos)) - .route("/api/videos/{id}/update-status", post(update_video_status)) - .route("/api/me/favorites", get(get_created_favorites)) - .route("/api/me/collections", get(get_followed_collections)) - .route("/api/me/uppers", get(get_followed_uppers)) - .route("/api/config", get(get_config)) - .route("/api/config", put(update_config)) - .route("/image-proxy", get(image_proxy)) -} - -/// 列出所有视频来源 -pub async fn get_video_sources( - Extension(db): Extension>, -) -> Result, ApiError> { - let (collection, favorite, submission, mut 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()) - )?; - // watch_later 是一个特殊的视频来源,如果不存在则添加一个默认项 - if watch_later.is_empty() { - watch_later.push(VideoSource { - id: 1, - name: "稍后再看".to_string(), - }); - } - Ok(ApiResponse::ok(VideoSourcesResponse { - collection, - favorite, - submission, - watch_later, - })) -} - -/// 列出视频的基本信息,支持根据视频来源筛选、名称查找和分页 -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, - })) -} - -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, - })) -} - -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, - })) -} - -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 has_video_updates = !resetted_videos_info.is_empty(); - let has_page_updates = !resetted_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, &resetted_videos_info, Some(500)).await?; - } - if has_page_updates { - update_page_download_status(&txn, &resetted_pages_info, Some(500)).await?; - } - txn.commit().await?; - } - Ok(ApiResponse::ok(ResetAllVideosResponse { - resetted: has_video_updates || has_page_updates, - resetted_videos_count: resetted_videos_info.len(), - resetted_pages_count: resetted_pages_info.len(), - })) -} - -pub async fn update_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(UpdateVideoStatusResponse { - success: has_video_updates || has_page_updates, - video: video_info, - pages: pages_info, - })) -} - -pub async fn get_created_favorites( - Extension(db): Extension>, - Extension(bili_client): Extension>, -) -> Result, ApiError> { - let me = Me::new(bili_client.as_ref()); - let bili_favorites = me.get_created_favorites().await?; - - let favorites = if let Some(bili_favorites) = bili_favorites { - // b 站收藏夹相关接口使用的所谓 “fid” 其实是该处的 id,即 fid + mid 后两位 - let bili_fids: Vec<_> = bili_favorites.iter().map(|fav| fav.id).collect(); - - let subscribed_fids: Vec = favorite::Entity::find() - .select_only() - .column(favorite::Column::FId) - .filter(favorite::Column::FId.is_in(bili_fids)) - .into_tuple() - .all(db.as_ref()) - .await?; - let subscribed_set: HashSet = subscribed_fids.into_iter().collect(); - - bili_favorites - .into_iter() - .map(|fav| FavoriteWithSubscriptionStatus { - title: fav.title, - media_count: fav.media_count, - // api 返回的 id 才是真实的 fid - fid: fav.id, - mid: fav.mid, - subscribed: subscribed_set.contains(&fav.id), - }) - .collect() - } else { - vec![] - }; - - Ok(ApiResponse::ok(FavoritesResponse { favorites })) -} - -pub async fn get_followed_collections( - Extension(db): Extension>, - Extension(bili_client): Extension>, - Query(params): Query, -) -> Result, ApiError> { - let me = Me::new(bili_client.as_ref()); - let (page_num, page_size) = (params.page_num.unwrap_or(1), params.page_size.unwrap_or(50)); - let bili_collections = me.get_followed_collections(page_num, page_size).await?; - - let collections = if let Some(collection_list) = bili_collections.list { - let bili_sids: Vec<_> = collection_list.iter().map(|col| col.id).collect(); - - let subscribed_ids: Vec = collection::Entity::find() - .select_only() - .column(collection::Column::SId) - .filter(collection::Column::SId.is_in(bili_sids)) - .into_tuple() - .all(db.as_ref()) - .await?; - let subscribed_set: HashSet = subscribed_ids.into_iter().collect(); - - collection_list - .into_iter() - .map(|col| CollectionWithSubscriptionStatus { - title: col.title, - sid: col.id, - mid: col.mid, - invalid: col.state == 1, - subscribed: subscribed_set.contains(&col.id), - }) - .collect() - } else { - vec![] - }; - - Ok(ApiResponse::ok(CollectionsResponse { - collections, - total: bili_collections.count, - })) -} - -pub async fn get_followed_uppers( - Extension(db): Extension>, - Extension(bili_client): Extension>, - Query(params): Query, -) -> Result, ApiError> { - let me = Me::new(bili_client.as_ref()); - let (page_num, page_size) = (params.page_num.unwrap_or(1), params.page_size.unwrap_or(20)); - let bili_uppers = me.get_followed_uppers(page_num, page_size).await?; - - let bili_uid: Vec<_> = bili_uppers.list.iter().map(|upper| upper.mid).collect(); - - let subscribed_ids: Vec = submission::Entity::find() - .select_only() - .column(submission::Column::UpperId) - .filter(submission::Column::UpperId.is_in(bili_uid)) - .into_tuple() - .all(db.as_ref()) - .await?; - let subscribed_set: HashSet = subscribed_ids.into_iter().collect(); - - let uppers = bili_uppers - .list - .into_iter() - .map(|upper| UpperWithSubscriptionStatus { - mid: upper.mid, - // 官方没有提供字段,但是可以使用这种方式简单判断下 - invalid: upper.uname == "账号已注销" && upper.face == "https://i0.hdslb.com/bfs/face/member/noface.jpg", - uname: upper.uname, - face: upper.face, - sign: upper.sign, - subscribed: subscribed_set.contains(&upper.mid), - }) - .collect(); - - Ok(ApiResponse::ok(UppersResponse { - uppers, - total: bili_uppers.total, - })) -} - -pub async fn insert_favorite( - Extension(db): Extension>, - Extension(bili_client): Extension>, - ValidatedJson(request): ValidatedJson, -) -> Result, ApiError> { - let favorite = FavoriteList::new(bili_client.as_ref(), request.fid.to_string()); - let favorite_info = favorite.get_info().await?; - favorite::Entity::insert(favorite::ActiveModel { - f_id: Set(favorite_info.id), - name: Set(favorite_info.title.clone()), - path: Set(request.path), - ..Default::default() - }) - .exec(db.as_ref()) - .await?; - - Ok(ApiResponse::ok(true)) -} - -pub async fn insert_collection( - Extension(db): Extension>, - Extension(bili_client): Extension>, - ValidatedJson(request): ValidatedJson, -) -> Result, ApiError> { - let collection = Collection::new( - bili_client.as_ref(), - CollectionItem { - sid: request.sid.to_string(), - mid: request.mid.to_string(), - collection_type: request.collection_type, - }, - ); - let collection_info = collection.get_info().await?; - - collection::Entity::insert(collection::ActiveModel { - s_id: Set(collection_info.sid), - m_id: Set(collection_info.mid), - r#type: Set(collection_info.collection_type.into()), - name: Set(collection_info.name.clone()), - path: Set(request.path), - ..Default::default() - }) - .exec(db.as_ref()) - .await?; - - Ok(ApiResponse::ok(true)) -} - -pub async fn insert_submission( - Extension(db): Extension>, - Extension(bili_client): Extension>, - ValidatedJson(request): ValidatedJson, -) -> Result, ApiError> { - let submission = Submission::new(bili_client.as_ref(), request.upper_id.to_string()); - let upper = submission.get_info().await?; - - submission::Entity::insert(submission::ActiveModel { - upper_id: Set(upper.mid.parse()?), - upper_name: Set(upper.name), - path: Set(request.path), - ..Default::default() - }) - .exec(db.as_ref()) - .await?; - - Ok(ApiResponse::ok(true)) -} - -pub async fn get_video_sources_details( - Extension(db): Extension>, -) -> Result, ApiError> { - let (collections, favorites, submissions, mut watch_later) = tokio::try_join!( - collection::Entity::find() - .select_only() - .columns([ - collection::Column::Id, - collection::Column::Name, - collection::Column::Path, - collection::Column::Enabled - ]) - .into_model::() - .all(db.as_ref()), - favorite::Entity::find() - .select_only() - .columns([ - favorite::Column::Id, - favorite::Column::Name, - favorite::Column::Path, - favorite::Column::Enabled - ]) - .into_model::() - .all(db.as_ref()), - submission::Entity::find() - .select_only() - .column(submission::Column::Id) - .column_as(submission::Column::UpperName, "name") - .columns([submission::Column::Path, submission::Column::Enabled]) - .into_model::() - .all(db.as_ref()), - watch_later::Entity::find() - .select_only() - .column(watch_later::Column::Id) - .column_as(Expr::value("稍后再看"), "name") - .columns([watch_later::Column::Path, watch_later::Column::Enabled]) - .into_model::() - .all(db.as_ref()) - )?; - if watch_later.is_empty() { - watch_later.push(VideoSourceDetail { - id: 1, - name: "稍后再看".to_string(), - path: String::new(), - enabled: false, - }) - } - Ok(ApiResponse::ok(VideoSourcesDetailsResponse { - collections, - favorites, - submissions, - watch_later, - })) -} - -pub async fn update_video_source( - Path((source_type, id)): Path<(String, i32)>, - Extension(db): Extension>, - ValidatedJson(request): ValidatedJson, -) -> Result, ApiError> { - let active_model = match source_type.as_str() { - "collections" => collection::Entity::find_by_id(id).one(db.as_ref()).await?.map(|model| { - let mut active_model: collection::ActiveModel = model.into(); - active_model.path = Set(request.path); - active_model.enabled = Set(request.enabled); - _ActiveModel::Collection(active_model) - }), - "favorites" => favorite::Entity::find_by_id(id).one(db.as_ref()).await?.map(|model| { - let mut active_model: favorite::ActiveModel = model.into(); - active_model.path = Set(request.path); - active_model.enabled = Set(request.enabled); - _ActiveModel::Favorite(active_model) - }), - "submissions" => submission::Entity::find_by_id(id).one(db.as_ref()).await?.map(|model| { - let mut active_model: submission::ActiveModel = model.into(); - active_model.path = Set(request.path); - active_model.enabled = Set(request.enabled); - _ActiveModel::Submission(active_model) - }), - "watch_later" => match watch_later::Entity::find_by_id(id).one(db.as_ref()).await? { - // 稍后再看需要做特殊处理,get 时如果稍后再看不存在返回的是 id 为 1 的假记录 - // 因此此处可能是更新也可能是插入,做个额外的处理 - Some(model) => { - // 如果有记录,使用 id 对应的记录更新 - let mut active_model: watch_later::ActiveModel = model.into(); - active_model.path = Set(request.path); - active_model.enabled = Set(request.enabled); - Some(_ActiveModel::WatchLater(active_model)) - } - None => { - if id != 1 { - None - } else { - // 如果没有记录且 id 为 1,插入一个新的稍后再看记录 - Some(_ActiveModel::WatchLater(watch_later::ActiveModel { - id: Set(1), - path: Set(request.path), - enabled: Set(request.enabled), - ..Default::default() - })) - } - } - }, - _ => return Err(InnerApiError::BadRequest("Invalid video source type".to_string()).into()), - }; - let Some(active_model) = active_model else { - return Err(InnerApiError::NotFound(id).into()); - }; - active_model.save(db.as_ref()).await?; - Ok(ApiResponse::ok(true)) -} - -pub async fn get_config() -> Result>, ApiError> { - Ok(ApiResponse::ok(VersionedConfig::get().load_full())) -} - -pub async fn update_config( - Extension(db): Extension>, - ValidatedJson(config): ValidatedJson, -) -> Result>, ApiError> { - let Ok(_lock) = DOWNLOADER_TASK_RUNNING.try_lock() else { - // 简单避免一下可能的不一致现象 - return Err(InnerApiError::BadRequest("下载任务正在运行,无法修改配置".to_string()).into()); - }; - config.check()?; - let new_config = VersionedConfig::get().update(config, db.as_ref()).await?; - drop(_lock); - Ok(ApiResponse::ok(new_config)) -} - -/// B 站的图片会检查 referer,需要做个转发伪造一下,否则直接返回 403 -pub async fn image_proxy( - Extension(bili_client): Extension>, - Query(params): Query, -) -> Response { - let resp = bili_client.client.request(Method::GET, ¶ms.url, None).send().await; - let whitelist = [ - header::CONTENT_TYPE, - header::CONTENT_LENGTH, - header::CACHE_CONTROL, - header::EXPIRES, - header::LAST_MODIFIED, - header::ETAG, - header::CONTENT_DISPOSITION, - header::CONTENT_ENCODING, - header::ACCEPT_RANGES, - header::ACCESS_CONTROL_ALLOW_ORIGIN, - ] - .into_iter() - .collect::>(); - - let builder = Response::builder(); - - let response = match resp { - Err(e) => builder.status(StatusCode::BAD_GATEWAY).body(Body::new(e.to_string())), - Ok(res) => { - let mut response = builder.status(res.status()); - for (k, v) in res.headers() { - if whitelist.contains(k) { - response = response.header(k, v); - } - } - let streams = res.bytes_stream(); - response.body(Body::from_stream(streams)) - } - }; - //safety: all previously configured headers are taken from a valid response, ensuring the response is safe to use - response.unwrap() -} diff --git a/crates/bili_sync/src/api/mod.rs b/crates/bili_sync/src/api/mod.rs index feb2726..f847b83 100644 --- a/crates/bili_sync/src/api/mod.rs +++ b/crates/bili_sync/src/api/mod.rs @@ -1,8 +1,8 @@ -pub mod auth; -pub mod handler; - mod error; mod helper; mod request; mod response; +mod routes; mod wrapper; + +pub use routes::router; diff --git a/crates/bili_sync/src/api/routes/config/mod.rs b/crates/bili_sync/src/api/routes/config/mod.rs new file mode 100644 index 0000000..cebbd4a --- /dev/null +++ b/crates/bili_sync/src/api/routes/config/mod.rs @@ -0,0 +1,36 @@ +use std::sync::Arc; + +use anyhow::Result; +use axum::Router; +use axum::extract::Extension; +use axum::routing::get; +use sea_orm::DatabaseConnection; + +use crate::api::error::InnerApiError; +use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson}; +use crate::config::{Config, VersionedConfig}; +use crate::task::DOWNLOADER_TASK_RUNNING; + +pub(super) fn router() -> Router { + Router::new().route("/config", get(get_config).put(update_config)) +} + +/// 获取全局配置 +pub async fn get_config() -> Result>, ApiError> { + Ok(ApiResponse::ok(VersionedConfig::get().load_full())) +} + +/// 更新全局配置 +pub async fn update_config( + Extension(db): Extension>, + ValidatedJson(config): ValidatedJson, +) -> Result>, ApiError> { + let Ok(_lock) = DOWNLOADER_TASK_RUNNING.try_lock() else { + // 简单避免一下可能的不一致现象 + return Err(InnerApiError::BadRequest("下载任务正在运行,无法修改配置".to_string()).into()); + }; + config.check()?; + let new_config = VersionedConfig::get().update(config, db.as_ref()).await?; + drop(_lock); + Ok(ApiResponse::ok(new_config)) +} diff --git a/crates/bili_sync/src/api/routes/me/mod.rs b/crates/bili_sync/src/api/routes/me/mod.rs new file mode 100644 index 0000000..6beb9a5 --- /dev/null +++ b/crates/bili_sync/src/api/routes/me/mod.rs @@ -0,0 +1,146 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use anyhow::Result; +use axum::Router; +use axum::extract::{Extension, Query}; +use axum::routing::get; +use bili_sync_entity::*; +use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QuerySelect}; + +use crate::api::request::{FollowedCollectionsRequest, FollowedUppersRequest}; +use crate::api::response::{ + CollectionWithSubscriptionStatus, CollectionsResponse, FavoriteWithSubscriptionStatus, FavoritesResponse, + UpperWithSubscriptionStatus, UppersResponse, +}; +use crate::api::wrapper::{ApiError, ApiResponse}; +use crate::bilibili::{BiliClient, Me}; + +pub(super) fn router() -> Router { + Router::new() + .route("/me/favorites", get(get_created_favorites)) + .route("/me/collections", get(get_followed_collections)) + .route("/me/uppers", get(get_followed_uppers)) +} + +/// 获取当前用户创建的收藏夹 +pub async fn get_created_favorites( + Extension(db): Extension>, + Extension(bili_client): Extension>, +) -> Result, ApiError> { + let me = Me::new(bili_client.as_ref()); + let bili_favorites = me.get_created_favorites().await?; + + let favorites = if let Some(bili_favorites) = bili_favorites { + // b 站收藏夹相关接口使用的所谓 “fid” 其实是该处的 id,即 fid + mid 后两位 + let bili_fids: Vec<_> = bili_favorites.iter().map(|fav| fav.id).collect(); + + let subscribed_fids: Vec = favorite::Entity::find() + .select_only() + .column(favorite::Column::FId) + .filter(favorite::Column::FId.is_in(bili_fids)) + .into_tuple() + .all(db.as_ref()) + .await?; + let subscribed_set: HashSet = subscribed_fids.into_iter().collect(); + + bili_favorites + .into_iter() + .map(|fav| FavoriteWithSubscriptionStatus { + title: fav.title, + media_count: fav.media_count, + // api 返回的 id 才是真实的 fid + fid: fav.id, + mid: fav.mid, + subscribed: subscribed_set.contains(&fav.id), + }) + .collect() + } else { + vec![] + }; + + Ok(ApiResponse::ok(FavoritesResponse { favorites })) +} + +/// 获取当前用户收藏的合集 +pub async fn get_followed_collections( + Extension(db): Extension>, + Extension(bili_client): Extension>, + Query(params): Query, +) -> Result, ApiError> { + let me = Me::new(bili_client.as_ref()); + let (page_num, page_size) = (params.page_num.unwrap_or(1), params.page_size.unwrap_or(50)); + let bili_collections = me.get_followed_collections(page_num, page_size).await?; + + let collections = if let Some(collection_list) = bili_collections.list { + let bili_sids: Vec<_> = collection_list.iter().map(|col| col.id).collect(); + + let subscribed_ids: Vec = collection::Entity::find() + .select_only() + .column(collection::Column::SId) + .filter(collection::Column::SId.is_in(bili_sids)) + .into_tuple() + .all(db.as_ref()) + .await?; + let subscribed_set: HashSet = subscribed_ids.into_iter().collect(); + + collection_list + .into_iter() + .map(|col| CollectionWithSubscriptionStatus { + title: col.title, + sid: col.id, + mid: col.mid, + invalid: col.state == 1, + subscribed: subscribed_set.contains(&col.id), + }) + .collect() + } else { + vec![] + }; + + Ok(ApiResponse::ok(CollectionsResponse { + collections, + total: bili_collections.count, + })) +} + +/// 获取当前用户关注的 UP 主 +pub async fn get_followed_uppers( + Extension(db): Extension>, + Extension(bili_client): Extension>, + Query(params): Query, +) -> Result, ApiError> { + let me = Me::new(bili_client.as_ref()); + let (page_num, page_size) = (params.page_num.unwrap_or(1), params.page_size.unwrap_or(20)); + let bili_uppers = me.get_followed_uppers(page_num, page_size).await?; + + let bili_uid: Vec<_> = bili_uppers.list.iter().map(|upper| upper.mid).collect(); + + let subscribed_ids: Vec = submission::Entity::find() + .select_only() + .column(submission::Column::UpperId) + .filter(submission::Column::UpperId.is_in(bili_uid)) + .into_tuple() + .all(db.as_ref()) + .await?; + let subscribed_set: HashSet = subscribed_ids.into_iter().collect(); + + let uppers = bili_uppers + .list + .into_iter() + .map(|upper| UpperWithSubscriptionStatus { + mid: upper.mid, + // 官方没有提供字段,但是可以使用这种方式简单判断下 + invalid: upper.uname == "账号已注销" && upper.face == "https://i0.hdslb.com/bfs/face/member/noface.jpg", + uname: upper.uname, + face: upper.face, + sign: upper.sign, + subscribed: subscribed_set.contains(&upper.mid), + }) + .collect(); + + Ok(ApiResponse::ok(UppersResponse { + uppers, + total: bili_uppers.total, + })) +} diff --git a/crates/bili_sync/src/api/routes/mod.rs b/crates/bili_sync/src/api/routes/mod.rs new file mode 100644 index 0000000..0d8f133 --- /dev/null +++ b/crates/bili_sync/src/api/routes/mod.rs @@ -0,0 +1,83 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::{Extension, Query, Request}; +use axum::http::HeaderMap; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use axum::{Router, middleware}; +use reqwest::{Method, StatusCode, header}; + +use super::request::ImageProxyParams; +use crate::api::wrapper::ApiResponse; +use crate::bilibili::BiliClient; +use crate::config::VersionedConfig; + +mod config; +mod me; +mod video_sources; +mod videos; + +pub fn router() -> Router { + Router::new().route("/image-proxy", get(image_proxy)).nest( + "/api", + config::router() + .merge(me::router()) + .merge(video_sources::router()) + .merge(videos::router()) + .layer(middleware::from_fn(auth)), + ) +} + +/// 中间件:验证请求头中的 Authorization 是否与配置中的 auth_token 匹配 +pub async fn auth(headers: HeaderMap, request: Request, next: Next) -> Result { + if headers + .get("Authorization") + .is_some_and(|v| v.to_str().is_ok_and(|s| s == VersionedConfig::get().load().auth_token)) + { + return Ok(next.run(request).await); + } + Ok(ApiResponse::<()>::unauthorized("auth token does not match").into_response()) +} + +/// B 站的图片会检查 referer,需要做个转发伪造一下,否则直接返回 403 +pub async fn image_proxy( + Extension(bili_client): Extension>, + Query(params): Query, +) -> Response { + let resp = bili_client.client.request(Method::GET, ¶ms.url, None).send().await; + let whitelist = [ + header::CONTENT_TYPE, + header::CONTENT_LENGTH, + header::CACHE_CONTROL, + header::EXPIRES, + header::LAST_MODIFIED, + header::ETAG, + header::CONTENT_DISPOSITION, + header::CONTENT_ENCODING, + header::ACCEPT_RANGES, + header::ACCESS_CONTROL_ALLOW_ORIGIN, + ] + .into_iter() + .collect::>(); + + let builder = Response::builder(); + + let response = match resp { + Err(e) => builder.status(StatusCode::BAD_GATEWAY).body(Body::new(e.to_string())), + Ok(res) => { + let mut response = builder.status(res.status()); + for (k, v) in res.headers() { + if whitelist.contains(k) { + response = response.header(k, v); + } + } + let streams = res.bytes_stream(); + response.body(Body::from_stream(streams)) + } + }; + //safety: all previously configured headers are taken from a valid response, ensuring the response is safe to use + response.unwrap() +} diff --git a/crates/bili_sync/src/api/routes/video_sources/mod.rs b/crates/bili_sync/src/api/routes/video_sources/mod.rs new file mode 100644 index 0000000..02e6539 --- /dev/null +++ b/crates/bili_sync/src/api/routes/video_sources/mod.rs @@ -0,0 +1,257 @@ +use std::sync::Arc; + +use anyhow::Result; +use axum::Router; +use axum::extract::{Extension, Path}; +use axum::routing::{get, post, put}; +use bili_sync_entity::*; +use bili_sync_migration::Expr; +use sea_orm::ActiveValue::Set; +use sea_orm::{DatabaseConnection, EntityTrait, QuerySelect}; + +use crate::adapter::_ActiveModel; +use crate::api::error::InnerApiError; +use crate::api::request::{ + InsertCollectionRequest, InsertFavoriteRequest, InsertSubmissionRequest, UpdateVideoSourceRequest, +}; +use crate::api::response::{VideoSource, VideoSourceDetail, VideoSourcesDetailsResponse, VideoSourcesResponse}; +use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson}; +use crate::bilibili::{BiliClient, Collection, CollectionItem, FavoriteList, Submission}; + +pub(super) fn router() -> Router { + Router::new() + .route("/video-sources", get(get_video_sources)) + .route("/video-sources/details", get(get_video_sources_details)) + .route("/video-sources/{type}/{id}", put(update_video_source)) + .route("/video-sources/favorites", post(insert_favorite)) + .route("/video-sources/collections", post(insert_collection)) + .route("/video-sources/submissions", post(insert_submission)) +} + +/// 列出所有视频来源 +pub async fn get_video_sources( + Extension(db): Extension>, +) -> Result, ApiError> { + let (collection, favorite, submission, mut 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()) + )?; + // watch_later 是一个特殊的视频来源,如果不存在则添加一个默认项 + if watch_later.is_empty() { + watch_later.push(VideoSource { + id: 1, + name: "稍后再看".to_string(), + }); + } + Ok(ApiResponse::ok(VideoSourcesResponse { + collection, + favorite, + submission, + watch_later, + })) +} + +/// 获取视频来源详情 +pub async fn get_video_sources_details( + Extension(db): Extension>, +) -> Result, ApiError> { + let (collections, favorites, submissions, mut watch_later) = tokio::try_join!( + collection::Entity::find() + .select_only() + .columns([ + collection::Column::Id, + collection::Column::Name, + collection::Column::Path, + collection::Column::Enabled + ]) + .into_model::() + .all(db.as_ref()), + favorite::Entity::find() + .select_only() + .columns([ + favorite::Column::Id, + favorite::Column::Name, + favorite::Column::Path, + favorite::Column::Enabled + ]) + .into_model::() + .all(db.as_ref()), + submission::Entity::find() + .select_only() + .column(submission::Column::Id) + .column_as(submission::Column::UpperName, "name") + .columns([submission::Column::Path, submission::Column::Enabled]) + .into_model::() + .all(db.as_ref()), + watch_later::Entity::find() + .select_only() + .column(watch_later::Column::Id) + .column_as(Expr::value("稍后再看"), "name") + .columns([watch_later::Column::Path, watch_later::Column::Enabled]) + .into_model::() + .all(db.as_ref()) + )?; + if watch_later.is_empty() { + watch_later.push(VideoSourceDetail { + id: 1, + name: "稍后再看".to_string(), + path: String::new(), + enabled: false, + }) + } + Ok(ApiResponse::ok(VideoSourcesDetailsResponse { + collections, + favorites, + submissions, + watch_later, + })) +} + +/// 更新视频来源 +pub async fn update_video_source( + Path((source_type, id)): Path<(String, i32)>, + Extension(db): Extension>, + ValidatedJson(request): ValidatedJson, +) -> Result, ApiError> { + let active_model = match source_type.as_str() { + "collections" => collection::Entity::find_by_id(id).one(db.as_ref()).await?.map(|model| { + let mut active_model: collection::ActiveModel = model.into(); + active_model.path = Set(request.path); + active_model.enabled = Set(request.enabled); + _ActiveModel::Collection(active_model) + }), + "favorites" => favorite::Entity::find_by_id(id).one(db.as_ref()).await?.map(|model| { + let mut active_model: favorite::ActiveModel = model.into(); + active_model.path = Set(request.path); + active_model.enabled = Set(request.enabled); + _ActiveModel::Favorite(active_model) + }), + "submissions" => submission::Entity::find_by_id(id).one(db.as_ref()).await?.map(|model| { + let mut active_model: submission::ActiveModel = model.into(); + active_model.path = Set(request.path); + active_model.enabled = Set(request.enabled); + _ActiveModel::Submission(active_model) + }), + "watch_later" => match watch_later::Entity::find_by_id(id).one(db.as_ref()).await? { + // 稍后再看需要做特殊处理,get 时如果稍后再看不存在返回的是 id 为 1 的假记录 + // 因此此处可能是更新也可能是插入,做个额外的处理 + Some(model) => { + // 如果有记录,使用 id 对应的记录更新 + let mut active_model: watch_later::ActiveModel = model.into(); + active_model.path = Set(request.path); + active_model.enabled = Set(request.enabled); + Some(_ActiveModel::WatchLater(active_model)) + } + None => { + if id != 1 { + None + } else { + // 如果没有记录且 id 为 1,插入一个新的稍后再看记录 + Some(_ActiveModel::WatchLater(watch_later::ActiveModel { + id: Set(1), + path: Set(request.path), + enabled: Set(request.enabled), + ..Default::default() + })) + } + } + }, + _ => return Err(InnerApiError::BadRequest("Invalid video source type".to_string()).into()), + }; + let Some(active_model) = active_model else { + return Err(InnerApiError::NotFound(id).into()); + }; + active_model.save(db.as_ref()).await?; + Ok(ApiResponse::ok(true)) +} + +/// 新增收藏夹订阅 +pub async fn insert_favorite( + Extension(db): Extension>, + Extension(bili_client): Extension>, + ValidatedJson(request): ValidatedJson, +) -> Result, ApiError> { + let favorite = FavoriteList::new(bili_client.as_ref(), request.fid.to_string()); + let favorite_info = favorite.get_info().await?; + favorite::Entity::insert(favorite::ActiveModel { + f_id: Set(favorite_info.id), + name: Set(favorite_info.title.clone()), + path: Set(request.path), + ..Default::default() + }) + .exec(db.as_ref()) + .await?; + + Ok(ApiResponse::ok(true)) +} + +/// 新增合集/列表订阅 +pub async fn insert_collection( + Extension(db): Extension>, + Extension(bili_client): Extension>, + ValidatedJson(request): ValidatedJson, +) -> Result, ApiError> { + let collection = Collection::new( + bili_client.as_ref(), + CollectionItem { + sid: request.sid.to_string(), + mid: request.mid.to_string(), + collection_type: request.collection_type, + }, + ); + let collection_info = collection.get_info().await?; + + collection::Entity::insert(collection::ActiveModel { + s_id: Set(collection_info.sid), + m_id: Set(collection_info.mid), + r#type: Set(collection_info.collection_type.into()), + name: Set(collection_info.name.clone()), + path: Set(request.path), + ..Default::default() + }) + .exec(db.as_ref()) + .await?; + + Ok(ApiResponse::ok(true)) +} + +/// 新增投稿订阅 +pub async fn insert_submission( + Extension(db): Extension>, + Extension(bili_client): Extension>, + ValidatedJson(request): ValidatedJson, +) -> Result, ApiError> { + let submission = Submission::new(bili_client.as_ref(), request.upper_id.to_string()); + let upper = submission.get_info().await?; + + submission::Entity::insert(submission::ActiveModel { + upper_id: Set(upper.mid.parse()?), + upper_name: Set(upper.name), + path: Set(request.path), + ..Default::default() + }) + .exec(db.as_ref()) + .await?; + + Ok(ApiResponse::ok(true)) +} diff --git a/crates/bili_sync/src/api/routes/videos/mod.rs b/crates/bili_sync/src/api/routes/videos/mod.rs new file mode 100644 index 0000000..98dc1ae --- /dev/null +++ b/crates/bili_sync/src/api/routes/videos/mod.rs @@ -0,0 +1,263 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use anyhow::Result; +use axum::Router; +use axum::extract::{Extension, Path, Query}; +use axum::routing::{get, post}; +use bili_sync_entity::*; +use sea_orm::{ + ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, TransactionTrait, +}; + +use crate::api::error::InnerApiError; +use crate::api::helper::{update_page_download_status, update_video_download_status}; +use crate::api::request::{UpdateVideoStatusRequest, VideosRequest}; +use crate::api::response::{ + PageInfo, ResetAllVideosResponse, ResetVideoResponse, UpdateVideoStatusResponse, VideoInfo, VideoResponse, + VideosResponse, +}; +use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson}; +use crate::utils::status::{PageStatus, VideoStatus}; + +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}/update-status", post(update_video_status)) +} + +/// 列出视频的基本信息,支持根据视频来源筛选、名称查找和分页 +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, + })) +} + +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, + })) +} + +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, + })) +} + +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 has_video_updates = !resetted_videos_info.is_empty(); + let has_page_updates = !resetted_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, &resetted_videos_info, Some(500)).await?; + } + if has_page_updates { + update_page_download_status(&txn, &resetted_pages_info, Some(500)).await?; + } + txn.commit().await?; + } + Ok(ApiResponse::ok(ResetAllVideosResponse { + resetted: has_video_updates || has_page_updates, + resetted_videos_count: resetted_videos_info.len(), + resetted_pages_count: resetted_pages_info.len(), + })) +} + +pub async fn update_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(UpdateVideoStatusResponse { + success: has_video_updates || has_page_updates, + video: video_info, + pages: pages_info, + })) +} diff --git a/crates/bili_sync/src/task/http_server.rs b/crates/bili_sync/src/task/http_server.rs index d175603..a1ed144 100644 --- a/crates/bili_sync/src/task/http_server.rs +++ b/crates/bili_sync/src/task/http_server.rs @@ -5,13 +5,12 @@ use axum::extract::Request; use axum::http::{Uri, header}; use axum::response::IntoResponse; use axum::routing::get; -use axum::{Extension, Router, ServiceExt, middleware}; +use axum::{Extension, ServiceExt}; use reqwest::StatusCode; use rust_embed_for_web::{EmbedableFile, RustEmbed}; use sea_orm::DatabaseConnection; -use crate::api::auth; -use crate::api::handler::api_router; +use crate::api::router; use crate::bilibili::BiliClient; use crate::config::VersionedConfig; @@ -22,12 +21,10 @@ use crate::config::VersionedConfig; struct Asset; pub async fn http_server(database_connection: Arc, bili_client: Arc) -> Result<()> { - let app = Router::new() - .merge(api_router()) + let app = router() .fallback_service(get(frontend_files)) .layer(Extension(database_connection)) - .layer(Extension(bili_client)) - .layer(middleware::from_fn(auth::auth)); + .layer(Extension(bili_client)); let config = VersionedConfig::get().load_full(); let listener = tokio::net::TcpListener::bind(&config.bind_address) .await