1use crate::BilibiliRequest;
6use crate::BpiError;
7use crate::response::BpiResult;
8use crate::video::VideoClient;
9use serde::{Deserialize, Serialize};
10
11const LIKE_ENDPOINT: &str = "https://api.bilibili.com/x/web-interface/archive/like";
12const COIN_ENDPOINT: &str = "https://api.bilibili.com/x/web-interface/coin/add";
13const COIN_STATUS_ENDPOINT: &str = "https://api.bilibili.com/x/web-interface/archive/coins";
14const FAVORITE_ENDPOINT: &str = "https://api.bilibili.com/x/v3/fav/resource/deal";
15
16#[derive(Debug, Serialize, Clone, Deserialize)]
18pub struct CoinData {
19 pub like: bool,
21}
22
23#[derive(Debug, Serialize, Clone, Deserialize)]
25pub struct FavoriteData {
26 pub prompt: bool,
28 pub ga_data: Option<serde_json::Value>,
30 pub toast_msg: Option<String>,
32 pub success_num: u32,
34}
35
36#[derive(Debug, Serialize, Clone, Deserialize, PartialEq, Eq)]
38pub struct VideoCoinStatusData {
39 pub multiply: u8,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44struct LegacyVideoIdForm {
45 aid: String,
46 bvid: String,
47}
48
49impl LegacyVideoIdForm {
50 fn form_pairs(&self) -> Vec<(&'static str, String)> {
51 let mut pairs = Vec::new();
52 if self.aid != "0" {
53 pairs.push(("aid", self.aid.clone()));
54 }
55 if !self.bvid.is_empty() {
56 pairs.push(("bvid", self.bvid.clone()));
57 }
58 pairs
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct VideoCoinStatusParams {
65 video_id: LegacyVideoIdForm,
66}
67
68impl VideoCoinStatusParams {
69 pub fn from_aid(aid: u64) -> BpiResult<Self> {
70 Self::from_ids(Some(aid), None)
71 }
72
73 pub fn from_bvid(bvid: impl Into<String>) -> BpiResult<Self> {
74 Self::from_ids(None, Some(bvid.into()))
75 }
76
77 pub fn from_ids(aid: Option<u64>, bvid: Option<String>) -> BpiResult<Self> {
78 Ok(Self {
79 video_id: legacy_video_id_form(aid, bvid)?,
80 })
81 }
82
83 fn query_pairs(&self) -> Vec<(&'static str, String)> {
84 self.video_id.form_pairs()
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct VideoLikeParams {
91 video_id: LegacyVideoIdForm,
92 like: u8,
93}
94
95impl VideoLikeParams {
96 pub fn from_aid(aid: u64, like: u8) -> BpiResult<Self> {
97 Self::from_ids(Some(aid), None, like)
98 }
99
100 pub fn from_bvid(bvid: impl Into<String>, like: u8) -> BpiResult<Self> {
101 Self::from_ids(None, Some(bvid.into()), like)
102 }
103
104 pub fn from_ids(aid: Option<u64>, bvid: Option<String>, like: u8) -> BpiResult<Self> {
105 let video_id = legacy_video_id_form(aid, bvid)?;
106 validate_like_action(like)?;
107
108 Ok(Self { video_id, like })
109 }
110
111 fn form_pairs(&self, csrf: &str) -> Vec<(&'static str, String)> {
112 let mut pairs = self.video_id.form_pairs();
113 pairs.push(("like", self.like.to_string()));
114 pairs.push(("csrf", csrf.to_string()));
115 pairs
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct VideoCoinParams {
122 video_id: LegacyVideoIdForm,
123 multiply: u8,
124 select_like: u8,
125}
126
127impl VideoCoinParams {
128 pub fn from_aid(aid: u64, multiply: u8) -> BpiResult<Self> {
129 Self::from_ids(Some(aid), None, multiply)
130 }
131
132 pub fn from_bvid(bvid: impl Into<String>, multiply: u8) -> BpiResult<Self> {
133 Self::from_ids(None, Some(bvid.into()), multiply)
134 }
135
136 pub fn from_ids(aid: Option<u64>, bvid: Option<String>, multiply: u8) -> BpiResult<Self> {
137 let video_id = legacy_video_id_form(aid, bvid)?;
138 validate_coin_multiply(multiply)?;
139
140 Ok(Self {
141 video_id,
142 multiply,
143 select_like: 0,
144 })
145 }
146
147 pub fn select_like(mut self, select_like: bool) -> Self {
148 self.select_like = u8::from(select_like);
149 self
150 }
151
152 fn form_pairs(&self, csrf: &str) -> Vec<(&'static str, String)> {
153 let mut pairs = self.video_id.form_pairs();
154 pairs.push(("multiply", self.multiply.to_string()));
155 pairs.push(("select_like", self.select_like.to_string()));
156 pairs.push(("csrf", csrf.to_string()));
157 pairs
158 }
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct VideoFavoriteParams {
164 rid: u64,
165 add_media_ids: Vec<String>,
166 del_media_ids: Vec<String>,
167}
168
169impl VideoFavoriteParams {
170 pub fn new(
171 rid: u64,
172 add_media_ids: impl IntoIterator<Item = impl Into<String>>,
173 del_media_ids: impl IntoIterator<Item = impl Into<String>>,
174 ) -> BpiResult<Self> {
175 if rid == 0 {
176 return Err(BpiError::invalid_parameter("rid", "id must be non-zero"));
177 }
178
179 let add_media_ids = normalize_id_list("add_media_ids", add_media_ids)?;
180 let del_media_ids = normalize_id_list("del_media_ids", del_media_ids)?;
181
182 if add_media_ids.is_empty() && del_media_ids.is_empty() {
183 return Err(BpiError::invalid_parameter(
184 "media_ids",
185 "at least one add or delete media id is required",
186 ));
187 }
188
189 Ok(Self {
190 rid,
191 add_media_ids,
192 del_media_ids,
193 })
194 }
195
196 fn form_pairs(&self, csrf: &str) -> Vec<(&'static str, String)> {
197 let mut pairs = vec![
198 ("rid", self.rid.to_string()),
199 ("type", "2".to_string()),
200 ("csrf", csrf.to_string()),
201 ];
202
203 if !self.add_media_ids.is_empty() {
204 pairs.push(("add_media_ids", self.add_media_ids.join(",")));
205 }
206 if !self.del_media_ids.is_empty() {
207 pairs.push(("del_media_ids", self.del_media_ids.join(",")));
208 }
209
210 pairs
211 }
212}
213
214impl<'a> VideoClient<'a> {
215 pub async fn coin_status(
219 &self,
220 params: VideoCoinStatusParams,
221 ) -> BpiResult<VideoCoinStatusData> {
222 self.client
223 .get(COIN_STATUS_ENDPOINT)
224 .with_bilibili_headers()
225 .query(¶ms.query_pairs())
226 .send_bpi_payload("video.coin_status")
227 .await
228 }
229
230 pub async fn like(&self, params: VideoLikeParams) -> BpiResult<Option<serde_json::Value>> {
232 let csrf = self.client.csrf()?;
233
234 self.client
235 .post(LIKE_ENDPOINT)
236 .with_bilibili_headers()
237 .form(¶ms.form_pairs(&csrf))
238 .send_bpi_optional_payload("video.like")
239 .await
240 }
241
242 pub async fn coin(&self, params: VideoCoinParams) -> BpiResult<CoinData> {
247 let csrf = self.client.csrf()?;
248
249 self.client
250 .post(COIN_ENDPOINT)
251 .with_bilibili_headers()
252 .form(¶ms.form_pairs(&csrf))
253 .send_bpi_payload("video.coin")
254 .await
255 }
256
257 pub async fn favorite(&self, params: VideoFavoriteParams) -> BpiResult<FavoriteData> {
259 let csrf = self.client.csrf()?;
260
261 self.client
262 .post(FAVORITE_ENDPOINT)
263 .with_bilibili_headers()
264 .form(¶ms.form_pairs(&csrf))
265 .send_bpi_payload("video.favorite")
266 .await
267 }
268}
269
270fn legacy_video_id_form(
271 aid: Option<u64>,
272 bvid: Option<String>,
273) -> Result<LegacyVideoIdForm, BpiError> {
274 let aid = match aid {
275 Some(0) => {
276 return Err(BpiError::invalid_parameter("aid", "id must be non-zero"));
277 }
278 Some(aid) => Some(aid.to_string()),
279 None => None,
280 };
281
282 let bvid = match bvid {
283 Some(bvid) if bvid.trim().is_empty() => {
284 return Err(BpiError::invalid_parameter("bvid", "bvid cannot be blank"));
285 }
286 Some(bvid) => Some(bvid),
287 None => None,
288 };
289
290 if aid.is_none() && bvid.is_none() {
291 return Err(BpiError::invalid_parameter(
292 "video_id",
293 "aid or bvid is required",
294 ));
295 }
296
297 Ok(LegacyVideoIdForm {
298 aid: aid.unwrap_or_else(|| "0".to_string()),
299 bvid: bvid.unwrap_or_default(),
300 })
301}
302
303fn validate_like_action(like: u8) -> Result<(), BpiError> {
304 if matches!(like, 1 | 2) {
305 return Ok(());
306 }
307
308 Err(BpiError::invalid_parameter("like", "value must be 1 or 2"))
309}
310
311fn validate_coin_multiply(multiply: u8) -> Result<(), BpiError> {
312 if matches!(multiply, 1 | 2) {
313 return Ok(());
314 }
315
316 Err(BpiError::invalid_parameter(
317 "multiply",
318 "value must be 1 or 2",
319 ))
320}
321
322fn normalize_id_list(
323 field: &'static str,
324 values: impl IntoIterator<Item = impl Into<String>>,
325) -> BpiResult<Vec<String>> {
326 values
327 .into_iter()
328 .map(|value| {
329 let value = value.into();
330 let value = value.trim();
331 if value.is_empty() {
332 return Err(BpiError::invalid_parameter(field, "value cannot be blank"));
333 }
334
335 Ok(value.to_string())
336 })
337 .collect()
338}
339
340#[cfg(test)]
341mod tests {
342 use crate::{
343 BpiClient, BpiError,
344 session::{Account, AccountProfile},
345 };
346
347 use super::{
348 VideoCoinParams, VideoCoinStatusParams, VideoFavoriteParams, VideoLikeParams,
349 legacy_video_id_form, validate_coin_multiply,
350 };
351
352 #[test]
353 fn legacy_video_id_form_rejects_missing_video_id() {
354 let err = legacy_video_id_form(None, None).unwrap_err();
355
356 assert!(matches!(
357 err,
358 BpiError::InvalidParameter {
359 field: "video_id",
360 ..
361 }
362 ));
363 }
364
365 #[test]
366 fn validate_coin_multiply_rejects_oversized_value() {
367 let err = validate_coin_multiply(3).unwrap_err();
368
369 assert!(matches!(
370 err,
371 BpiError::InvalidParameter {
372 field: "multiply",
373 ..
374 }
375 ));
376 }
377
378 #[test]
379 fn video_like_params_serializes_aid() -> Result<(), BpiError> {
380 let params = VideoLikeParams::from_aid(170001, 1)?;
381
382 assert_eq!(
383 params.form_pairs("csrf-token"),
384 vec![
385 ("aid", "170001".to_string()),
386 ("like", "1".to_string()),
387 ("csrf", "csrf-token".to_string()),
388 ]
389 );
390 Ok(())
391 }
392
393 #[test]
394 fn video_coin_params_defaults_select_like_to_false() -> Result<(), BpiError> {
395 let params = VideoCoinParams::from_bvid("BV1xx411c7mD", 2)?;
396
397 assert_eq!(
398 params.form_pairs("csrf-token"),
399 vec![
400 ("bvid", "BV1xx411c7mD".to_string()),
401 ("multiply", "2".to_string()),
402 ("select_like", "0".to_string()),
403 ("csrf", "csrf-token".to_string()),
404 ]
405 );
406 Ok(())
407 }
408
409 #[test]
410 fn video_coin_status_params_serializes_bvid() -> Result<(), BpiError> {
411 let params = VideoCoinStatusParams::from_bvid("BV1xx411c7mD")?;
412
413 assert_eq!(
414 params.query_pairs(),
415 vec![("bvid", "BV1xx411c7mD".to_string())]
416 );
417 Ok(())
418 }
419
420 #[test]
421 fn video_favorite_params_rejects_empty_operation() {
422 let err = VideoFavoriteParams::new(170001, Vec::<String>::new(), Vec::<String>::new())
423 .unwrap_err();
424
425 assert!(matches!(
426 err,
427 BpiError::InvalidParameter {
428 field: "media_ids",
429 ..
430 }
431 ));
432 }
433
434 #[test]
435 fn video_favorite_params_rejects_blank_media_id() {
436 let err = VideoFavoriteParams::new(170001, [" "], Vec::<String>::new()).unwrap_err();
437
438 assert!(matches!(
439 err,
440 BpiError::InvalidParameter {
441 field: "add_media_ids",
442 ..
443 }
444 ));
445 }
446
447 #[test]
448 fn video_action_methods_use_documented_payload_shapes() {
449 let source = include_str!("action.rs")
450 .split("#[cfg(test)]")
451 .next()
452 .expect("production source should precede tests");
453 let optional_helper = concat!(".send_", "bpi_optional_payload");
454 let payload_helper = concat!(".send_", "bpi_payload");
455
456 assert!(source.contains(&format!("{payload_helper}(\"video.coin_status\")")));
457 assert!(source.contains(&format!("{optional_helper}(\"video.like\")")));
458 assert!(source.contains(&format!("{payload_helper}(\"video.coin\")")));
459 }
460
461 fn live_mutating_tests_enabled() -> bool {
462 std::env::var("BPI_MUTATING_TEST").ok().as_deref() == Some("1")
463 }
464
465 #[derive(Debug, Clone, PartialEq, Eq)]
466 enum LiveCoinTarget {
467 Aid(u64),
468 Bvid(String),
469 }
470
471 impl LiveCoinTarget {
472 fn label(&self) -> String {
473 match self {
474 Self::Aid(aid) => format!("aid={aid}"),
475 Self::Bvid(bvid) => format!("bvid={bvid}"),
476 }
477 }
478
479 fn coin_status_params(&self) -> Result<VideoCoinStatusParams, BpiError> {
480 match self {
481 Self::Aid(aid) => VideoCoinStatusParams::from_aid(*aid),
482 Self::Bvid(bvid) => VideoCoinStatusParams::from_bvid(bvid.as_str()),
483 }
484 }
485
486 fn coin_params(&self, multiply: u8) -> Result<VideoCoinParams, BpiError> {
487 match self {
488 Self::Aid(aid) => VideoCoinParams::from_aid(*aid, multiply),
489 Self::Bvid(bvid) => VideoCoinParams::from_bvid(bvid.as_str(), multiply),
490 }
491 }
492 }
493
494 fn live_coin_target_from_env() -> Result<LiveCoinTarget, BpiError> {
495 parse_live_coin_target(
496 std::env::var("BPI_VIDEO_AID").ok(),
497 std::env::var("BPI_VIDEO_BVID").ok(),
498 )
499 }
500
501 fn parse_live_coin_target(
502 aid: Option<String>,
503 bvid: Option<String>,
504 ) -> Result<LiveCoinTarget, BpiError> {
505 if let Some(aid) = aid {
506 let aid = aid.trim();
507 if !aid.is_empty() {
508 let aid = aid.parse::<u64>().map_err(|_| {
509 BpiError::invalid_parameter("BPI_VIDEO_AID", "value must be a non-zero avid")
510 })?;
511 if aid == 0 {
512 return Err(BpiError::invalid_parameter(
513 "BPI_VIDEO_AID",
514 "value must be a non-zero avid",
515 ));
516 }
517 return Ok(LiveCoinTarget::Aid(aid));
518 }
519 }
520
521 if let Some(bvid) = bvid {
522 let bvid = bvid.trim();
523 if !bvid.is_empty() {
524 return Ok(LiveCoinTarget::Bvid(bvid.to_string()));
525 }
526 }
527
528 Err(BpiError::invalid_parameter(
529 "BPI_VIDEO_AID",
530 "set BPI_VIDEO_AID or BPI_VIDEO_BVID",
531 ))
532 }
533
534 fn live_coin_multiply_from_env() -> Result<u8, BpiError> {
535 parse_live_coin_multiply(std::env::var("BPI_VIDEO_COIN_MULTIPLY").ok())
536 }
537
538 fn parse_live_coin_multiply(value: Option<String>) -> Result<u8, BpiError> {
539 let Some(value) = value else {
540 return Ok(1);
541 };
542 let value = value.trim();
543 if value.is_empty() {
544 return Ok(1);
545 }
546
547 let multiply = value.parse::<u8>().map_err(|_| {
548 BpiError::invalid_parameter("BPI_VIDEO_COIN_MULTIPLY", "value must be 1 or 2")
549 })?;
550 if matches!(multiply, 1 | 2) {
551 return Ok(multiply);
552 }
553
554 Err(BpiError::invalid_parameter(
555 "BPI_VIDEO_COIN_MULTIPLY",
556 "value must be 1 or 2",
557 ))
558 }
559
560 #[test]
561 fn live_coin_target_from_env_prefers_aid() -> Result<(), BpiError> {
562 let target = parse_live_coin_target(Some("116856715220362".to_string()), None)?;
563
564 assert_eq!(target.label(), "aid=116856715220362");
565 Ok(())
566 }
567
568 #[test]
569 fn live_coin_target_from_env_accepts_bvid() -> Result<(), BpiError> {
570 let target = parse_live_coin_target(None, Some("BV11GTb6GEXX".to_string()))?;
571
572 assert_eq!(target.label(), "bvid=BV11GTb6GEXX");
573 Ok(())
574 }
575
576 #[test]
577 fn live_coin_target_from_env_requires_a_video_id() {
578 let err = parse_live_coin_target(None, None).unwrap_err();
579
580 assert!(matches!(
581 err,
582 BpiError::InvalidParameter {
583 field: "BPI_VIDEO_AID",
584 ..
585 }
586 ));
587 }
588
589 #[test]
590 fn live_coin_multiply_from_env_defaults_to_one() -> Result<(), BpiError> {
591 assert_eq!(parse_live_coin_multiply(None)?, 1);
592 Ok(())
593 }
594
595 #[test]
596 fn live_coin_multiply_from_env_rejects_invalid_value() {
597 let err = parse_live_coin_multiply(Some("3".to_string())).unwrap_err();
598
599 assert!(matches!(
600 err,
601 BpiError::InvalidParameter {
602 field: "BPI_VIDEO_COIN_MULTIPLY",
603 ..
604 }
605 ));
606 }
607
608 #[ignore = "live mutating test; requires BPI_MUTATING_TEST=1 plus BPI_VIDEO_AID or BPI_VIDEO_BVID"]
609 #[tokio::test]
610 async fn live_vip_coin_from_env_spends_requested_coins() -> Result<(), BpiError> {
611 if !live_mutating_tests_enabled() {
612 eprintln!(
613 "skipping live mutating test; set BPI_MUTATING_TEST=1, BPI_VIDEO_AID or BPI_VIDEO_BVID, and optional BPI_VIDEO_COIN_MULTIPLY"
614 );
615 return Ok(());
616 }
617
618 let target = live_coin_target_from_env()?;
619 let multiply = live_coin_multiply_from_env()?;
620 let account = Account::load_test_account_profile(AccountProfile::Vip)?;
621 let client = BpiClient::builder().account(account).build()?;
622 let video = client.video();
623
624 match video.coin_status(target.coin_status_params()?).await {
625 Ok(status) => eprintln!("coin status before {}: {status:?}", target.label()),
626 Err(err) => eprintln!("coin status before {} failed: {err:?}", target.label()),
627 }
628
629 match video.coin(target.coin_params(multiply)?).await {
630 Ok(payload) => eprintln!(
631 "coin succeeded for {} with multiply={multiply}: {payload:?}",
632 target.label()
633 ),
634 Err(err) => {
635 eprintln!(
636 "coin failed for {} with multiply={multiply}: {err:?}",
637 target.label()
638 );
639 return Err(err);
640 }
641 }
642
643 match video.coin_status(target.coin_status_params()?).await {
644 Ok(status) => eprintln!("coin status after {}: {status:?}", target.label()),
645 Err(err) => eprintln!("coin status after {} failed: {err:?}", target.label()),
646 }
647
648 Ok(())
649 }
650
651 #[ignore = "local account shape diagnostic; prints presence only, never secret values"]
652 #[test]
653 fn live_vip_account_shape_has_expected_cookie_fields() -> Result<(), BpiError> {
654 let account = Account::load_test_account_profile(AccountProfile::Vip)?;
655
656 eprintln!("vip has DedeUserID: {}", !account.dede_user_id.is_empty());
657 eprintln!("vip has SESSDATA: {}", !account.sessdata.is_empty());
658 eprintln!("vip has bili_jct: {}", !account.bili_jct.is_empty());
659 eprintln!("vip has buvid3: {}", !account.buvid3.is_empty());
660
661 Ok(())
662 }
663
664 #[ignore = "live read diagnostic; requires BPI_VIDEO_AID or BPI_VIDEO_BVID"]
665 #[tokio::test]
666 async fn live_vip_coin_status_from_env() -> Result<(), BpiError> {
667 let target = live_coin_target_from_env()?;
668 let account = Account::load_test_account_profile(AccountProfile::Vip)?;
669 let client = BpiClient::builder().account(account).build()?;
670 let video = client.video();
671
672 let status = video.coin_status(target.coin_status_params()?).await?;
673 eprintln!("coin status for {}: {status:?}", target.label());
674
675 Ok(())
676 }
677}