Skip to main content

bpi_rs/video/
action.rs

1// B站视频交互接口(Web端)
2//
3// [查看 API 文档](https://github.com/SocialSisterYi/bilibili-API-collect/tree/master/docs/video)
4
5use 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/// 投币视频 - 响应结构体
17#[derive(Debug, Serialize, Clone, Deserialize)]
18pub struct CoinData {
19    /// 是否点赞成功
20    pub like: bool,
21}
22
23/// 收藏视频 - 响应结构体
24#[derive(Debug, Serialize, Clone, Deserialize)]
25pub struct FavoriteData {
26    /// 是否为未关注用户收藏
27    pub prompt: bool,
28    /// 作用不明确
29    pub ga_data: Option<serde_json::Value>,
30    /// 提示消息
31    pub toast_msg: Option<String>,
32    /// 成功数
33    pub success_num: u32,
34}
35
36/// 当前账号对某个视频的投币状态。
37#[derive(Debug, Serialize, Clone, Deserialize, PartialEq, Eq)]
38pub struct VideoCoinStatusData {
39    /// 当前账号已给此视频投出的硬币数量。
40    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/// 检查当前账号是否已给视频投币的参数。
63#[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/// 视频点赞/取消点赞操作的参数。
89#[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/// 视频投币操作的参数。
120#[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/// 视频添加/移除收藏操作的参数。
162#[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    /// 检查当前账号已给视频投出多少硬币。
216    ///
217    /// [`Self::coin`] 成功后,上游 endpoint 可能会短暂滞后。
218    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(&params.query_pairs())
226            .send_bpi_payload("video.coin_status")
227            .await
228    }
229
230    /// 点赞或取消点赞视频,并返回可选的标准 payload 结果。
231    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(&params.form_pairs(&csrf))
238            .send_bpi_optional_payload("video.like")
239            .await
240    }
241
242    /// 给视频投币,并返回标准 payload 结果。
243    ///
244    /// Bilibili 的 Web endpoint 需要包含 `buvid3` 的正常登录 cookie 集合;
245    /// 即使存在 `SESSDATA` 和 `bili_jct`,缺少它的账号也可能触发风控。
246    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(&params.form_pairs(&csrf))
253            .send_bpi_payload("video.coin")
254            .await
255    }
256
257    /// 将视频添加到收藏夹,或从收藏夹中移除。
258    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(&params.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}