mirror of
https://github.com/amtoaer/bili-sync.git
synced 2026-09-04 23:18:02 +08:00
feat: 支持 webui 加载用户的订阅与收藏,一键点击订阅 (#357)
This commit is contained in:
@@ -2,29 +2,45 @@ use std::collections::HashSet;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use axum::Router;
|
||||||
|
use axum::body::Body;
|
||||||
use axum::extract::{Extension, Path, Query};
|
use axum::extract::{Extension, Path, Query};
|
||||||
|
use axum::response::Response;
|
||||||
|
use axum::routing::{get, post};
|
||||||
use bili_sync_entity::*;
|
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::{
|
use sea_orm::{
|
||||||
ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect,
|
ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect,
|
||||||
TransactionTrait,
|
TransactionTrait,
|
||||||
};
|
};
|
||||||
use utoipa::OpenApi;
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
use super::request::ImageProxyParams;
|
||||||
use crate::api::auth::OpenAPIAuth;
|
use crate::api::auth::OpenAPIAuth;
|
||||||
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::{UpdateVideoStatusRequest, VideosRequest};
|
use crate::api::request::{
|
||||||
|
FollowedCollectionsRequest, FollowedUppersRequest, UpdateVideoStatusRequest, UpsertCollectionRequest,
|
||||||
|
UpsertFavoriteRequest, UpsertSubmissionRequest, VideosRequest,
|
||||||
|
};
|
||||||
use crate::api::response::{
|
use crate::api::response::{
|
||||||
PageInfo, ResetAllVideosResponse, ResetVideoResponse, UpdateVideoStatusResponse, VideoInfo, VideoResponse,
|
CollectionWithSubscriptionStatus, CollectionsResponse, FavoriteWithSubscriptionStatus, FavoritesResponse, PageInfo,
|
||||||
VideoSource, VideoSourcesResponse, VideosResponse,
|
ResetAllVideosResponse, ResetVideoResponse, UpdateVideoStatusResponse, UpperWithSubscriptionStatus, UppersResponse,
|
||||||
|
VideoInfo, VideoResponse, VideoSource, VideoSourcesResponse, VideosResponse,
|
||||||
};
|
};
|
||||||
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
|
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
|
||||||
|
use crate::bilibili::{BiliClient, Collection, CollectionItem, FavoriteList, Me, Submission};
|
||||||
use crate::utils::status::{PageStatus, VideoStatus};
|
use crate::utils::status::{PageStatus, VideoStatus};
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[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),
|
modifiers(&OpenAPIAuth),
|
||||||
security(
|
security(
|
||||||
("Token" = []),
|
("Token" = []),
|
||||||
@@ -32,6 +48,23 @@ use crate::utils::status::{PageStatus, VideoStatus};
|
|||||||
)]
|
)]
|
||||||
pub struct ApiDoc;
|
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(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
@@ -287,7 +320,7 @@ pub async fn reset_all_videos(
|
|||||||
/// 更新特定视频及其所含分页的状态位
|
/// 更新特定视频及其所含分页的状态位
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/videos/{id}/update-status",
|
path = "/api/video/{id}/update-status",
|
||||||
request_body = UpdateVideoStatusRequest,
|
request_body = UpdateVideoStatusRequest,
|
||||||
responses(
|
responses(
|
||||||
(status = 200, body = ApiResponse<UpdateVideoStatusResponse>),
|
(status = 200, body = ApiResponse<UpdateVideoStatusResponse>),
|
||||||
@@ -349,3 +382,299 @@ pub async fn update_video_status(
|
|||||||
pages: pages_info,
|
pages: pages_info,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取当前用户创建的收藏夹列表,包含订阅状态
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/me/favorites",
|
||||||
|
responses(
|
||||||
|
(status = 200, body = ApiResponse<FavoritesResponse>),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn get_created_favorites(
|
||||||
|
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||||
|
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||||
|
) -> Result<ApiResponse<FavoritesResponse>, 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<i64> = 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<i64> = 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<CollectionsResponse>),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn get_followed_collections(
|
||||||
|
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||||
|
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||||
|
Query(params): Query<FollowedCollectionsRequest>,
|
||||||
|
) -> Result<ApiResponse<CollectionsResponse>, 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<i64> = 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<i64> = 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<UppersResponse>),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn get_followed_uppers(
|
||||||
|
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||||
|
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||||
|
Query(params): Query<FollowedUppersRequest>,
|
||||||
|
) -> Result<ApiResponse<UppersResponse>, 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<i64> = 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<i64> = 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<bool>),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn upsert_favorite(
|
||||||
|
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||||
|
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||||
|
ValidatedJson(request): ValidatedJson<UpsertFavoriteRequest>,
|
||||||
|
) -> Result<ApiResponse<bool>, 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<bool>),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn upsert_collection(
|
||||||
|
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||||
|
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||||
|
ValidatedJson(request): ValidatedJson<UpsertCollectionRequest>,
|
||||||
|
) -> Result<ApiResponse<bool>, 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<bool>),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn upsert_submission(
|
||||||
|
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||||
|
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||||
|
ValidatedJson(request): ValidatedJson<UpsertSubmissionRequest>,
|
||||||
|
) -> Result<ApiResponse<bool>, 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<Arc<BiliClient>>,
|
||||||
|
Query(params): Query<ImageProxyParams>,
|
||||||
|
) -> 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::<HashSet<_>>();
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use utoipa::{IntoParams, ToSchema};
|
use utoipa::{IntoParams, ToSchema};
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
|
use crate::bilibili::CollectionType;
|
||||||
#[derive(Deserialize, IntoParams)]
|
#[derive(Deserialize, IntoParams)]
|
||||||
pub struct VideosRequest {
|
pub struct VideosRequest {
|
||||||
pub collection: Option<i32>,
|
pub collection: Option<i32>,
|
||||||
@@ -36,3 +38,45 @@ pub struct UpdateVideoStatusRequest {
|
|||||||
#[validate(nested)]
|
#[validate(nested)]
|
||||||
pub page_updates: Vec<PageStatusUpdate>,
|
pub page_updates: Vec<PageStatusUpdate>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, IntoParams)]
|
||||||
|
pub struct FollowedCollectionsRequest {
|
||||||
|
pub page_num: Option<i32>,
|
||||||
|
pub page_size: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, IntoParams)]
|
||||||
|
pub struct FollowedUppersRequest {
|
||||||
|
pub page_num: Option<i32>,
|
||||||
|
pub page_size: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,3 +90,47 @@ where
|
|||||||
let status: [u32; 5] = PageStatus::from(*status).into();
|
let status: [u32; 5] = PageStatus::from(*status).into();
|
||||||
status.serialize(serializer)
|
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<FavoriteWithSubscriptionStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct CollectionsResponse {
|
||||||
|
pub collections: Vec<CollectionWithSubscriptionStatus>,
|
||||||
|
pub total: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct UppersResponse {
|
||||||
|
pub uppers: Vec<UpperWithSubscriptionStatus>,
|
||||||
|
pub total: i64,
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ use serde_json::Value;
|
|||||||
use crate::bilibili::credential::encoded_query;
|
use crate::bilibili::credential::encoded_query;
|
||||||
use crate::bilibili::{BiliClient, MIXIN_KEY, Validate, VideoInfo};
|
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 {
|
pub enum CollectionType {
|
||||||
Series,
|
Series,
|
||||||
|
#[default]
|
||||||
Season,
|
Season,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,15 +33,15 @@ impl<'a> Me<'a> {
|
|||||||
Ok(serde_json::from_value(resp["data"]["list"].take())?)
|
Ok(serde_json::from_value(resp["data"]["list"].take())?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_followed_collections(&self, page: i32) -> Result<Collections> {
|
pub async fn get_followed_collections(&self, page_num: i32, page_size: i32) -> Result<Collections> {
|
||||||
let mut resp = self
|
let mut resp = self
|
||||||
.client
|
.client
|
||||||
.request(Method::GET, "https://api.bilibili.com/x/v3/fav/folder/collected/list")
|
.request(Method::GET, "https://api.bilibili.com/x/v3/fav/folder/collected/list")
|
||||||
.await
|
.await
|
||||||
.query(&[
|
.query(&[
|
||||||
("up_mid", self.mid.as_ref()),
|
("up_mid", self.mid.as_str()),
|
||||||
("pn", page.to_string().as_ref()),
|
("pn", page_num.to_string().as_str()),
|
||||||
("ps", "20"),
|
("ps", page_size.to_string().as_str()),
|
||||||
("platform", "web"),
|
("platform", "web"),
|
||||||
])
|
])
|
||||||
.send()
|
.send()
|
||||||
@@ -53,15 +53,15 @@ impl<'a> Me<'a> {
|
|||||||
Ok(serde_json::from_value(resp["data"].take())?)
|
Ok(serde_json::from_value(resp["data"].take())?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_followed_uppers(&self, page: i32) -> Result<FollowedUppers> {
|
pub async fn get_followed_uppers(&self, page_num: i32, page_size: i32) -> Result<FollowedUppers> {
|
||||||
let mut resp = self
|
let mut resp = self
|
||||||
.client
|
.client
|
||||||
.request(Method::GET, "https://api.bilibili.com/x/relation/followings")
|
.request(Method::GET, "https://api.bilibili.com/x/relation/followings")
|
||||||
.await
|
.await
|
||||||
.query(&[
|
.query(&[
|
||||||
("vmid", self.mid.as_ref()),
|
("vmid", self.mid.as_str()),
|
||||||
("pn", page.to_string().as_ref()),
|
("pn", page_num.to_string().as_str()),
|
||||||
("ps", "20"),
|
("ps", page_size.to_string().as_str()),
|
||||||
])
|
])
|
||||||
.send()
|
.send()
|
||||||
.await?
|
.await?
|
||||||
@@ -86,6 +86,7 @@ impl<'a> Me<'a> {
|
|||||||
pub struct FavoriteItem {
|
pub struct FavoriteItem {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub media_count: i64,
|
pub media_count: i64,
|
||||||
|
pub id: i64,
|
||||||
pub fid: i64,
|
pub fid: i64,
|
||||||
pub mid: i64,
|
pub mid: i64,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub use danmaku::DanmakuOption;
|
|||||||
pub use error::BiliError;
|
pub use error::BiliError;
|
||||||
pub use favorite_list::FavoriteList;
|
pub use favorite_list::FavoriteList;
|
||||||
use favorite_list::Upper;
|
use favorite_list::Upper;
|
||||||
|
pub use me::Me;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
pub use submission::Submission;
|
pub use submission::Submission;
|
||||||
pub use video::{Dimension, PageInfo, Video};
|
pub use video::{Dimension, PageInfo, Video};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use std::fmt::Debug;
|
|||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use bilibili::BiliClient;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use task::{http_server, video_downloader};
|
use task::{http_server, video_downloader};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
@@ -29,12 +30,26 @@ use crate::utils::signal::terminate;
|
|||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
init();
|
init();
|
||||||
|
let bili_client = Arc::new(BiliClient::new());
|
||||||
let connection = Arc::new(setup_database().await);
|
let connection = Arc::new(setup_database().await);
|
||||||
|
|
||||||
let token = CancellationToken::new();
|
let token = CancellationToken::new();
|
||||||
let tracker = TaskTracker::new();
|
let tracker = TaskTracker::new();
|
||||||
|
|
||||||
spawn_task("HTTP 服务", http_server(connection.clone()), &tracker, token.clone());
|
spawn_task(
|
||||||
spawn_task("定时下载", video_downloader(connection), &tracker, token.clone());
|
"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();
|
tracker.close();
|
||||||
handle_shutdown(tracker, token).await
|
handle_shutdown(tracker, token).await
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use axum::body::Body;
|
|||||||
use axum::extract::Request;
|
use axum::extract::Request;
|
||||||
use axum::http::{Response, Uri, header};
|
use axum::http::{Response, Uri, header};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use axum::routing::{get, post};
|
use axum::routing::get;
|
||||||
use axum::{Extension, Router, ServiceExt, middleware};
|
use axum::{Extension, Router, ServiceExt, middleware};
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use rust_embed_for_web::{EmbedableFile, RustEmbed};
|
use rust_embed_for_web::{EmbedableFile, RustEmbed};
|
||||||
@@ -14,9 +14,8 @@ use utoipa::OpenApi;
|
|||||||
use utoipa_swagger_ui::{Config, SwaggerUi};
|
use utoipa_swagger_ui::{Config, SwaggerUi};
|
||||||
|
|
||||||
use crate::api::auth;
|
use crate::api::auth;
|
||||||
use crate::api::handler::{
|
use crate::api::handler::{ApiDoc, api_router};
|
||||||
ApiDoc, get_video, get_video_sources, get_videos, reset_all_videos, reset_video, update_video_status,
|
use crate::bilibili::BiliClient;
|
||||||
};
|
|
||||||
use crate::config::CONFIG;
|
use crate::config::CONFIG;
|
||||||
|
|
||||||
#[derive(RustEmbed)]
|
#[derive(RustEmbed)]
|
||||||
@@ -25,14 +24,9 @@ use crate::config::CONFIG;
|
|||||||
#[folder = "../../web/build"]
|
#[folder = "../../web/build"]
|
||||||
struct Asset;
|
struct Asset;
|
||||||
|
|
||||||
pub async fn http_server(database_connection: Arc<DatabaseConnection>) -> Result<()> {
|
pub async fn http_server(database_connection: Arc<DatabaseConnection>, bili_client: Arc<BiliClient>) -> Result<()> {
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/api/video-sources", get(get_video_sources))
|
.merge(api_router())
|
||||||
.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(
|
.merge(
|
||||||
SwaggerUi::new("/swagger-ui/")
|
SwaggerUi::new("/swagger-ui/")
|
||||||
.url("/api-docs/openapi.json", ApiDoc::openapi())
|
.url("/api-docs/openapi.json", ApiDoc::openapi())
|
||||||
@@ -45,6 +39,7 @@ pub async fn http_server(database_connection: Arc<DatabaseConnection>) -> Result
|
|||||||
)
|
)
|
||||||
.fallback_service(get(frontend_files))
|
.fallback_service(get(frontend_files))
|
||||||
.layer(Extension(database_connection))
|
.layer(Extension(database_connection))
|
||||||
|
.layer(Extension(bili_client))
|
||||||
.layer(middleware::from_fn(auth::auth));
|
.layer(middleware::from_fn(auth::auth));
|
||||||
let listener = tokio::net::TcpListener::bind(&CONFIG.bind_address)
|
let listener = tokio::net::TcpListener::bind(&CONFIG.bind_address)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ use crate::config::CONFIG;
|
|||||||
use crate::workflow::process_video_source;
|
use crate::workflow::process_video_source;
|
||||||
|
|
||||||
/// 启动周期下载视频的任务
|
/// 启动周期下载视频的任务
|
||||||
pub async fn video_downloader(connection: Arc<DatabaseConnection>) {
|
pub async fn video_downloader(connection: Arc<DatabaseConnection>, bili_client: Arc<BiliClient>) {
|
||||||
let mut anchor = chrono::Local::now().date_naive();
|
let mut anchor = chrono::Local::now().date_naive();
|
||||||
let bili_client = BiliClient::new();
|
|
||||||
let video_sources = CONFIG.as_video_sources();
|
let video_sources = CONFIG.as_video_sources();
|
||||||
loop {
|
loop {
|
||||||
info!("开始执行本轮视频下载任务..");
|
info!("开始执行本轮视频下载任务..");
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
use validator::ValidationError;
|
use validator::ValidationError;
|
||||||
|
|
||||||
use crate::utils::status::{STATUS_NOT_STARTED, STATUS_OK};
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth !important;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
|
|||||||
+100
-1
@@ -8,7 +8,13 @@ import type {
|
|||||||
ResetAllVideosResponse,
|
ResetAllVideosResponse,
|
||||||
UpdateVideoStatusRequest,
|
UpdateVideoStatusRequest,
|
||||||
UpdateVideoStatusResponse,
|
UpdateVideoStatusResponse,
|
||||||
ApiError
|
ApiError,
|
||||||
|
FavoritesResponse,
|
||||||
|
CollectionsResponse,
|
||||||
|
UppersResponse,
|
||||||
|
UpsertFavoriteRequest,
|
||||||
|
UpsertCollectionRequest,
|
||||||
|
UpsertSubmissionRequest
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// API 基础配置
|
// API 基础配置
|
||||||
@@ -168,6 +174,67 @@ class ApiClient {
|
|||||||
): Promise<ApiResponse<UpdateVideoStatusResponse>> {
|
): Promise<ApiResponse<UpdateVideoStatusResponse>> {
|
||||||
return this.post<UpdateVideoStatusResponse>(`/videos/${id}/update-status`, request);
|
return this.post<UpdateVideoStatusResponse>(`/videos/${id}/update-status`, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取我的收藏夹
|
||||||
|
*/
|
||||||
|
async getCreatedFavorites(): Promise<ApiResponse<FavoritesResponse>> {
|
||||||
|
return this.get<FavoritesResponse>('/me/favorites');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取关注的合集
|
||||||
|
* @param page 页码
|
||||||
|
*/
|
||||||
|
async getFollowedCollections(
|
||||||
|
pageNum?: number,
|
||||||
|
pageSize?: number
|
||||||
|
): Promise<ApiResponse<CollectionsResponse>> {
|
||||||
|
const params = {
|
||||||
|
page_num: pageNum,
|
||||||
|
page_size: pageSize
|
||||||
|
};
|
||||||
|
return this.get<CollectionsResponse>('/me/collections', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取关注的UP主
|
||||||
|
* @param page 页码
|
||||||
|
*/
|
||||||
|
async getFollowedUppers(
|
||||||
|
pageNum?: number,
|
||||||
|
pageSize?: number
|
||||||
|
): Promise<ApiResponse<UppersResponse>> {
|
||||||
|
const params = {
|
||||||
|
page_num: pageNum,
|
||||||
|
page_size: pageSize
|
||||||
|
};
|
||||||
|
return this.get<UppersResponse>('/me/uppers', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅收藏夹
|
||||||
|
* @param request 订阅请求参数
|
||||||
|
*/
|
||||||
|
async upsertFavorite(request: UpsertFavoriteRequest): Promise<ApiResponse<boolean>> {
|
||||||
|
return this.post<boolean>('/video-sources/favorites', request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅合集
|
||||||
|
* @param request 订阅请求参数
|
||||||
|
*/
|
||||||
|
async upsertCollection(request: UpsertCollectionRequest): Promise<ApiResponse<boolean>> {
|
||||||
|
return this.post<boolean>('/video-sources/collections', request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅UP主投稿
|
||||||
|
* @param request 订阅请求参数
|
||||||
|
*/
|
||||||
|
async upsertSubmission(request: UpsertSubmissionRequest): Promise<ApiResponse<boolean>> {
|
||||||
|
return this.post<boolean>('/video-sources/submissions', request);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建默认的 API 客户端实例
|
// 创建默认的 API 客户端实例
|
||||||
@@ -206,6 +273,38 @@ export const api = {
|
|||||||
updateVideoStatus: (id: number, request: UpdateVideoStatusRequest) =>
|
updateVideoStatus: (id: number, request: UpdateVideoStatusRequest) =>
|
||||||
apiClient.updateVideoStatus(id, request),
|
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
|
* 设置认证 token
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||||
import SettingsIcon from '@lucide/svelte/icons/settings';
|
import SettingsIcon from '@lucide/svelte/icons/settings';
|
||||||
|
import UserIcon from '@lucide/svelte/icons/user';
|
||||||
|
import HeartIcon from '@lucide/svelte/icons/heart';
|
||||||
|
import FolderIcon from '@lucide/svelte/icons/folder';
|
||||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||||
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte.js';
|
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte.js';
|
||||||
import {
|
import {
|
||||||
@@ -64,7 +67,7 @@
|
|||||||
<Sidebar.GroupLabel
|
<Sidebar.GroupLabel
|
||||||
class="text-muted-foreground mb-2 px-2 text-xs font-medium tracking-wider uppercase"
|
class="text-muted-foreground mb-2 px-2 text-xs font-medium tracking-wider uppercase"
|
||||||
>
|
>
|
||||||
视频来源
|
视频筛选
|
||||||
</Sidebar.GroupLabel>
|
</Sidebar.GroupLabel>
|
||||||
<Sidebar.GroupContent>
|
<Sidebar.GroupContent>
|
||||||
<Sidebar.Menu class="space-y-1">
|
<Sidebar.Menu class="space-y-1">
|
||||||
@@ -115,9 +118,69 @@
|
|||||||
</Sidebar.Menu>
|
</Sidebar.Menu>
|
||||||
</Sidebar.GroupContent>
|
</Sidebar.GroupContent>
|
||||||
</Sidebar.Group>
|
</Sidebar.Group>
|
||||||
|
|
||||||
|
<Sidebar.Group>
|
||||||
|
<Sidebar.GroupLabel
|
||||||
|
class="text-muted-foreground mb-2 px-2 text-xs font-medium tracking-wider uppercase"
|
||||||
|
>
|
||||||
|
快捷订阅
|
||||||
|
</Sidebar.GroupLabel>
|
||||||
|
<Sidebar.GroupContent>
|
||||||
|
<Sidebar.Menu class="space-y-1">
|
||||||
|
<Sidebar.MenuItem>
|
||||||
|
<Sidebar.MenuButton>
|
||||||
|
<a
|
||||||
|
href="/me/favorites"
|
||||||
|
class="hover:bg-accent/50 text-foreground flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200"
|
||||||
|
onclick={() => {
|
||||||
|
if (sidebar.isMobile) {
|
||||||
|
sidebar.setOpenMobile(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HeartIcon class="text-muted-foreground h-4 w-4" />
|
||||||
|
<span>创建的收藏夹</span>
|
||||||
|
</a>
|
||||||
|
</Sidebar.MenuButton>
|
||||||
|
</Sidebar.MenuItem>
|
||||||
|
<Sidebar.MenuItem>
|
||||||
|
<Sidebar.MenuButton>
|
||||||
|
<a
|
||||||
|
href="/me/collections"
|
||||||
|
class="hover:bg-accent/50 text-foreground flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200"
|
||||||
|
onclick={() => {
|
||||||
|
if (sidebar.isMobile) {
|
||||||
|
sidebar.setOpenMobile(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderIcon class="text-muted-foreground h-4 w-4" />
|
||||||
|
<span>关注的合集</span>
|
||||||
|
</a>
|
||||||
|
</Sidebar.MenuButton>
|
||||||
|
</Sidebar.MenuItem>
|
||||||
|
<Sidebar.MenuItem>
|
||||||
|
<Sidebar.MenuButton>
|
||||||
|
<a
|
||||||
|
href="/me/uppers"
|
||||||
|
class="hover:bg-accent/50 text-foreground flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200"
|
||||||
|
onclick={() => {
|
||||||
|
if (sidebar.isMobile) {
|
||||||
|
sidebar.setOpenMobile(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserIcon class="text-muted-foreground h-4 w-4" />
|
||||||
|
<span>关注的 UP 主</span>
|
||||||
|
</a>
|
||||||
|
</Sidebar.MenuButton>
|
||||||
|
</Sidebar.MenuItem>
|
||||||
|
</Sidebar.Menu>
|
||||||
|
</Sidebar.GroupContent>
|
||||||
|
</Sidebar.Group>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 固定在底部的设置选项 -->
|
<!-- 固定在底部的菜单选项 -->
|
||||||
<div class="border-border mt-auto border-t pt-4">
|
<div class="border-border mt-auto border-t pt-4">
|
||||||
<Sidebar.Menu class="space-y-1">
|
<Sidebar.Menu class="space-y-1">
|
||||||
<Sidebar.MenuItem>
|
<Sidebar.MenuItem>
|
||||||
@@ -125,6 +188,11 @@
|
|||||||
<a
|
<a
|
||||||
href="/settings"
|
href="/settings"
|
||||||
class="hover:bg-accent/50 text-foreground flex w-full cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 font-medium transition-all duration-200"
|
class="hover:bg-accent/50 text-foreground flex w-full cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 font-medium transition-all duration-200"
|
||||||
|
onclick={() => {
|
||||||
|
if (sidebar.isMobile) {
|
||||||
|
sidebar.setOpenMobile(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex flex-1 items-center gap-3">
|
<div class="flex flex-1 items-center gap-3">
|
||||||
<SettingsIcon class="text-muted-foreground h-4 w-4" />
|
<SettingsIcon class="text-muted-foreground h-4 w-4" />
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
|
||||||
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
|
import SubscriptionDialog from './subscription-dialog.svelte';
|
||||||
|
import UserIcon from '@lucide/svelte/icons/user';
|
||||||
|
import VideoIcon from '@lucide/svelte/icons/video';
|
||||||
|
import FolderIcon from '@lucide/svelte/icons/folder';
|
||||||
|
import HeartIcon from '@lucide/svelte/icons/heart';
|
||||||
|
import CheckIcon from '@lucide/svelte/icons/check';
|
||||||
|
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||||
|
import XIcon from '@lucide/svelte/icons/x';
|
||||||
|
import type {
|
||||||
|
FavoriteWithSubscriptionStatus,
|
||||||
|
CollectionWithSubscriptionStatus,
|
||||||
|
UpperWithSubscriptionStatus
|
||||||
|
} from '$lib/types';
|
||||||
|
|
||||||
|
export let item:
|
||||||
|
| FavoriteWithSubscriptionStatus
|
||||||
|
| CollectionWithSubscriptionStatus
|
||||||
|
| UpperWithSubscriptionStatus;
|
||||||
|
export let type: 'favorite' | 'collection' | 'upper' = 'favorite';
|
||||||
|
export let onSubscriptionSuccess: (() => void) | null = null;
|
||||||
|
|
||||||
|
let dialogOpen = false;
|
||||||
|
|
||||||
|
function getIcon() {
|
||||||
|
switch (type) {
|
||||||
|
case 'favorite':
|
||||||
|
return HeartIcon;
|
||||||
|
case 'collection':
|
||||||
|
return FolderIcon;
|
||||||
|
case 'upper':
|
||||||
|
return UserIcon;
|
||||||
|
default:
|
||||||
|
return VideoIcon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeLabel() {
|
||||||
|
switch (type) {
|
||||||
|
case 'favorite':
|
||||||
|
return '收藏夹';
|
||||||
|
case 'collection':
|
||||||
|
return '合集';
|
||||||
|
case 'upper':
|
||||||
|
return 'UP主';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTitle(): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'favorite':
|
||||||
|
return (item as FavoriteWithSubscriptionStatus).title;
|
||||||
|
case 'collection':
|
||||||
|
return (item as CollectionWithSubscriptionStatus).title;
|
||||||
|
case 'upper':
|
||||||
|
return (item as UpperWithSubscriptionStatus).uname;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubtitle(): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'favorite':
|
||||||
|
return `UP主ID: ${(item as FavoriteWithSubscriptionStatus).mid}`;
|
||||||
|
case 'collection':
|
||||||
|
return `UP主ID: ${(item as CollectionWithSubscriptionStatus).mid}`;
|
||||||
|
case 'upper':
|
||||||
|
return ''; // UP主不需要副标题
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDescription(): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'upper':
|
||||||
|
return (item as UpperWithSubscriptionStatus).sign || '';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDisabled(): boolean {
|
||||||
|
switch (type) {
|
||||||
|
case 'collection':
|
||||||
|
return (item as CollectionWithSubscriptionStatus).state === 1;
|
||||||
|
case 'upper': {
|
||||||
|
const upper = item as UpperWithSubscriptionStatus;
|
||||||
|
// 没看到有 status 标记,这样判断应该没什么大问题
|
||||||
|
return (
|
||||||
|
upper.uname === '账号已注销' &&
|
||||||
|
upper.face === 'https://i0.hdslb.com/bfs/face/member/noface.jpg'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisabledReason(): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'collection':
|
||||||
|
return '已失效';
|
||||||
|
case 'upper':
|
||||||
|
return '账号已注销';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCount(): number | null {
|
||||||
|
switch (type) {
|
||||||
|
case 'favorite':
|
||||||
|
return (item as FavoriteWithSubscriptionStatus).media_count;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCountLabel(): string {
|
||||||
|
return '个视频';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvatarUrl(): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'upper':
|
||||||
|
return `/image-proxy?url=${(item as UpperWithSubscriptionStatus).face}`;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubscribe() {
|
||||||
|
if (!disabled) {
|
||||||
|
dialogOpen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubscriptionSuccess() {
|
||||||
|
// 更新本地状态
|
||||||
|
item.subscribed = true;
|
||||||
|
if (onSubscriptionSuccess) {
|
||||||
|
onSubscriptionSuccess();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = getIcon();
|
||||||
|
const typeLabel = getTypeLabel();
|
||||||
|
const title = getTitle();
|
||||||
|
const subtitle = getSubtitle();
|
||||||
|
const description = getDescription();
|
||||||
|
const count = getCount();
|
||||||
|
const countLabel = getCountLabel();
|
||||||
|
const avatarUrl = getAvatarUrl();
|
||||||
|
const subscribed = item.subscribed;
|
||||||
|
const disabled = isDisabled();
|
||||||
|
const disabledReason = getDisabledReason();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card class="group transition-shadow hover:shadow-md {disabled ? 'opacity-60 grayscale' : ''}">
|
||||||
|
<CardHeader class="pb-3">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="flex min-w-0 flex-1 items-start gap-3">
|
||||||
|
<!-- 头像或图标 -->
|
||||||
|
<div
|
||||||
|
class="bg-muted flex h-12 w-12 shrink-0 items-center justify-center rounded-lg {disabled
|
||||||
|
? 'opacity-50'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{#if avatarUrl && type === 'upper'}
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={title}
|
||||||
|
class="h-full w-full rounded-lg object-cover {disabled ? 'grayscale' : ''}"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<Icon class="text-muted-foreground h-6 w-6" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 标题和信息 -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<CardTitle
|
||||||
|
class="line-clamp-2 text-base leading-tight {disabled
|
||||||
|
? 'text-muted-foreground line-through'
|
||||||
|
: ''}"
|
||||||
|
{title}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
{#if subtitle}
|
||||||
|
<div class="text-muted-foreground mt-1 flex items-center gap-1.5 text-sm">
|
||||||
|
<UserIcon class="h-3 w-3 shrink-0" />
|
||||||
|
<span class="truncate" title={subtitle}>{subtitle}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if description}
|
||||||
|
<p class="text-muted-foreground mt-1 line-clamp-2 text-xs" title={description}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 状态标记 -->
|
||||||
|
<div class="flex shrink-0 flex-col items-end gap-2">
|
||||||
|
{#if disabled}
|
||||||
|
<Badge variant="destructive" class="text-xs">不可用</Badge>
|
||||||
|
<div class="text-muted-foreground text-xs">
|
||||||
|
{disabledReason}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Badge variant={subscribed ? 'default' : 'outline'} class="text-xs">
|
||||||
|
{subscribed ? '已订阅' : typeLabel}
|
||||||
|
</Badge>
|
||||||
|
{#if count !== null}
|
||||||
|
<div class="text-muted-foreground text-xs">
|
||||||
|
{count}
|
||||||
|
{countLabel}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="pt-0">
|
||||||
|
<div class="flex justify-end">
|
||||||
|
{#if disabled}
|
||||||
|
<Button size="sm" variant="outline" disabled class="cursor-not-allowed opacity-50">
|
||||||
|
<XIcon class="mr-2 h-4 w-4" />
|
||||||
|
不可用
|
||||||
|
</Button>
|
||||||
|
{:else if subscribed}
|
||||||
|
<Button size="sm" variant="outline" disabled class="cursor-not-allowed">
|
||||||
|
<CheckIcon class="mr-2 h-4 w-4" />
|
||||||
|
已订阅
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
onclick={handleSubscribe}
|
||||||
|
class="cursor-pointer"
|
||||||
|
{disabled}
|
||||||
|
>
|
||||||
|
<PlusIcon class="mr-2 h-4 w-4" />
|
||||||
|
快捷订阅
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 订阅对话框 -->
|
||||||
|
<SubscriptionDialog bind:open={dialogOpen} {item} {type} onSuccess={handleSubscriptionSuccess} />
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle
|
||||||
|
} from '$lib/components/ui/sheet/index.js';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import api from '$lib/api';
|
||||||
|
import type {
|
||||||
|
FavoriteWithSubscriptionStatus,
|
||||||
|
CollectionWithSubscriptionStatus,
|
||||||
|
UpperWithSubscriptionStatus,
|
||||||
|
UpsertFavoriteRequest,
|
||||||
|
UpsertCollectionRequest,
|
||||||
|
UpsertSubmissionRequest,
|
||||||
|
ApiError
|
||||||
|
} from '$lib/types';
|
||||||
|
|
||||||
|
export let open = false;
|
||||||
|
export let item:
|
||||||
|
| FavoriteWithSubscriptionStatus
|
||||||
|
| CollectionWithSubscriptionStatus
|
||||||
|
| UpperWithSubscriptionStatus
|
||||||
|
| null = null;
|
||||||
|
export let type: 'favorite' | 'collection' | 'upper' = 'favorite';
|
||||||
|
export let onSuccess: (() => void) | null = null;
|
||||||
|
|
||||||
|
let customPath = '';
|
||||||
|
let loading = false;
|
||||||
|
|
||||||
|
// 根据类型和item生成默认路径
|
||||||
|
function generateDefaultPath(): string {
|
||||||
|
if (!item) return '';
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'favorite': {
|
||||||
|
const favorite = item as FavoriteWithSubscriptionStatus;
|
||||||
|
return `收藏夹/${favorite.title}`;
|
||||||
|
}
|
||||||
|
case 'collection': {
|
||||||
|
const collection = item as CollectionWithSubscriptionStatus;
|
||||||
|
return `合集/${collection.title}`;
|
||||||
|
}
|
||||||
|
case 'upper': {
|
||||||
|
const upper = item as UpperWithSubscriptionStatus;
|
||||||
|
return `UP主/${upper.uname}`;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeLabel(): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'favorite':
|
||||||
|
return '收藏夹';
|
||||||
|
case 'collection':
|
||||||
|
return '合集';
|
||||||
|
case 'upper':
|
||||||
|
return 'UP主';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItemTitle(): string {
|
||||||
|
if (!item) return '';
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'favorite':
|
||||||
|
return (item as FavoriteWithSubscriptionStatus).title;
|
||||||
|
case 'collection':
|
||||||
|
return (item as CollectionWithSubscriptionStatus).title;
|
||||||
|
case 'upper':
|
||||||
|
return (item as UpperWithSubscriptionStatus).uname;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubscribe() {
|
||||||
|
if (!item || !customPath.trim()) return;
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'favorite': {
|
||||||
|
const favorite = item as FavoriteWithSubscriptionStatus;
|
||||||
|
const request: UpsertFavoriteRequest = {
|
||||||
|
// 数据库中保存的 fid 实际上是 favorite.id
|
||||||
|
fid: favorite.id,
|
||||||
|
name: favorite.title,
|
||||||
|
path: customPath.trim()
|
||||||
|
};
|
||||||
|
response = await api.upsertFavorite(request);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'collection': {
|
||||||
|
const collection = item as CollectionWithSubscriptionStatus;
|
||||||
|
const request: UpsertCollectionRequest = {
|
||||||
|
id: collection.id,
|
||||||
|
mid: collection.mid,
|
||||||
|
path: customPath.trim()
|
||||||
|
};
|
||||||
|
response = await api.upsertCollection(request);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'upper': {
|
||||||
|
const upper = item as UpperWithSubscriptionStatus;
|
||||||
|
const request: UpsertSubmissionRequest = {
|
||||||
|
upper_id: upper.mid,
|
||||||
|
path: customPath.trim()
|
||||||
|
};
|
||||||
|
response = await api.upsertSubmission(request);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response && response.data) {
|
||||||
|
toast.success('订阅成功', {
|
||||||
|
description: `已订阅${getTypeLabel()}「${getItemTitle()}」到路径「${customPath.trim()}」`
|
||||||
|
});
|
||||||
|
open = false;
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`订阅${getTypeLabel()}失败:`, error);
|
||||||
|
toast.error('订阅失败', {
|
||||||
|
description: (error as ApiError).message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当对话框打开时重置path
|
||||||
|
$: if (open && item) {
|
||||||
|
customPath = generateDefaultPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabel = getTypeLabel();
|
||||||
|
const itemTitle = getItemTitle();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sheet bind:open>
|
||||||
|
<SheetContent side="right" class="flex w-full flex-col sm:max-w-md">
|
||||||
|
<SheetHeader class="px-6 pb-2">
|
||||||
|
<SheetTitle class="text-lg">订阅{typeLabel}</SheetTitle>
|
||||||
|
<SheetDescription class="text-muted-foreground space-y-1 text-sm">
|
||||||
|
<div>即将订阅{typeLabel}「{itemTitle}」</div>
|
||||||
|
<div>请手动编辑本地保存路径:</div>
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto px-6">
|
||||||
|
<div class="space-y-4 py-4">
|
||||||
|
<!-- 项目信息 -->
|
||||||
|
<div class="bg-muted/30 rounded-lg border p-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-muted-foreground text-sm font-medium">{typeLabel}名称:</span>
|
||||||
|
<span class="text-sm">{itemTitle}</span>
|
||||||
|
</div>
|
||||||
|
{#if type === 'favorite'}
|
||||||
|
{@const favorite = item as FavoriteWithSubscriptionStatus}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-muted-foreground text-sm font-medium">视频数量:</span>
|
||||||
|
<span class="text-sm">{favorite.media_count} 个</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if type === 'upper'}
|
||||||
|
{@const upper = item as UpperWithSubscriptionStatus}
|
||||||
|
{#if upper.sign}
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-muted-foreground text-sm font-medium">个人简介:</span>
|
||||||
|
<span class="text-muted-foreground text-sm">{upper.sign}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 路径输入 -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<Label for="custom-path" class="text-sm font-medium">
|
||||||
|
本地保存路径 <span class="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="custom-path"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入保存路径,例如:/home/我的收藏"
|
||||||
|
bind:value={customPath}
|
||||||
|
disabled={loading}
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<div class="text-muted-foreground space-y-3 text-xs">
|
||||||
|
<p>路径将作为文件夹名称,用于存放下载的视频文件。</p>
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 font-medium">路径示例:</p>
|
||||||
|
<div class="space-y-1 pl-4">
|
||||||
|
<div class="font-mono text-xs">Mac/Linux: /home/downloads/我的收藏</div>
|
||||||
|
<div class="font-mono text-xs">Windows: C:\Downloads\我的收藏</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter class="bg-background flex gap-2 border-t px-6 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onclick={handleCancel}
|
||||||
|
disabled={loading}
|
||||||
|
class="flex-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onclick={handleSubscribe}
|
||||||
|
disabled={loading || !customPath.trim()}
|
||||||
|
class="flex-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
{loading ? '订阅中...' : '确认订阅'}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
@@ -102,3 +102,62 @@ export interface UpdateVideoStatusResponse {
|
|||||||
video: VideoInfo;
|
video: VideoInfo;
|
||||||
pages: PageInfo[];
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
export const prerender = true;
|
export const prerender = false;
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import SubscriptionCard from '$lib/components/subscription-card.svelte';
|
||||||
|
import Pagination from '$lib/components/pagination.svelte';
|
||||||
|
import { setBreadcrumb } from '$lib/stores/breadcrumb';
|
||||||
|
import { appStateStore, ToQuery } from '$lib/stores/filter';
|
||||||
|
import api from '$lib/api';
|
||||||
|
import type { CollectionWithSubscriptionStatus, ApiError } from '$lib/types';
|
||||||
|
|
||||||
|
let collections: CollectionWithSubscriptionStatus[] = [];
|
||||||
|
let totalCount = 0;
|
||||||
|
let currentPage = 0;
|
||||||
|
let loading = false;
|
||||||
|
|
||||||
|
const pageSize = 50;
|
||||||
|
|
||||||
|
async function loadCollections(page: number = 0) {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const response = await api.getFollowedCollections(page + 1, pageSize); // API使用1基索引
|
||||||
|
collections = response.data.collections;
|
||||||
|
totalCount = response.data.total;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载合集失败:', error);
|
||||||
|
toast.error('加载合集失败', {
|
||||||
|
description: (error as ApiError).message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubscriptionSuccess() {
|
||||||
|
// 重新加载数据以获取最新状态
|
||||||
|
loadCollections(currentPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePageChange(page: number) {
|
||||||
|
currentPage = page;
|
||||||
|
await loadCollections(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
setBreadcrumb([
|
||||||
|
{
|
||||||
|
label: '主页',
|
||||||
|
onClick: () => {
|
||||||
|
goto(`/${ToQuery($appStateStore)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '关注的合集',
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
await loadCollections();
|
||||||
|
});
|
||||||
|
|
||||||
|
$: totalPages = Math.ceil(totalCount / pageSize);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>关注的合集 - Bili Sync</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="max-w-6xl">
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">关注的合集</h1>
|
||||||
|
<p class="text-muted-foreground mt-1">管理您在B站关注的合集订阅</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground text-sm">
|
||||||
|
{#if !loading}
|
||||||
|
共 {totalCount} 个合集
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center justify-center py-12">
|
||||||
|
<div class="text-muted-foreground">加载中...</div>
|
||||||
|
</div>
|
||||||
|
{:else if collections.length > 0}
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{#each collections as collection (collection.id)}
|
||||||
|
<SubscriptionCard
|
||||||
|
item={collection}
|
||||||
|
type="collection"
|
||||||
|
onSubscriptionSuccess={handleSubscriptionSuccess}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分页组件 -->
|
||||||
|
{#if totalPages > 1}
|
||||||
|
<Pagination {currentPage} {totalPages} onPageChange={handlePageChange} />
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center justify-center py-12">
|
||||||
|
<div class="space-y-2 text-center">
|
||||||
|
<p class="text-muted-foreground">暂无合集数据</p>
|
||||||
|
<p class="text-muted-foreground text-sm">请先在B站关注一些合集,或检查账号配置</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import SubscriptionCard from '$lib/components/subscription-card.svelte';
|
||||||
|
import { setBreadcrumb } from '$lib/stores/breadcrumb';
|
||||||
|
import { appStateStore, ToQuery } from '$lib/stores/filter';
|
||||||
|
import api from '$lib/api';
|
||||||
|
import type { FavoriteWithSubscriptionStatus, ApiError } from '$lib/types';
|
||||||
|
|
||||||
|
let favorites: FavoriteWithSubscriptionStatus[] = [];
|
||||||
|
let loading = false;
|
||||||
|
|
||||||
|
async function loadFavorites() {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const response = await api.getCreatedFavorites();
|
||||||
|
favorites = response.data.favorites;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载收藏夹失败:', error);
|
||||||
|
toast.error('加载收藏夹失败', {
|
||||||
|
description: (error as ApiError).message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubscriptionSuccess() {
|
||||||
|
// 重新加载数据以获取最新状态
|
||||||
|
loadFavorites();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
setBreadcrumb([
|
||||||
|
{
|
||||||
|
label: '主页',
|
||||||
|
onClick: () => {
|
||||||
|
goto(`/${ToQuery($appStateStore)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ label: '我的收藏夹', isActive: true }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await loadFavorites();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>我的收藏夹 - Bili Sync</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="max-w-6xl">
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">我的收藏夹</h1>
|
||||||
|
<p class="text-muted-foreground mt-1">管理您在B站创建的收藏夹订阅</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground text-sm">
|
||||||
|
{#if !loading}
|
||||||
|
共 {favorites.length} 个收藏夹
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center justify-center py-12">
|
||||||
|
<div class="text-muted-foreground">加载中...</div>
|
||||||
|
</div>
|
||||||
|
{:else if favorites.length > 0}
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{#each favorites as favorite (favorite.fid)}
|
||||||
|
<SubscriptionCard
|
||||||
|
item={favorite}
|
||||||
|
type="favorite"
|
||||||
|
onSubscriptionSuccess={handleSubscriptionSuccess}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center justify-center py-12">
|
||||||
|
<div class="space-y-2 text-center">
|
||||||
|
<p class="text-muted-foreground">暂无收藏夹数据</p>
|
||||||
|
<p class="text-muted-foreground text-sm">请先在B站创建收藏夹,或检查账号配置</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import SubscriptionCard from '$lib/components/subscription-card.svelte';
|
||||||
|
import Pagination from '$lib/components/pagination.svelte';
|
||||||
|
import { setBreadcrumb } from '$lib/stores/breadcrumb';
|
||||||
|
import { appStateStore, ToQuery } from '$lib/stores/filter';
|
||||||
|
import api from '$lib/api';
|
||||||
|
import type { UpperWithSubscriptionStatus, ApiError } from '$lib/types';
|
||||||
|
|
||||||
|
let uppers: UpperWithSubscriptionStatus[] = [];
|
||||||
|
let totalCount = 0;
|
||||||
|
let currentPage = 0;
|
||||||
|
let loading = false;
|
||||||
|
|
||||||
|
const pageSize = 50;
|
||||||
|
|
||||||
|
async function loadUppers(page: number = 0) {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const response = await api.getFollowedUppers(page + 1, pageSize); // API使用1基索引
|
||||||
|
uppers = response.data.uppers;
|
||||||
|
totalCount = response.data.total;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载UP主失败:', error);
|
||||||
|
toast.error('加载UP主失败', {
|
||||||
|
description: (error as ApiError).message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubscriptionSuccess() {
|
||||||
|
// 重新加载数据以获取最新状态
|
||||||
|
loadUppers(currentPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePageChange(page: number) {
|
||||||
|
currentPage = page;
|
||||||
|
await loadUppers(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
setBreadcrumb([
|
||||||
|
{
|
||||||
|
label: '主页',
|
||||||
|
onClick: () => {
|
||||||
|
goto(`/${ToQuery($appStateStore)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ label: '关注的UP主', isActive: true }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await loadUppers();
|
||||||
|
});
|
||||||
|
|
||||||
|
$: totalPages = Math.ceil(totalCount / pageSize);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>关注的UP主 - Bili Sync</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="max-w-6xl">
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">关注的UP主</h1>
|
||||||
|
<p class="text-muted-foreground mt-1">管理您在B站关注的UP主投稿订阅</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground text-sm">
|
||||||
|
{#if !loading}
|
||||||
|
共 {totalCount} 个UP主
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center justify-center py-12">
|
||||||
|
<div class="text-muted-foreground">加载中...</div>
|
||||||
|
</div>
|
||||||
|
{:else if uppers.length > 0}
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{#each uppers as upper (upper.mid)}
|
||||||
|
<SubscriptionCard
|
||||||
|
item={upper}
|
||||||
|
type="upper"
|
||||||
|
onSubscriptionSuccess={handleSubscriptionSuccess}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分页组件 -->
|
||||||
|
{#if totalPages > 1}
|
||||||
|
<Pagination {currentPage} {totalPages} onPageChange={handlePageChange} />
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center justify-center py-12">
|
||||||
|
<div class="space-y-2 text-center">
|
||||||
|
<p class="text-muted-foreground">暂无UP主数据</p>
|
||||||
|
<p class="text-muted-foreground text-sm">请先在B站关注一些UP主,或检查账号配置</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -156,7 +156,7 @@
|
|||||||
bind:resetting
|
bind:resetting
|
||||||
onReset={async () => {
|
onReset={async () => {
|
||||||
try {
|
try {
|
||||||
const result = await api.resetVideo((videoData as VideoResponse).video.id);
|
const result = await api.resetVideo(videoData!.video.id);
|
||||||
const data = result.data;
|
const data = result.data;
|
||||||
if (data.resetted) {
|
if (data.resetted) {
|
||||||
videoData = {
|
videoData = {
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
export const ssr = false;
|
|
||||||
export const prerender = false;
|
|
||||||
+2
-1
@@ -6,7 +6,8 @@ export default defineConfig({
|
|||||||
plugins: [tailwindcss(), sveltekit()],
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:12345'
|
'/api': 'http://localhost:12345',
|
||||||
|
'/image-proxy': 'http://localhost:12345'
|
||||||
},
|
},
|
||||||
host: true
|
host: true
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user