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 FAVORITE_ENDPOINT: &str = "https://api.bilibili.com/x/v3/fav/resource/deal";
14
15/// 投币视频 - 响应结构体
16#[derive(Debug, Serialize, Clone, Deserialize)]
17pub struct CoinData {
18    /// 是否点赞成功
19    pub like: bool,
20}
21
22/// 收藏视频 - 响应结构体
23#[derive(Debug, Serialize, Clone, Deserialize)]
24pub struct FavoriteData {
25    /// 是否为未关注用户收藏
26    pub prompt: bool,
27    /// 作用不明确
28    pub ga_data: Option<serde_json::Value>,
29    /// 提示消息
30    pub toast_msg: Option<String>,
31    /// 成功数
32    pub success_num: u32,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36struct LegacyVideoIdForm {
37    aid: String,
38    bvid: String,
39}
40
41/// Parameters for video like/unlike operations.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct VideoLikeParams {
44    video_id: LegacyVideoIdForm,
45    like: u8,
46}
47
48impl VideoLikeParams {
49    pub fn from_aid(aid: u64, like: u8) -> BpiResult<Self> {
50        Self::from_ids(Some(aid), None, like)
51    }
52
53    pub fn from_bvid(bvid: impl Into<String>, like: u8) -> BpiResult<Self> {
54        Self::from_ids(None, Some(bvid.into()), like)
55    }
56
57    pub fn from_ids(aid: Option<u64>, bvid: Option<String>, like: u8) -> BpiResult<Self> {
58        let video_id = legacy_video_id_form(aid, bvid)?;
59        validate_like_action(like)?;
60
61        Ok(Self { video_id, like })
62    }
63
64    fn form_pairs(&self, csrf: &str) -> Vec<(&'static str, String)> {
65        vec![
66            ("aid", self.video_id.aid.clone()),
67            ("bvid", self.video_id.bvid.clone()),
68            ("like", self.like.to_string()),
69            ("csrf", csrf.to_string()),
70        ]
71    }
72}
73
74/// Parameters for video coin operations.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct VideoCoinParams {
77    video_id: LegacyVideoIdForm,
78    multiply: u8,
79    select_like: u8,
80}
81
82impl VideoCoinParams {
83    pub fn from_aid(aid: u64, multiply: u8) -> BpiResult<Self> {
84        Self::from_ids(Some(aid), None, multiply)
85    }
86
87    pub fn from_bvid(bvid: impl Into<String>, multiply: u8) -> BpiResult<Self> {
88        Self::from_ids(None, Some(bvid.into()), multiply)
89    }
90
91    pub fn from_ids(aid: Option<u64>, bvid: Option<String>, multiply: u8) -> BpiResult<Self> {
92        let video_id = legacy_video_id_form(aid, bvid)?;
93        validate_coin_multiply(multiply)?;
94
95        Ok(Self {
96            video_id,
97            multiply,
98            select_like: 0,
99        })
100    }
101
102    pub fn select_like(mut self, select_like: bool) -> Self {
103        self.select_like = u8::from(select_like);
104        self
105    }
106
107    fn form_pairs(&self, csrf: &str) -> Vec<(&'static str, String)> {
108        vec![
109            ("aid", self.video_id.aid.clone()),
110            ("bvid", self.video_id.bvid.clone()),
111            ("multiply", self.multiply.to_string()),
112            ("select_like", self.select_like.to_string()),
113            ("csrf", csrf.to_string()),
114        ]
115    }
116}
117
118/// Parameters for video favorite add/remove operations.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct VideoFavoriteParams {
121    rid: u64,
122    add_media_ids: Vec<String>,
123    del_media_ids: Vec<String>,
124}
125
126impl VideoFavoriteParams {
127    pub fn new(
128        rid: u64,
129        add_media_ids: impl IntoIterator<Item = impl Into<String>>,
130        del_media_ids: impl IntoIterator<Item = impl Into<String>>,
131    ) -> BpiResult<Self> {
132        if rid == 0 {
133            return Err(BpiError::invalid_parameter("rid", "id must be non-zero"));
134        }
135
136        let add_media_ids = normalize_id_list("add_media_ids", add_media_ids)?;
137        let del_media_ids = normalize_id_list("del_media_ids", del_media_ids)?;
138
139        if add_media_ids.is_empty() && del_media_ids.is_empty() {
140            return Err(BpiError::invalid_parameter(
141                "media_ids",
142                "at least one add or delete media id is required",
143            ));
144        }
145
146        Ok(Self {
147            rid,
148            add_media_ids,
149            del_media_ids,
150        })
151    }
152
153    fn form_pairs(&self, csrf: &str) -> Vec<(&'static str, String)> {
154        let mut pairs = vec![
155            ("rid", self.rid.to_string()),
156            ("type", "2".to_string()),
157            ("csrf", csrf.to_string()),
158        ];
159
160        if !self.add_media_ids.is_empty() {
161            pairs.push(("add_media_ids", self.add_media_ids.join(",")));
162        }
163        if !self.del_media_ids.is_empty() {
164            pairs.push(("del_media_ids", self.del_media_ids.join(",")));
165        }
166
167        pairs
168    }
169}
170
171impl<'a> VideoClient<'a> {
172    /// Likes or unlikes a video and returns the canonical payload result.
173    pub async fn like(&self, params: VideoLikeParams) -> BpiResult<CoinData> {
174        let csrf = self.client.csrf()?;
175
176        self.client
177            .post(LIKE_ENDPOINT)
178            .with_bilibili_headers()
179            .form(&params.form_pairs(&csrf))
180            .send_bpi_payload("video.like")
181            .await
182    }
183
184    /// Gives coins to a video and returns the canonical payload result.
185    pub async fn coin(&self, params: VideoCoinParams) -> BpiResult<CoinData> {
186        let csrf = self.client.csrf()?;
187
188        self.client
189            .post(COIN_ENDPOINT)
190            .with_bilibili_headers()
191            .form(&params.form_pairs(&csrf))
192            .send_bpi_payload("video.coin")
193            .await
194    }
195
196    /// Favorites a video to, or removes it from, favorite folders.
197    pub async fn favorite(&self, params: VideoFavoriteParams) -> BpiResult<FavoriteData> {
198        let csrf = self.client.csrf()?;
199
200        self.client
201            .post(FAVORITE_ENDPOINT)
202            .with_bilibili_headers()
203            .form(&params.form_pairs(&csrf))
204            .send_bpi_payload("video.favorite")
205            .await
206    }
207}
208
209fn legacy_video_id_form(
210    aid: Option<u64>,
211    bvid: Option<String>,
212) -> Result<LegacyVideoIdForm, BpiError> {
213    let aid = match aid {
214        Some(0) => {
215            return Err(BpiError::invalid_parameter("aid", "id must be non-zero"));
216        }
217        Some(aid) => Some(aid.to_string()),
218        None => None,
219    };
220
221    let bvid = match bvid {
222        Some(bvid) if bvid.trim().is_empty() => {
223            return Err(BpiError::invalid_parameter("bvid", "bvid cannot be blank"));
224        }
225        Some(bvid) => Some(bvid),
226        None => None,
227    };
228
229    if aid.is_none() && bvid.is_none() {
230        return Err(BpiError::invalid_parameter(
231            "video_id",
232            "aid or bvid is required",
233        ));
234    }
235
236    Ok(LegacyVideoIdForm {
237        aid: aid.unwrap_or_else(|| "0".to_string()),
238        bvid: bvid.unwrap_or_default(),
239    })
240}
241
242fn validate_like_action(like: u8) -> Result<(), BpiError> {
243    if matches!(like, 1 | 2) {
244        return Ok(());
245    }
246
247    Err(BpiError::invalid_parameter("like", "value must be 1 or 2"))
248}
249
250fn validate_coin_multiply(multiply: u8) -> Result<(), BpiError> {
251    if matches!(multiply, 1 | 2) {
252        return Ok(());
253    }
254
255    Err(BpiError::invalid_parameter(
256        "multiply",
257        "value must be 1 or 2",
258    ))
259}
260
261fn normalize_id_list(
262    field: &'static str,
263    values: impl IntoIterator<Item = impl Into<String>>,
264) -> BpiResult<Vec<String>> {
265    values
266        .into_iter()
267        .map(|value| {
268            let value = value.into();
269            let value = value.trim();
270            if value.is_empty() {
271                return Err(BpiError::invalid_parameter(field, "value cannot be blank"));
272            }
273
274            Ok(value.to_string())
275        })
276        .collect()
277}
278
279#[cfg(test)]
280mod tests {
281    use crate::BpiError;
282
283    use super::{
284        VideoCoinParams, VideoFavoriteParams, VideoLikeParams, legacy_video_id_form,
285        validate_coin_multiply,
286    };
287
288    #[test]
289    fn legacy_video_id_form_rejects_missing_video_id() {
290        let err = legacy_video_id_form(None, None).unwrap_err();
291
292        assert!(matches!(
293            err,
294            BpiError::InvalidParameter {
295                field: "video_id",
296                ..
297            }
298        ));
299    }
300
301    #[test]
302    fn validate_coin_multiply_rejects_oversized_value() {
303        let err = validate_coin_multiply(3).unwrap_err();
304
305        assert!(matches!(
306            err,
307            BpiError::InvalidParameter {
308                field: "multiply",
309                ..
310            }
311        ));
312    }
313
314    #[test]
315    fn video_like_params_serializes_aid() -> Result<(), BpiError> {
316        let params = VideoLikeParams::from_aid(170001, 1)?;
317
318        assert_eq!(
319            params.form_pairs("csrf-token"),
320            vec![
321                ("aid", "170001".to_string()),
322                ("bvid", String::new()),
323                ("like", "1".to_string()),
324                ("csrf", "csrf-token".to_string()),
325            ]
326        );
327        Ok(())
328    }
329
330    #[test]
331    fn video_coin_params_defaults_select_like_to_false() -> Result<(), BpiError> {
332        let params = VideoCoinParams::from_bvid("BV1xx411c7mD", 2)?;
333
334        assert_eq!(
335            params.form_pairs("csrf-token"),
336            vec![
337                ("aid", "0".to_string()),
338                ("bvid", "BV1xx411c7mD".to_string()),
339                ("multiply", "2".to_string()),
340                ("select_like", "0".to_string()),
341                ("csrf", "csrf-token".to_string()),
342            ]
343        );
344        Ok(())
345    }
346
347    #[test]
348    fn video_favorite_params_rejects_empty_operation() {
349        let err = VideoFavoriteParams::new(170001, Vec::<String>::new(), Vec::<String>::new())
350            .unwrap_err();
351
352        assert!(matches!(
353            err,
354            BpiError::InvalidParameter {
355                field: "media_ids",
356                ..
357            }
358        ));
359    }
360
361    #[test]
362    fn video_favorite_params_rejects_blank_media_id() {
363        let err = VideoFavoriteParams::new(170001, [" "], Vec::<String>::new()).unwrap_err();
364
365        assert!(matches!(
366            err,
367            BpiError::InvalidParameter {
368                field: "add_media_ids",
369                ..
370            }
371        ));
372    }
373}