Skip to main content

bpi_rs/video/collection/
action.rs

1// B站视频合集相关接口实现
2//
3// [查看 API 文档](https://github.com/SocialSisterYi/bilibili-API-collect/tree/master/docs/video)
4
5// --- 响应数据结构体 ---
6
7use crate::BilibiliRequest;
8use crate::BpiError;
9use crate::BpiResult;
10use crate::video::VideoClient;
11use serde::{Deserialize, Serialize};
12
13const CREATE_AND_ADD_ARCHIVES_ENDPOINT: &str =
14    "https://api.bilibili.com/x/series/series/createAndAddArchives";
15const DELETE_SERIES_ENDPOINT: &str = "https://api.bilibili.com/x/series/series/delete";
16const DELETE_ARCHIVES_ENDPOINT: &str = "https://api.bilibili.com/x/series/series/delArchives";
17const ADD_ARCHIVES_ENDPOINT: &str = "https://api.bilibili.com/x/series/series/addArchives";
18const UPDATE_SERIES_ENDPOINT: &str = "https://api.bilibili.com/x/series/series/update";
19
20/// 创建视频列表响应数据
21
22#[derive(Debug, Clone, Deserialize, Serialize)]
23pub struct CreateSeriesResponseData {
24    /// 视频列表 ID
25    pub series_id: u64,
26}
27
28/// Parameters for creating a video series and adding archives to it.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct CollectionCreateAndAddArchivesParams {
31    mid: u64,
32    name: String,
33    keywords: Option<String>,
34    description: Option<String>,
35    aids: Option<String>,
36}
37
38impl CollectionCreateAndAddArchivesParams {
39    pub fn new(mid: u64, name: impl Into<String>) -> BpiResult<Self> {
40        Ok(Self {
41            mid: validate_nonzero_u64("mid", mid)?,
42            name: normalize_non_blank("name", name.into())?,
43            keywords: None,
44            description: None,
45            aids: None,
46        })
47    }
48
49    pub fn keywords(mut self, keywords: impl Into<String>) -> BpiResult<Self> {
50        self.keywords = Some(normalize_non_blank("keywords", keywords.into())?);
51        Ok(self)
52    }
53
54    pub fn description(mut self, description: impl Into<String>) -> BpiResult<Self> {
55        self.description = Some(normalize_non_blank("description", description.into())?);
56        Ok(self)
57    }
58
59    pub fn aids(mut self, aids: impl Into<String>) -> BpiResult<Self> {
60        self.aids = Some(normalize_non_blank("aids", aids.into())?);
61        Ok(self)
62    }
63
64    fn into_multipart(self) -> reqwest::multipart::Form {
65        let mut form = reqwest::multipart::Form::new()
66            .text("mid", self.mid.to_string())
67            .text("name", self.name);
68
69        if let Some(keywords) = self.keywords {
70            form = form.text("keywords", keywords);
71        }
72        if let Some(description) = self.description {
73            form = form.text("description", description);
74        }
75        if let Some(aids) = self.aids {
76            form = form.text("aids", aids);
77        }
78
79        form
80    }
81}
82
83/// Parameters for deleting a video series.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub struct CollectionDeleteSeriesParams {
86    mid: u64,
87    series_id: u64,
88}
89
90impl CollectionDeleteSeriesParams {
91    pub fn new(mid: u64, series_id: u64) -> BpiResult<Self> {
92        Ok(Self {
93            mid: validate_nonzero_u64("mid", mid)?,
94            series_id: validate_nonzero_u64("series_id", series_id)?,
95        })
96    }
97
98    fn query_pairs(&self, csrf: &str) -> Vec<(&'static str, String)> {
99        vec![
100            ("csrf", csrf.to_string()),
101            ("mid", self.mid.to_string()),
102            ("series_id", self.series_id.to_string()),
103            ("aids", String::new()),
104        ]
105    }
106}
107
108/// Parameters for adding or deleting archives in a video series.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct CollectionArchivesMutationParams {
111    mid: u64,
112    series_id: u64,
113    aids: String,
114}
115
116impl CollectionArchivesMutationParams {
117    pub fn new(mid: u64, series_id: u64, aids: impl Into<String>) -> BpiResult<Self> {
118        Ok(Self {
119            mid: validate_nonzero_u64("mid", mid)?,
120            series_id: validate_nonzero_u64("series_id", series_id)?,
121            aids: normalize_non_blank("aids", aids.into())?,
122        })
123    }
124
125    fn form_pairs(&self) -> Vec<(&'static str, String)> {
126        vec![
127            ("mid", self.mid.to_string()),
128            ("series_id", self.series_id.to_string()),
129            ("aids", self.aids.clone()),
130        ]
131    }
132}
133
134/// Parameters for editing an existing video series.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct CollectionUpdateSeriesParams {
137    mid: u64,
138    series_id: u64,
139    name: String,
140    keywords: Option<String>,
141    description: Option<String>,
142    add_aids: Option<String>,
143    del_aids: Option<String>,
144}
145
146impl CollectionUpdateSeriesParams {
147    /// Creates parameters with the required account, series and title fields.
148    pub fn new(mid: u64, series_id: u64, name: impl Into<String>) -> BpiResult<Self> {
149        Ok(Self {
150            mid: validate_nonzero_u64("mid", mid)?,
151            series_id: validate_nonzero_u64("series_id", series_id)?,
152            name: normalize_non_blank("name", name.into())?,
153            keywords: None,
154            description: None,
155            add_aids: None,
156            del_aids: None,
157        })
158    }
159
160    /// Sets comma-separated keywords.
161    pub fn keywords(mut self, keywords: impl Into<String>) -> BpiResult<Self> {
162        self.keywords = Some(normalize_non_blank("keywords", keywords.into())?);
163        Ok(self)
164    }
165
166    /// Sets the series description.
167    pub fn description(mut self, description: impl Into<String>) -> BpiResult<Self> {
168        self.description = Some(normalize_non_blank("description", description.into())?);
169        Ok(self)
170    }
171
172    /// Sets comma-separated AIDs to add to the series.
173    pub fn add_aids(mut self, aids: impl Into<String>) -> BpiResult<Self> {
174        self.add_aids = Some(normalize_non_blank("add_aids", aids.into())?);
175        Ok(self)
176    }
177
178    /// Sets comma-separated AIDs to remove from the series.
179    pub fn del_aids(mut self, aids: impl Into<String>) -> BpiResult<Self> {
180        self.del_aids = Some(normalize_non_blank("del_aids", aids.into())?);
181        Ok(self)
182    }
183
184    fn form_pairs(&self) -> Vec<(&'static str, String)> {
185        let mut form = vec![
186            ("mid", self.mid.to_string()),
187            ("series_id", self.series_id.to_string()),
188            ("name", self.name.clone()),
189        ];
190
191        if let Some(keywords) = self.keywords.as_ref() {
192            form.push(("keywords", keywords.clone()));
193        }
194        if let Some(description) = self.description.as_ref() {
195            form.push(("description", description.clone()));
196        }
197        if let Some(add_aids) = self.add_aids.as_ref() {
198            form.push(("add_aids", add_aids.clone()));
199        }
200        if let Some(del_aids) = self.del_aids.as_ref() {
201            form.push(("del_aids", del_aids.clone()));
202        }
203
204        form
205    }
206
207    fn into_multipart(self) -> reqwest::multipart::Form {
208        self.form_pairs()
209            .into_iter()
210            .fold(reqwest::multipart::Form::new(), |form, (key, value)| {
211                form.text(key, value)
212            })
213    }
214}
215
216// --- 测试模块 ---
217
218impl<'a> VideoClient<'a> {
219    /// Creates a video series and optionally adds archives to it.
220    pub async fn create_collection_series(
221        &self,
222        params: CollectionCreateAndAddArchivesParams,
223    ) -> BpiResult<CreateSeriesResponseData> {
224        let csrf = self.client.csrf()?;
225        let form = params.into_multipart();
226
227        self.client
228            .post(CREATE_AND_ADD_ARCHIVES_ENDPOINT)
229            .query(&[("csrf", csrf)])
230            .multipart(form)
231            .send_bpi_payload("video.collection.series.create")
232            .await
233    }
234
235    /// Deletes a video series.
236    pub async fn delete_collection_series(
237        &self,
238        params: CollectionDeleteSeriesParams,
239    ) -> BpiResult<Option<serde_json::Value>> {
240        let csrf = self.client.csrf()?;
241
242        self.client
243            .post(DELETE_SERIES_ENDPOINT)
244            .query(&params.query_pairs(&csrf))
245            .send_bpi_optional_payload("video.collection.series.delete")
246            .await
247    }
248
249    /// Deletes archives from a video series.
250    pub async fn delete_collection_archives(
251        &self,
252        params: CollectionArchivesMutationParams,
253    ) -> BpiResult<Option<serde_json::Value>> {
254        let csrf = self.client.csrf()?;
255
256        self.client
257            .post(DELETE_ARCHIVES_ENDPOINT)
258            .query(&[("csrf", csrf)])
259            .form(&params.form_pairs())
260            .send_bpi_optional_payload("video.collection.archives.delete")
261            .await
262    }
263
264    /// Adds archives to a video series.
265    pub async fn add_collection_archives(
266        &self,
267        params: CollectionArchivesMutationParams,
268    ) -> BpiResult<Option<serde_json::Value>> {
269        let csrf = self.client.csrf()?;
270
271        self.client
272            .post(ADD_ARCHIVES_ENDPOINT)
273            .query(&[("csrf", csrf)])
274            .form(&params.form_pairs())
275            .send_bpi_optional_payload("video.collection.archives.add")
276            .await
277    }
278
279    /// Updates a video series.
280    pub async fn update_collection_series(
281        &self,
282        params: CollectionUpdateSeriesParams,
283    ) -> BpiResult<Option<serde_json::Value>> {
284        let csrf = self.client.csrf()?;
285        let form = params.into_multipart();
286
287        self.client
288            .post(UPDATE_SERIES_ENDPOINT)
289            .query(&[("csrf", csrf)])
290            .multipart(form)
291            .send_bpi_optional_payload("video.collection.series.update")
292            .await
293    }
294}
295
296fn validate_nonzero_u64(field: &'static str, value: u64) -> BpiResult<u64> {
297    if value == 0 {
298        return Err(BpiError::invalid_parameter(field, "value must be non-zero"));
299    }
300
301    Ok(value)
302}
303
304fn normalize_non_blank(field: &'static str, value: String) -> BpiResult<String> {
305    let value = value.trim().to_string();
306    if value.is_empty() {
307        return Err(BpiError::invalid_parameter(field, "value cannot be blank"));
308    }
309
310    Ok(value)
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    // 请在运行测试前设置环境变量 `BPI_COOKIE`,以包含 SESSDATA 等登录信息
318    // mid 和 series_id 根据实际情况修改
319
320    // 测试用的 mid
321    // 测试用的合集 ID
322
323    #[test]
324    fn collection_update_series_params_rejects_blank_name() {
325        let err = CollectionUpdateSeriesParams::new(42, 100, "  ").unwrap_err();
326
327        assert!(matches!(
328            err,
329            BpiError::InvalidParameter { field: "name", .. }
330        ));
331    }
332}