diff --git a/crates/bili_sync/src/api/handler.rs b/crates/bili_sync/src/api/handler.rs index fc9d070..bf19c7a 100644 --- a/crates/bili_sync/src/api/handler.rs +++ b/crates/bili_sync/src/api/handler.rs @@ -2,29 +2,45 @@ 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}; use bili_sync_entity::*; -use bili_sync_migration::Expr; +use bili_sync_migration::{Expr, OnConflict}; +use reqwest::{Method, StatusCode, header}; +use sea_orm::ActiveValue::Set; use sea_orm::{ ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, TransactionTrait, }; use utoipa::OpenApi; +use super::request::ImageProxyParams; 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::{UpdateVideoStatusRequest, VideosRequest}; +use crate::api::request::{ + FollowedCollectionsRequest, FollowedUppersRequest, UpdateVideoStatusRequest, UpsertCollectionRequest, + UpsertFavoriteRequest, UpsertSubmissionRequest, VideosRequest, +}; use crate::api::response::{ - PageInfo, ResetAllVideosResponse, ResetVideoResponse, UpdateVideoStatusResponse, VideoInfo, VideoResponse, - VideoSource, VideoSourcesResponse, VideosResponse, + CollectionWithSubscriptionStatus, CollectionsResponse, FavoriteWithSubscriptionStatus, FavoritesResponse, PageInfo, + ResetAllVideosResponse, ResetVideoResponse, UpdateVideoStatusResponse, UpperWithSubscriptionStatus, UppersResponse, + VideoInfo, VideoResponse, VideoSource, VideoSourcesResponse, VideosResponse, }; use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson}; +use crate::bilibili::{BiliClient, Collection, CollectionItem, FavoriteList, Me, Submission}; use crate::utils::status::{PageStatus, VideoStatus}; #[derive(OpenApi)] #[openapi( - paths(get_video_sources, get_videos, get_video, reset_video, reset_all_videos, update_video_status), + paths( + get_video_sources, get_videos, get_video, reset_video, reset_all_videos, update_video_status, + get_created_favorites, get_followed_collections, get_followed_uppers, + upsert_favorite, upsert_collection, upsert_submission + ), modifiers(&OpenAPIAuth), security( ("Token" = []), @@ -32,6 +48,23 @@ use crate::utils::status::{PageStatus, VideoStatus}; )] pub struct ApiDoc; +pub fn api_router() -> Router { + Router::new() + .route("/api/video-sources", get(get_video_sources)) + .route("/api/video-sources/collections", post(upsert_collection)) + .route("/api/video-sources/favorites", post(upsert_favorite)) + .route("/api/video-sources/submissions", post(upsert_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("/image-proxy", get(image_proxy)) +} + /// 列出所有视频来源 #[utoipa::path( get, @@ -287,7 +320,7 @@ pub async fn reset_all_videos( /// 更新特定视频及其所含分页的状态位 #[utoipa::path( post, - path = "/api/videos/{id}/update-status", + path = "/api/video/{id}/update-status", request_body = UpdateVideoStatusRequest, responses( (status = 200, body = ApiResponse), @@ -349,3 +382,299 @@ pub async fn update_video_status( pages: pages_info, })) } + +/// 获取当前用户创建的收藏夹列表,包含订阅状态 +#[utoipa::path( + get, + path = "/api/me/favorites", + responses( + (status = 200, body = ApiResponse), + ) +)] +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, + fid: fav.fid, + mid: fav.mid, + subscribed: subscribed_set.contains(&fav.id), + }) + .collect() + } else { + vec![] + }; + + Ok(ApiResponse::ok(FavoritesResponse { favorites })) +} + +/// 获取当前用户关注的合集列表,包含订阅状态 +#[utoipa::path( + get, + path = "/api/me/collections", + params( + FollowedCollectionsRequest, + ), + responses( + (status = 200, body = ApiResponse), + ) +)] +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 + .iter() + .map(|col| CollectionWithSubscriptionStatus { + id: col.id, + mid: col.mid, + state: col.state, + title: col.title.clone(), + subscribed: subscribed_set.contains(&col.id), + }) + .collect() + } else { + vec![] + }; + + Ok(ApiResponse::ok(CollectionsResponse { + collections, + total: bili_collections.count, + })) +} + +#[utoipa::path( + get, + path = "/api/me/uppers", + params( + FollowedUppersRequest, + ), + responses( + (status = 200, body = ApiResponse), + ) +)] +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, + 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, + })) +} + +#[utoipa::path( + post, + path = "/api/video-sources/favorites", + request_body = UpsertFavoriteRequest, + responses( + (status = 200, body = ApiResponse), + ) +)] +pub async fn upsert_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() + }) + .on_conflict( + OnConflict::column(favorite::Column::FId) + .update_columns([favorite::Column::Name, favorite::Column::Path]) + .to_owned(), + ) + .exec(db.as_ref()) + .await?; + + Ok(ApiResponse::ok(true)) +} + +#[utoipa::path( + post, + path = "/api/video-sources/collections", + request_body = UpsertCollectionRequest, + responses( + (status = 200, body = ApiResponse), + ) +)] +pub async fn upsert_collection( + Extension(db): Extension>, + Extension(bili_client): Extension>, + ValidatedJson(request): ValidatedJson, +) -> Result, ApiError> { + let collection_item = CollectionItem { + sid: request.id.to_string(), + mid: request.mid.to_string(), + collection_type: request.collection_type, + }; + + let collection = Collection::new(bili_client.as_ref(), &collection_item); + 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() + }) + .on_conflict( + OnConflict::columns([ + collection::Column::SId, + collection::Column::MId, + collection::Column::Type, + ]) + .update_columns([collection::Column::Name, collection::Column::Path]) + .to_owned(), + ) + .exec(db.as_ref()) + .await?; + + Ok(ApiResponse::ok(true)) +} + +/// 订阅UP主投稿 +#[utoipa::path( + post, + path = "/api/video-sources/submissions", + request_body = UpsertSubmissionRequest, + responses( + (status = 200, body = ApiResponse), + ) +)] +pub async fn upsert_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() + }) + .on_conflict( + OnConflict::column(submission::Column::UpperId) + .update_columns([submission::Column::UpperName, submission::Column::Path]) + .to_owned(), + ) + .exec(db.as_ref()) + .await?; + + Ok(ApiResponse::ok(true)) +} + +/// 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/request.rs b/crates/bili_sync/src/api/request.rs index fed3faa..e511c97 100644 --- a/crates/bili_sync/src/api/request.rs +++ b/crates/bili_sync/src/api/request.rs @@ -1,6 +1,8 @@ use serde::Deserialize; use utoipa::{IntoParams, ToSchema}; use validator::Validate; + +use crate::bilibili::CollectionType; #[derive(Deserialize, IntoParams)] pub struct VideosRequest { pub collection: Option, @@ -36,3 +38,45 @@ pub struct UpdateVideoStatusRequest { #[validate(nested)] pub page_updates: Vec, } + +#[derive(Deserialize, IntoParams)] +pub struct FollowedCollectionsRequest { + pub page_num: Option, + pub page_size: Option, +} + +#[derive(Deserialize, IntoParams)] +pub struct FollowedUppersRequest { + pub page_num: Option, + pub page_size: Option, +} + +#[derive(Deserialize, ToSchema, Validate)] +pub struct UpsertFavoriteRequest { + pub fid: i64, + #[validate(custom(function = "crate::utils::validation::validate_path"))] + pub path: String, +} + +#[derive(Deserialize, ToSchema, Validate)] +pub struct UpsertCollectionRequest { + pub id: i64, + pub mid: i64, + #[schema(value_type = i8)] + #[serde(default)] + pub collection_type: CollectionType, + #[validate(custom(function = "crate::utils::validation::validate_path"))] + pub path: String, +} + +#[derive(Deserialize, ToSchema, Validate)] +pub struct UpsertSubmissionRequest { + pub upper_id: i64, + #[validate(custom(function = "crate::utils::validation::validate_path"))] + pub path: String, +} + +#[derive(Deserialize, IntoParams)] +pub struct ImageProxyParams { + pub url: String, +} diff --git a/crates/bili_sync/src/api/response.rs b/crates/bili_sync/src/api/response.rs index 8440161..a5bf92a 100644 --- a/crates/bili_sync/src/api/response.rs +++ b/crates/bili_sync/src/api/response.rs @@ -90,3 +90,47 @@ where let status: [u32; 5] = PageStatus::from(*status).into(); status.serialize(serializer) } + +#[derive(Serialize, ToSchema)] +pub struct FavoriteWithSubscriptionStatus { + pub title: String, + pub media_count: i64, + pub fid: i64, + pub mid: i64, + pub subscribed: bool, +} + +#[derive(Serialize, ToSchema)] +pub struct CollectionWithSubscriptionStatus { + pub id: i64, + pub mid: i64, + pub state: i32, + pub title: String, + pub subscribed: bool, +} + +#[derive(Serialize, ToSchema)] +pub struct UpperWithSubscriptionStatus { + pub mid: i64, + pub uname: String, + pub face: String, + pub sign: String, + pub subscribed: bool, +} + +#[derive(Serialize, ToSchema)] +pub struct FavoritesResponse { + pub favorites: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct CollectionsResponse { + pub collections: Vec, + pub total: i64, +} + +#[derive(Serialize, ToSchema)] +pub struct UppersResponse { + pub uppers: Vec, + pub total: i64, +} diff --git a/crates/bili_sync/src/bilibili/collection.rs b/crates/bili_sync/src/bilibili/collection.rs index f0a78fd..0823eaa 100644 --- a/crates/bili_sync/src/bilibili/collection.rs +++ b/crates/bili_sync/src/bilibili/collection.rs @@ -10,9 +10,10 @@ use serde_json::Value; use crate::bilibili::credential::encoded_query; use crate::bilibili::{BiliClient, MIXIN_KEY, Validate, VideoInfo}; -#[derive(PartialEq, Eq, Hash, Clone, Debug)] +#[derive(PartialEq, Eq, Hash, Clone, Debug, Deserialize, Default)] pub enum CollectionType { Series, + #[default] Season, } diff --git a/crates/bili_sync/src/bilibili/me.rs b/crates/bili_sync/src/bilibili/me.rs index 852956e..5b63b9f 100644 --- a/crates/bili_sync/src/bilibili/me.rs +++ b/crates/bili_sync/src/bilibili/me.rs @@ -33,15 +33,15 @@ impl<'a> Me<'a> { Ok(serde_json::from_value(resp["data"]["list"].take())?) } - pub async fn get_followed_collections(&self, page: i32) -> Result { + pub async fn get_followed_collections(&self, page_num: i32, page_size: i32) -> Result { let mut resp = self .client .request(Method::GET, "https://api.bilibili.com/x/v3/fav/folder/collected/list") .await .query(&[ - ("up_mid", self.mid.as_ref()), - ("pn", page.to_string().as_ref()), - ("ps", "20"), + ("up_mid", self.mid.as_str()), + ("pn", page_num.to_string().as_str()), + ("ps", page_size.to_string().as_str()), ("platform", "web"), ]) .send() @@ -53,15 +53,15 @@ impl<'a> Me<'a> { Ok(serde_json::from_value(resp["data"].take())?) } - pub async fn get_followed_uppers(&self, page: i32) -> Result { + pub async fn get_followed_uppers(&self, page_num: i32, page_size: i32) -> Result { let mut resp = self .client .request(Method::GET, "https://api.bilibili.com/x/relation/followings") .await .query(&[ - ("vmid", self.mid.as_ref()), - ("pn", page.to_string().as_ref()), - ("ps", "20"), + ("vmid", self.mid.as_str()), + ("pn", page_num.to_string().as_str()), + ("ps", page_size.to_string().as_str()), ]) .send() .await? @@ -86,6 +86,7 @@ impl<'a> Me<'a> { pub struct FavoriteItem { pub title: String, pub media_count: i64, + pub id: i64, pub fid: i64, pub mid: i64, } diff --git a/crates/bili_sync/src/bilibili/mod.rs b/crates/bili_sync/src/bilibili/mod.rs index eb9cf5c..fcf4512 100644 --- a/crates/bili_sync/src/bilibili/mod.rs +++ b/crates/bili_sync/src/bilibili/mod.rs @@ -12,6 +12,7 @@ pub use danmaku::DanmakuOption; pub use error::BiliError; pub use favorite_list::FavoriteList; use favorite_list::Upper; +pub use me::Me; use once_cell::sync::Lazy; pub use submission::Submission; pub use video::{Dimension, PageInfo, Video}; diff --git a/crates/bili_sync/src/main.rs b/crates/bili_sync/src/main.rs index 80d561b..668b526 100644 --- a/crates/bili_sync/src/main.rs +++ b/crates/bili_sync/src/main.rs @@ -16,6 +16,7 @@ use std::fmt::Debug; use std::future::Future; use std::sync::Arc; +use bilibili::BiliClient; use once_cell::sync::Lazy; use task::{http_server, video_downloader}; use tokio_util::sync::CancellationToken; @@ -29,12 +30,26 @@ use crate::utils::signal::terminate; #[tokio::main] async fn main() { init(); + let bili_client = Arc::new(BiliClient::new()); let connection = Arc::new(setup_database().await); + let token = CancellationToken::new(); let tracker = TaskTracker::new(); - spawn_task("HTTP 服务", http_server(connection.clone()), &tracker, token.clone()); - spawn_task("定时下载", video_downloader(connection), &tracker, token.clone()); + spawn_task( + "HTTP 服务", + http_server(connection.clone(), bili_client.clone()), + &tracker, + token.clone(), + ); + if !cfg!(debug_assertions) { + spawn_task( + "定时下载", + video_downloader(connection, bili_client), + &tracker, + token.clone(), + ); + } tracker.close(); handle_shutdown(tracker, token).await diff --git a/crates/bili_sync/src/task/http_server.rs b/crates/bili_sync/src/task/http_server.rs index ccfd72f..e1308f4 100644 --- a/crates/bili_sync/src/task/http_server.rs +++ b/crates/bili_sync/src/task/http_server.rs @@ -5,7 +5,7 @@ use axum::body::Body; use axum::extract::Request; use axum::http::{Response, Uri, header}; use axum::response::IntoResponse; -use axum::routing::{get, post}; +use axum::routing::get; use axum::{Extension, Router, ServiceExt, middleware}; use reqwest::StatusCode; use rust_embed_for_web::{EmbedableFile, RustEmbed}; @@ -14,9 +14,8 @@ use utoipa::OpenApi; use utoipa_swagger_ui::{Config, SwaggerUi}; use crate::api::auth; -use crate::api::handler::{ - ApiDoc, get_video, get_video_sources, get_videos, reset_all_videos, reset_video, update_video_status, -}; +use crate::api::handler::{ApiDoc, api_router}; +use crate::bilibili::BiliClient; use crate::config::CONFIG; #[derive(RustEmbed)] @@ -25,14 +24,9 @@ use crate::config::CONFIG; #[folder = "../../web/build"] struct Asset; -pub async fn http_server(database_connection: Arc) -> Result<()> { +pub async fn http_server(database_connection: Arc, bili_client: Arc) -> Result<()> { let app = Router::new() - .route("/api/video-sources", get(get_video_sources)) - .route("/api/videos", get(get_videos)) - .route("/api/videos/{id}", get(get_video)) - .route("/api/videos/{id}/reset", post(reset_video)) - .route("/api/videos/{id}/update-status", post(update_video_status)) - .route("/api/videos/reset-all", post(reset_all_videos)) + .merge(api_router()) .merge( SwaggerUi::new("/swagger-ui/") .url("/api-docs/openapi.json", ApiDoc::openapi()) @@ -45,6 +39,7 @@ pub async fn http_server(database_connection: Arc) -> Result ) .fallback_service(get(frontend_files)) .layer(Extension(database_connection)) + .layer(Extension(bili_client)) .layer(middleware::from_fn(auth::auth)); let listener = tokio::net::TcpListener::bind(&CONFIG.bind_address) .await diff --git a/crates/bili_sync/src/task/video_downloader.rs b/crates/bili_sync/src/task/video_downloader.rs index 81976c4..dd0bb00 100644 --- a/crates/bili_sync/src/task/video_downloader.rs +++ b/crates/bili_sync/src/task/video_downloader.rs @@ -8,9 +8,8 @@ use crate::config::CONFIG; use crate::workflow::process_video_source; /// 启动周期下载视频的任务 -pub async fn video_downloader(connection: Arc) { +pub async fn video_downloader(connection: Arc, bili_client: Arc) { let mut anchor = chrono::Local::now().date_naive(); - let bili_client = BiliClient::new(); let video_sources = CONFIG.as_video_sources(); loop { info!("开始执行本轮视频下载任务.."); diff --git a/crates/bili_sync/src/utils/validation.rs b/crates/bili_sync/src/utils/validation.rs index 4bb8bb7..c7597b7 100644 --- a/crates/bili_sync/src/utils/validation.rs +++ b/crates/bili_sync/src/utils/validation.rs @@ -1,3 +1,5 @@ +use std::path::Path; + use validator::ValidationError; use crate::utils::status::{STATUS_NOT_STARTED, STATUS_OK}; @@ -11,3 +13,11 @@ pub fn validate_status_value(value: u32) -> Result<(), ValidationError> { )) } } + +pub fn validate_path(path: &str) -> Result<(), ValidationError> { + if path.is_empty() || !Path::new(path).is_absolute() { + Err(ValidationError::new("path must be a non-empty absolute path")) + } else { + Ok(()) + } +} diff --git a/web/src/app.css b/web/src/app.css index b37c9b3..754787e 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -4,6 +4,10 @@ @custom-variant dark (&:is(.dark *)); +html { + scroll-behavior: smooth !important; +} + :root { --radius: 0.625rem; --background: oklch(1 0 0); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 5907143..12aeeec 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -8,7 +8,13 @@ import type { ResetAllVideosResponse, UpdateVideoStatusRequest, UpdateVideoStatusResponse, - ApiError + ApiError, + FavoritesResponse, + CollectionsResponse, + UppersResponse, + UpsertFavoriteRequest, + UpsertCollectionRequest, + UpsertSubmissionRequest } from './types'; // API 基础配置 @@ -168,6 +174,67 @@ class ApiClient { ): Promise> { return this.post(`/videos/${id}/update-status`, request); } + + /** + * 获取我的收藏夹 + */ + async getCreatedFavorites(): Promise> { + return this.get('/me/favorites'); + } + + /** + * 获取关注的合集 + * @param page 页码 + */ + async getFollowedCollections( + pageNum?: number, + pageSize?: number + ): Promise> { + const params = { + page_num: pageNum, + page_size: pageSize + }; + return this.get('/me/collections', params); + } + + /** + * 获取关注的UP主 + * @param page 页码 + */ + async getFollowedUppers( + pageNum?: number, + pageSize?: number + ): Promise> { + const params = { + page_num: pageNum, + page_size: pageSize + }; + return this.get('/me/uppers', params); + } + + /** + * 订阅收藏夹 + * @param request 订阅请求参数 + */ + async upsertFavorite(request: UpsertFavoriteRequest): Promise> { + return this.post('/video-sources/favorites', request); + } + + /** + * 订阅合集 + * @param request 订阅请求参数 + */ + async upsertCollection(request: UpsertCollectionRequest): Promise> { + return this.post('/video-sources/collections', request); + } + + /** + * 订阅UP主投稿 + * @param request 订阅请求参数 + */ + async upsertSubmission(request: UpsertSubmissionRequest): Promise> { + return this.post('/video-sources/submissions', request); + } } // 创建默认的 API 客户端实例 @@ -206,6 +273,38 @@ export const api = { updateVideoStatus: (id: number, request: UpdateVideoStatusRequest) => apiClient.updateVideoStatus(id, request), + /** + * 获取我的收藏夹 + */ + getCreatedFavorites: () => apiClient.getCreatedFavorites(), + + /** + * 获取关注的合集 + */ + getFollowedCollections: (pageNum?: number, pageSize?: number) => + apiClient.getFollowedCollections(pageNum, pageSize), + + /** + * 获取关注的UP主 + */ + getFollowedUppers: (pageNum?: number, pageSize?: number) => + apiClient.getFollowedUppers(pageNum, pageSize), + + /** + * 订阅收藏夹 + */ + upsertFavorite: (request: UpsertFavoriteRequest) => apiClient.upsertFavorite(request), + + /** + * 订阅合集 + */ + upsertCollection: (request: UpsertCollectionRequest) => apiClient.upsertCollection(request), + + /** + * 订阅UP主投稿 + */ + upsertSubmission: (request: UpsertSubmissionRequest) => apiClient.upsertSubmission(request), + /** * 设置认证 token */ diff --git a/web/src/lib/components/app-sidebar.svelte b/web/src/lib/components/app-sidebar.svelte index 13115f2..b7eb187 100644 --- a/web/src/lib/components/app-sidebar.svelte +++ b/web/src/lib/components/app-sidebar.svelte @@ -1,6 +1,9 @@ + + + +
+
+ +
+ {#if avatarUrl && type === 'upper'} + {title} + {:else} + + {/if} +
+ + +
+ + {title} + + {#if subtitle} +
+ + {subtitle} +
+ {/if} + {#if description} +

+ {description} +

+ {/if} +
+
+ + +
+ {#if disabled} + 不可用 +
+ {disabledReason} +
+ {:else} + + {subscribed ? '已订阅' : typeLabel} + + {#if count !== null} +
+ {count} + {countLabel} +
+ {/if} + {/if} +
+
+
+ + +
+ {#if disabled} + + {:else if subscribed} + + {:else} + + {/if} +
+
+
+ + + diff --git a/web/src/lib/components/subscription-dialog.svelte b/web/src/lib/components/subscription-dialog.svelte new file mode 100644 index 0000000..04916de --- /dev/null +++ b/web/src/lib/components/subscription-dialog.svelte @@ -0,0 +1,242 @@ + + + + + + 订阅{typeLabel} + +
即将订阅{typeLabel}「{itemTitle}」
+
请手动编辑本地保存路径:
+
+
+ +
+
+ +
+
+
+ {typeLabel}名称: + {itemTitle} +
+ {#if type === 'favorite'} + {@const favorite = item as FavoriteWithSubscriptionStatus} +
+ 视频数量: + {favorite.media_count} 个 +
+ {/if} + {#if type === 'upper'} + {@const upper = item as UpperWithSubscriptionStatus} + {#if upper.sign} +
+ 个人简介: + {upper.sign} +
+ {/if} + {/if} +
+
+ + +
+ + +
+

路径将作为文件夹名称,用于存放下载的视频文件。

+
+

路径示例:

+
+
Mac/Linux: /home/downloads/我的收藏
+
Windows: C:\Downloads\我的收藏
+
+
+
+
+
+
+ + + + + +
+
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 6ac5b8b..6a49d80 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -102,3 +102,62 @@ export interface UpdateVideoStatusResponse { video: VideoInfo; pages: PageInfo[]; } + +// 收藏夹相关类型 +export interface FavoriteWithSubscriptionStatus { + title: string; + media_count: number; + id: number; + fid: number; + mid: number; + subscribed: boolean; +} + +export interface FavoritesResponse { + favorites: FavoriteWithSubscriptionStatus[]; +} + +// 合集相关类型 +export interface CollectionWithSubscriptionStatus { + id: number; + mid: number; + state: number; + title: string; + subscribed: boolean; +} + +export interface CollectionsResponse { + collections: CollectionWithSubscriptionStatus[]; + total: number; +} + +// UP主相关类型 +export interface UpperWithSubscriptionStatus { + mid: number; + uname: string; + face: string; + sign: string; + subscribed: boolean; +} + +export interface UppersResponse { + uppers: UpperWithSubscriptionStatus[]; + total: number; +} + +export interface UpsertFavoriteRequest { + fid: number; + name: string; + path: string; +} + +export interface UpsertCollectionRequest { + id: number; + mid: number; + path: string; +} + +export interface UpsertSubmissionRequest { + upper_id: number; + path: string; +} diff --git a/web/src/routes/+layout.ts b/web/src/routes/+layout.ts index 89da957..83addb7 100644 --- a/web/src/routes/+layout.ts +++ b/web/src/routes/+layout.ts @@ -1,2 +1,2 @@ export const ssr = false; -export const prerender = true; +export const prerender = false; diff --git a/web/src/routes/me/collections/+page.svelte b/web/src/routes/me/collections/+page.svelte new file mode 100644 index 0000000..847d695 --- /dev/null +++ b/web/src/routes/me/collections/+page.svelte @@ -0,0 +1,108 @@ + + + + 关注的合集 - Bili Sync + + +
+
+
+

关注的合集

+

管理您在B站关注的合集订阅

+
+
+ {#if !loading} + 共 {totalCount} 个合集 + {/if} +
+
+ + {#if loading} +
+
加载中...
+
+ {:else if collections.length > 0} +
+ {#each collections as collection (collection.id)} + + {/each} +
+ + + {#if totalPages > 1} + + {/if} + {:else} +
+
+

暂无合集数据

+

请先在B站关注一些合集,或检查账号配置

+
+
+ {/if} +
diff --git a/web/src/routes/me/favorites/+page.svelte b/web/src/routes/me/favorites/+page.svelte new file mode 100644 index 0000000..1888614 --- /dev/null +++ b/web/src/routes/me/favorites/+page.svelte @@ -0,0 +1,88 @@ + + + + 我的收藏夹 - Bili Sync + + +
+
+
+

我的收藏夹

+

管理您在B站创建的收藏夹订阅

+
+
+ {#if !loading} + 共 {favorites.length} 个收藏夹 + {/if} +
+
+ + {#if loading} +
+
加载中...
+
+ {:else if favorites.length > 0} +
+ {#each favorites as favorite (favorite.fid)} + + {/each} +
+ {:else} +
+
+

暂无收藏夹数据

+

请先在B站创建收藏夹,或检查账号配置

+
+
+ {/if} +
diff --git a/web/src/routes/me/uppers/+page.svelte b/web/src/routes/me/uppers/+page.svelte new file mode 100644 index 0000000..beeb5d8 --- /dev/null +++ b/web/src/routes/me/uppers/+page.svelte @@ -0,0 +1,106 @@ + + + + 关注的UP主 - Bili Sync + + +
+
+
+

关注的UP主

+

管理您在B站关注的UP主投稿订阅

+
+
+ {#if !loading} + 共 {totalCount} 个UP主 + {/if} +
+
+ + {#if loading} +
+
加载中...
+
+ {:else if uppers.length > 0} +
+ {#each uppers as upper (upper.mid)} + + {/each} +
+ + + {#if totalPages > 1} + + {/if} + {:else} +
+
+

暂无UP主数据

+

请先在B站关注一些UP主,或检查账号配置

+
+
+ {/if} +
diff --git a/web/src/routes/video/[id]/+page.svelte b/web/src/routes/video/[id]/+page.svelte index 9aec59d..3e13930 100644 --- a/web/src/routes/video/[id]/+page.svelte +++ b/web/src/routes/video/[id]/+page.svelte @@ -156,7 +156,7 @@ bind:resetting onReset={async () => { try { - const result = await api.resetVideo((videoData as VideoResponse).video.id); + const result = await api.resetVideo(videoData!.video.id); const data = result.data; if (data.resetted) { videoData = { diff --git a/web/src/routes/video/[id]/+page.ts b/web/src/routes/video/[id]/+page.ts deleted file mode 100644 index 83addb7..0000000 --- a/web/src/routes/video/[id]/+page.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const ssr = false; -export const prerender = false; diff --git a/web/vite.config.ts b/web/vite.config.ts index b9e51c2..525781f 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -6,7 +6,8 @@ export default defineConfig({ plugins: [tailwindcss(), sveltekit()], server: { proxy: { - '/api': 'http://localhost:12345' + '/api': 'http://localhost:12345', + '/image-proxy': 'http://localhost:12345' }, host: true }