use crate::BilibiliRequest;
use crate::BpiError;
use crate::BpiResult;
use crate::dynamic::DynamicClient;
use reqwest::Body;
use reqwest::multipart::{Form, Part};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::path::PathBuf;
use tokio::fs::File;
use tokio_util::codec::{BytesCodec, FramedRead};
const UPLOAD_PIC_ENDPOINT: &str = "https://api.bilibili.com/x/dynamic/feed/draw/upload_bfs";
const CREATE_TEXT_ENDPOINT: &str = "https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/create";
const CREATE_COMPLEX_ENDPOINT: &str = "https://api.bilibili.com/x/dynamic/feed/create/dyn";
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct UploadPicData {
pub image_url: String,
pub image_width: u64,
pub image_height: u64,
pub img_size: f64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CreateVoteData {
pub vote_id: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CreateDynamicData {
pub dynamic_id: u64,
pub dynamic_id_str: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DynamicContentItem {
#[serde(rename = "type")]
pub type_num: u8,
pub biz_id: Option<String>,
pub raw_text: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DynamicPic {
pub img_src: String,
pub img_height: u64,
pub img_width: u64,
pub img_size: f64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DynamicTopic {
pub id: u64,
pub name: String,
pub from_source: Option<String>,
pub from_topic_id: Option<u64>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DynamicOption {
pub up_choose_comment: Option<u8>,
pub close_comment: Option<u8>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DynamicRequest {
pub attach_card: Option<serde_json::Value>,
pub content: DynamicContent,
pub meta: Option<serde_json::Value>,
pub scene: u8,
pub pics: Option<Vec<DynamicPic>>,
pub topic: Option<DynamicTopic>,
pub option: Option<DynamicOption>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DynamicContent {
pub contents: Vec<DynamicContentItem>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CreateComplexDynamicData {
pub dyn_id: u64,
pub dyn_id_str: String,
pub dyn_type: u8,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DynamicUploadPicParams {
file_path: PathBuf,
category: String,
}
impl DynamicUploadPicParams {
pub fn new(file_path: impl Into<PathBuf>) -> Self {
Self {
file_path: file_path.into(),
category: "daily".to_string(),
}
}
pub fn category(mut self, category: impl Into<String>) -> BpiResult<Self> {
self.category = normalize_non_blank("category", category.into())?;
Ok(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DynamicTextCreateParams {
content: String,
}
impl DynamicTextCreateParams {
pub fn new(content: impl Into<String>) -> BpiResult<Self> {
Ok(Self {
content: normalize_non_blank("content", content.into())?,
})
}
}
#[derive(Debug, Clone)]
pub struct DynamicComplexCreateParams {
scene: u8,
contents: Vec<DynamicContentItem>,
pics: Option<Vec<DynamicPic>>,
topic: Option<DynamicTopic>,
}
impl DynamicComplexCreateParams {
pub fn new(scene: u8, contents: Vec<DynamicContentItem>) -> BpiResult<Self> {
if !matches!(scene, 1 | 2 | 4) {
return Err(BpiError::invalid_parameter(
"scene",
"value must be 1, 2, or 4",
));
}
if contents.is_empty() {
return Err(BpiError::invalid_parameter(
"contents",
"at least one content item is required",
));
}
Ok(Self {
scene,
contents,
pics: None,
topic: None,
})
}
pub fn pics(mut self, pics: Vec<DynamicPic>) -> BpiResult<Self> {
if pics.is_empty() {
return Err(BpiError::invalid_parameter(
"pics",
"at least one picture is required",
));
}
self.pics = Some(pics);
Ok(self)
}
pub fn topic(mut self, topic: DynamicTopic) -> Self {
self.topic = Some(topic);
self
}
fn request_body(self) -> serde_json::Value {
let dyn_req = DynamicRequest {
attach_card: None,
content: DynamicContent {
contents: self.contents,
},
meta: Some(json!({
"app_meta": {
"from": "create.dynamic.web",
"mobi_app": "web"
}
})),
scene: self.scene,
pics: self.pics,
topic: self.topic,
option: None,
};
json!({ "dyn_req": dyn_req })
}
}
impl<'a> DynamicClient<'a> {
pub async fn upload_pic(&self, params: DynamicUploadPicParams) -> BpiResult<UploadPicData> {
let csrf = self.client.csrf()?;
let file = File::open(¶ms.file_path)
.await
.map_err(|_| BpiError::parse("打开文件失败"))?;
let stream = FramedRead::new(file, BytesCodec::new());
let body = Body::wrap_stream(stream);
let file_name = params.file_path.file_name().ok_or_else(|| {
BpiError::parse("Invalid file path, cannot get file name".to_string())
})?;
let file_part = Part::stream(body)
.file_name(file_name.to_string_lossy().into_owned())
.mime_str("image/jpeg")?;
let form = Form::new()
.part("file_up", file_part)
.text("csrf", csrf.clone())
.text("category", params.category)
.text("biz", "new_dyn".to_string());
self.client
.post(UPLOAD_PIC_ENDPOINT)
.multipart(form)
.send_bpi_payload("dynamic.pic.upload")
.await
}
pub async fn create_text(
&self,
params: DynamicTextCreateParams,
) -> BpiResult<CreateDynamicData> {
let csrf = self.client.csrf()?;
let form = Form::new()
.text("dynamic_id", "0")
.text("type", "4")
.text("rid", "0")
.text("content", params.content)
.text("csrf", csrf.clone())
.text("csrf_token", csrf);
self.client
.post(CREATE_TEXT_ENDPOINT)
.multipart(form)
.send_bpi_payload("dynamic.text.create")
.await
}
pub async fn create_complex(
&self,
params: DynamicComplexCreateParams,
) -> BpiResult<CreateComplexDynamicData> {
let csrf = self.client.csrf()?;
let request_body = params.request_body();
self.client
.post(CREATE_COMPLEX_ENDPOINT)
.header("Content-Type", "application/json")
.query(&[("csrf", csrf)])
.body(request_body.to_string())
.send_bpi_payload("dynamic.complex.create")
.await
}
}
fn normalize_non_blank(field: &'static str, value: String) -> BpiResult<String> {
let value = value.trim().to_string();
if value.is_empty() {
return Err(BpiError::invalid_parameter(field, "value cannot be blank"));
}
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dynamic_text_create_params_rejects_blank_content() {
let err = DynamicTextCreateParams::new(" ").unwrap_err();
assert!(matches!(
err,
BpiError::InvalidParameter {
field: "content",
..
}
));
}
}