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 FAVORITE_ENDPOINT: &str = "https://api.bilibili.com/x/v3/fav/resource/deal";
14
15#[derive(Debug, Serialize, Clone, Deserialize)]
17pub struct CoinData {
18 pub like: bool,
20}
21
22#[derive(Debug, Serialize, Clone, Deserialize)]
24pub struct FavoriteData {
25 pub prompt: bool,
27 pub ga_data: Option<serde_json::Value>,
29 pub toast_msg: Option<String>,
31 pub success_num: u32,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36struct LegacyVideoIdForm {
37 aid: String,
38 bvid: String,
39}
40
41#[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#[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#[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 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(¶ms.form_pairs(&csrf))
180 .send_bpi_payload("video.like")
181 .await
182 }
183
184 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(¶ms.form_pairs(&csrf))
192 .send_bpi_payload("video.coin")
193 .await
194 }
195
196 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(¶ms.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}