1use 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#[derive(Debug, Clone, Deserialize, Serialize)]
23pub struct CreateSeriesResponseData {
24 pub series_id: u64,
26}
27
28#[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#[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#[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#[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 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 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 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 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 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
216impl<'a> VideoClient<'a> {
219 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 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(¶ms.query_pairs(&csrf))
245 .send_bpi_optional_payload("video.collection.series.delete")
246 .await
247 }
248
249 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(¶ms.form_pairs())
260 .send_bpi_optional_payload("video.collection.archives.delete")
261 .await
262 }
263
264 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(¶ms.form_pairs())
275 .send_bpi_optional_payload("video.collection.archives.add")
276 .await
277 }
278
279 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 #[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}