1use crate::BilibiliRequest;
4use crate::BpiError;
5use crate::BpiResult;
6use crate::dynamic::DynamicClient;
7use reqwest::Body;
8use reqwest::multipart::{Form, Part};
9use serde::{Deserialize, Serialize};
10use serde_json::json;
11use std::path::PathBuf;
12use tokio::fs::File;
13use tokio_util::codec::{BytesCodec, FramedRead};
14
15const UPLOAD_PIC_ENDPOINT: &str = "https://api.bilibili.com/x/dynamic/feed/draw/upload_bfs";
16const CREATE_TEXT_ENDPOINT: &str = "https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/create";
17const CREATE_COMPLEX_ENDPOINT: &str = "https://api.bilibili.com/x/dynamic/feed/create/dyn";
18
19#[derive(Debug, Clone, Deserialize, Serialize)]
22pub struct UploadPicData {
23 pub image_url: String,
25 pub image_width: u64,
27 pub image_height: u64,
29 pub img_size: f64,
31}
32
33#[derive(Debug, Clone, Deserialize, Serialize)]
37pub struct CreateVoteData {
38 pub vote_id: u64,
40}
41
42#[derive(Debug, Clone, Deserialize, Serialize)]
46pub struct CreateDynamicData {
47 pub dynamic_id: u64,
49 pub dynamic_id_str: String,
51 }
53
54#[derive(Debug, Clone, Deserialize, Serialize)]
58pub struct DynamicContentItem {
59 #[serde(rename = "type")]
61 pub type_num: u8,
62 pub biz_id: Option<String>,
64 pub raw_text: String,
66}
67
68#[derive(Debug, Clone, Deserialize, Serialize)]
70pub struct DynamicPic {
71 pub img_src: String,
73 pub img_height: u64,
75 pub img_width: u64,
77 pub img_size: f64,
79}
80
81#[derive(Debug, Clone, Deserialize, Serialize)]
83pub struct DynamicTopic {
84 pub id: u64,
86 pub name: String,
88 pub from_source: Option<String>,
90 pub from_topic_id: Option<u64>,
92}
93
94#[derive(Debug, Clone, Deserialize, Serialize)]
96pub struct DynamicOption {
97 pub up_choose_comment: Option<u8>,
99 pub close_comment: Option<u8>,
101}
102
103#[derive(Debug, Clone, Deserialize, Serialize)]
105pub struct DynamicRequest {
106 pub attach_card: Option<serde_json::Value>,
108 pub content: DynamicContent,
110 pub meta: Option<serde_json::Value>,
112 pub scene: u8,
114 pub pics: Option<Vec<DynamicPic>>,
116 pub topic: Option<DynamicTopic>,
118 pub option: Option<DynamicOption>,
120}
121
122#[derive(Debug, Clone, Deserialize, Serialize)]
124pub struct DynamicContent {
125 pub contents: Vec<DynamicContentItem>,
126}
127
128#[derive(Debug, Clone, Deserialize, Serialize)]
130pub struct CreateComplexDynamicData {
131 pub dyn_id: u64,
132 pub dyn_id_str: String,
133 pub dyn_type: u8,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct DynamicUploadPicParams {
139 file_path: PathBuf,
140 category: String,
141}
142
143impl DynamicUploadPicParams {
144 pub fn new(file_path: impl Into<PathBuf>) -> Self {
145 Self {
146 file_path: file_path.into(),
147 category: "daily".to_string(),
148 }
149 }
150
151 pub fn category(mut self, category: impl Into<String>) -> BpiResult<Self> {
152 self.category = normalize_non_blank("category", category.into())?;
153 Ok(self)
154 }
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct DynamicTextCreateParams {
160 content: String,
161}
162
163impl DynamicTextCreateParams {
164 pub fn new(content: impl Into<String>) -> BpiResult<Self> {
165 Ok(Self {
166 content: normalize_non_blank("content", content.into())?,
167 })
168 }
169}
170
171#[derive(Debug, Clone)]
173pub struct DynamicComplexCreateParams {
174 scene: u8,
175 contents: Vec<DynamicContentItem>,
176 pics: Option<Vec<DynamicPic>>,
177 topic: Option<DynamicTopic>,
178}
179
180impl DynamicComplexCreateParams {
181 pub fn new(scene: u8, contents: Vec<DynamicContentItem>) -> BpiResult<Self> {
182 if !matches!(scene, 1 | 2 | 4) {
183 return Err(BpiError::invalid_parameter(
184 "scene",
185 "value must be 1, 2, or 4",
186 ));
187 }
188 if contents.is_empty() {
189 return Err(BpiError::invalid_parameter(
190 "contents",
191 "at least one content item is required",
192 ));
193 }
194
195 Ok(Self {
196 scene,
197 contents,
198 pics: None,
199 topic: None,
200 })
201 }
202
203 pub fn pics(mut self, pics: Vec<DynamicPic>) -> BpiResult<Self> {
204 if pics.is_empty() {
205 return Err(BpiError::invalid_parameter(
206 "pics",
207 "at least one picture is required",
208 ));
209 }
210 self.pics = Some(pics);
211 Ok(self)
212 }
213
214 pub fn topic(mut self, topic: DynamicTopic) -> Self {
215 self.topic = Some(topic);
216 self
217 }
218
219 fn request_body(self) -> serde_json::Value {
220 let dyn_req = DynamicRequest {
221 attach_card: None,
222 content: DynamicContent {
223 contents: self.contents,
224 },
225 meta: Some(json!({
226 "app_meta": {
227 "from": "create.dynamic.web",
228 "mobi_app": "web"
229 }
230 })),
231 scene: self.scene,
232 pics: self.pics,
233 topic: self.topic,
234 option: None,
235 };
236
237 json!({ "dyn_req": dyn_req })
238 }
239}
240
241impl<'a> DynamicClient<'a> {
242 pub async fn upload_pic(&self, params: DynamicUploadPicParams) -> BpiResult<UploadPicData> {
244 let csrf = self.client.csrf()?;
245
246 let file = File::open(¶ms.file_path)
247 .await
248 .map_err(|_| BpiError::parse("打开文件失败"))?;
249 let stream = FramedRead::new(file, BytesCodec::new());
250 let body = Body::wrap_stream(stream);
251
252 let file_name = params.file_path.file_name().ok_or_else(|| {
253 BpiError::parse("Invalid file path, cannot get file name".to_string())
254 })?;
255
256 let file_part = Part::stream(body)
257 .file_name(file_name.to_string_lossy().into_owned())
258 .mime_str("image/jpeg")?;
259
260 let form = Form::new()
261 .part("file_up", file_part)
262 .text("csrf", csrf.clone())
263 .text("category", params.category)
264 .text("biz", "new_dyn".to_string());
265
266 self.client
267 .post(UPLOAD_PIC_ENDPOINT)
268 .multipart(form)
269 .send_bpi_payload("dynamic.pic.upload")
270 .await
271 }
272
273 pub async fn create_text(
275 &self,
276 params: DynamicTextCreateParams,
277 ) -> BpiResult<CreateDynamicData> {
278 let csrf = self.client.csrf()?;
279 let form = Form::new()
280 .text("dynamic_id", "0")
281 .text("type", "4")
282 .text("rid", "0")
283 .text("content", params.content)
284 .text("csrf", csrf.clone())
285 .text("csrf_token", csrf);
286
287 self.client
288 .post(CREATE_TEXT_ENDPOINT)
289 .multipart(form)
290 .send_bpi_payload("dynamic.text.create")
291 .await
292 }
293
294 pub async fn create_complex(
296 &self,
297 params: DynamicComplexCreateParams,
298 ) -> BpiResult<CreateComplexDynamicData> {
299 let csrf = self.client.csrf()?;
300 let request_body = params.request_body();
301
302 self.client
303 .post(CREATE_COMPLEX_ENDPOINT)
304 .header("Content-Type", "application/json")
305 .query(&[("csrf", csrf)])
306 .body(request_body.to_string())
307 .send_bpi_payload("dynamic.complex.create")
308 .await
309 }
310}
311
312fn normalize_non_blank(field: &'static str, value: String) -> BpiResult<String> {
313 let value = value.trim().to_string();
314 if value.is_empty() {
315 return Err(BpiError::invalid_parameter(field, "value cannot be blank"));
316 }
317
318 Ok(value)
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 #[test]
326 fn dynamic_text_create_params_rejects_blank_content() {
327 let err = DynamicTextCreateParams::new(" ").unwrap_err();
328
329 assert!(matches!(
330 err,
331 BpiError::InvalidParameter {
332 field: "content",
333 ..
334 }
335 ));
336 }
337}