Skip to main content

bpi_rs/fav/
info.rs

1use serde::{Deserialize, Serialize};
2
3// --- 获取收藏夹元数据 ---
4
5/// 收藏夹元数据的创建者信息
6#[derive(Debug, Clone, Deserialize, Serialize)]
7pub struct FavFolderUpper {
8    pub mid: u64,
9    pub name: String,
10    pub face: String,
11    pub followed: bool,
12    pub vip_type: u8,
13    /// 阿b拼写错误
14    #[serde(rename = "vip_statue")]
15    pub vip_status: u8,
16}
17
18/// 收藏夹元数据的状态数
19#[derive(Debug, Clone, Deserialize, Serialize)]
20pub struct FavFolderCntInfo {
21    pub collect: u64,
22    pub play: u64,
23    pub thumb_up: u64,
24    pub share: u64,
25}
26
27/// 收藏夹元数据
28#[derive(Debug, Clone, Deserialize, Serialize)]
29pub struct FavFolderInfo {
30    pub id: u64,
31    pub fid: u64,
32    pub mid: u64,
33    pub attr: u32,
34    pub title: String,
35    pub cover: String,
36    pub upper: FavFolderUpper,
37    pub cover_type: u8,
38    pub cnt_info: FavFolderCntInfo,
39    #[serde(rename = "type")]
40    pub type_name: u32,
41    pub intro: String,
42    pub ctime: u64,
43    pub mtime: u64,
44    pub state: u8,
45    pub fav_state: u8,
46    pub like_state: u8,
47    pub media_count: u32,
48}
49
50// --- 获取指定用户创建的所有收藏夹信息 ---
51
52/// 用户创建的收藏夹列表项
53#[derive(Debug, Clone, Deserialize, Serialize)]
54pub struct CreatedFolderItem {
55    pub id: u64,
56    pub fid: u64,
57    pub mid: u64,
58    pub attr: u32,
59    pub title: String,
60    pub fav_state: u8,
61    pub media_count: u32,
62}
63
64/// 用户创建的收藏夹信息数据
65#[derive(Debug, Clone, Deserialize, Serialize)]
66pub struct CreatedFolderListData {
67    pub count: u32,
68    pub list: Vec<CreatedFolderItem>,
69}
70
71// --- 查询用户收藏的视频收藏夹 ---
72
73/// 用户收藏的视频收藏夹列表项的创建人信息
74#[derive(Debug, Clone, Deserialize, Serialize)]
75pub struct CollectedFolderUpper {
76    pub mid: u64,
77    pub name: String,
78    pub face: String,
79}
80
81/// 用户收藏的视频收藏夹列表项
82#[derive(Debug, Clone, Deserialize, Serialize)]
83pub struct CollectedFolderItem {
84    pub id: u64,
85    pub fid: u64,
86    pub mid: u64,
87    pub attr: u32,
88    pub title: String,
89    pub cover: String,
90    pub upper: CollectedFolderUpper,
91    pub cover_type: u8,
92    pub intro: String,
93    pub ctime: u64,
94    pub mtime: u64,
95    pub state: u8,
96    pub fav_state: u8,
97    pub media_count: u32,
98}
99
100/// 用户收藏的视频收藏夹列表数据
101#[derive(Debug, Clone, Deserialize, Serialize)]
102pub struct CollectedFolderListData {
103    pub count: u32,
104    pub list: Vec<CollectedFolderItem>,
105}
106
107// --- 批量获取指定收藏id的内容 ---
108
109/// 内容信息列表中的UP主信息
110#[derive(Debug, Clone, Deserialize, Serialize)]
111pub struct ResourceInfoUpper {
112    pub mid: u64,
113    pub name: String,
114    pub face: String,
115}
116
117/// 内容信息列表中的状态数
118#[derive(Debug, Clone, Deserialize, Serialize)]
119pub struct ResourceInfoCntInfo {
120    pub collect: u64,
121    pub play: u64,
122    pub danmaku: u64,
123}
124
125/// 批量获取的内容信息列表项
126#[derive(Debug, Clone, Deserialize, Serialize)]
127pub struct ResourceInfoItem {
128    pub id: u64,
129    #[serde(rename = "type")]
130    pub type_name: u8,
131    pub title: String,
132    pub cover: String,
133    pub intro: String,
134    pub page: Option<u32>,
135    pub duration: u32,
136    pub upper: ResourceInfoUpper,
137    pub attr: u8,
138    pub cnt_info: ResourceInfoCntInfo,
139    pub link: String,
140    pub ctime: u64,
141    pub pubtime: u64,
142    pub fav_time: u64,
143    pub bv_id: Option<String>,
144    pub bvid: Option<String>,
145    pub season: Option<serde_json::Value>,
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::fav::params::{
152        FavCollectedListParams, FavCreatedListParams, FavFolderInfoParams, FavResourceInfosParams,
153    };
154    use crate::probe::contract::HttpMethod;
155    use crate::probe::endpoint_contract::EndpointContract;
156    use crate::{ApiEnvelope, BpiClient, BpiResult};
157    use tracing::info;
158
159    fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
160        let bytes = match endpoint {
161            "folder-info" => {
162                include_bytes!("../../tests/contracts/fav/read/folder-info/contract.json")
163                    .as_slice()
164            }
165            "created-list" => {
166                include_bytes!("../../tests/contracts/fav/read/created-list/contract.json")
167                    .as_slice()
168            }
169            "collected-list" => {
170                include_bytes!("../../tests/contracts/fav/read/collected-list/contract.json")
171                    .as_slice()
172            }
173            "resource-infos" => {
174                include_bytes!("../../tests/contracts/fav/read/resource-infos/contract.json")
175                    .as_slice()
176            }
177            _ => unreachable!("unknown fav info contract endpoint"),
178        };
179
180        EndpointContract::from_slice(bytes)
181    }
182
183    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
184    #[tokio::test]
185    async fn test_get_fav_folder_info() {
186        let bpi = BpiClient::new().expect("client should build");
187        // 替换为一个公开收藏夹的media_id
188        let params = FavFolderInfoParams::new(
189            crate::ids::MediaId::new(1052622027).expect("fixture media id should be valid"),
190        );
191        let resp = bpi.fav().folder_info(params).await;
192
193        info!("{:?}", resp);
194        assert!(resp.is_ok());
195
196        let data = resp.unwrap();
197        info!("folder title: {}", data.title);
198        info!("folder media_count: {}", data.media_count);
199        info!("upper info: {:?}", data.upper);
200    }
201
202    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
203    #[tokio::test]
204    async fn test_get_fav_created_list() {
205        let bpi = BpiClient::new().expect("client should build");
206
207        let params = FavCreatedListParams::new(
208            crate::ids::Mid::new(7792521).expect("fixture mid should be valid"),
209        );
210        let resp = bpi.fav().created_list(params).await;
211
212        info!("{:?}", resp);
213        assert!(resp.is_ok());
214
215        let data = resp.unwrap();
216        info!("created folders count: {}", data.count);
217        info!("first folder info: {:?}", data.list.first());
218    }
219
220    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
221    #[tokio::test]
222    async fn test_get_fav_collected_list() {
223        let bpi = BpiClient::new().expect("client should build");
224
225        let params = FavCollectedListParams::new(
226            crate::ids::Mid::new(7792521).expect("fixture mid should be valid"),
227        )
228        .with_page(1)
229        .expect("fixture page should be valid")
230        .with_page_size(20)
231        .expect("fixture page size should be valid");
232        let resp = bpi.fav().collected_list(params).await;
233
234        info!("{:?}", resp);
235        assert!(resp.is_ok());
236
237        let data = resp.unwrap();
238        info!("collected folders count: {}", data.count);
239        info!("first collected folder info: {:?}", data.list.first());
240    }
241
242    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
243    #[tokio::test]
244    async fn test_get_fav_resource_infos() {
245        let bpi = BpiClient::new().expect("client should build");
246        let params =
247            FavResourceInfosParams::new("371494037:2").expect("fixture resources should be valid");
248        let resp = bpi.fav().resource_infos(params).await;
249
250        info!("{:?}", resp);
251        assert!(resp.is_ok());
252
253        let data = resp.unwrap();
254        info!("retrieved {} resources", data.len());
255        info!("first resource info: {:?}", data.first());
256    }
257
258    #[test]
259    fn fav_folder_info_contract_matches_endpoint_request() -> BpiResult<()> {
260        let contract = contract("folder-info")?;
261
262        assert_eq!(contract.name, "fav.folder_info");
263        assert_eq!(contract.request.method, HttpMethod::Get);
264        assert_eq!(
265            contract.request.url.as_str(),
266            "https://api.bilibili.com/x/v3/fav/folder/info"
267        );
268        assert_eq!(
269            contract.request.query.get("media_id").map(String::as_str),
270            Some("1052622027")
271        );
272        assert_eq!(contract.cases.len(), 3);
273        assert_eq!(
274            contract.cases[0].response.rust_model.as_deref(),
275            Some("FavFolderInfo")
276        );
277        Ok(())
278    }
279
280    #[test]
281    fn fav_created_list_contract_matches_endpoint_request() -> BpiResult<()> {
282        let contract = contract("created-list")?;
283
284        assert_eq!(contract.name, "fav.created_list");
285        assert_eq!(contract.request.method, HttpMethod::Get);
286        assert_eq!(
287            contract.request.url.as_str(),
288            "https://api.bilibili.com/x/v3/fav/folder/created/list-all"
289        );
290        assert_eq!(
291            contract.request.query.get("up_mid").map(String::as_str),
292            Some("7792521")
293        );
294        assert_eq!(contract.cases.len(), 3);
295        assert_eq!(
296            contract.cases[0].response.rust_model.as_deref(),
297            Some("CreatedFolderListData")
298        );
299        Ok(())
300    }
301
302    #[test]
303    fn fav_collected_list_contract_matches_endpoint_request() -> BpiResult<()> {
304        let contract = contract("collected-list")?;
305
306        assert_eq!(contract.name, "fav.collected_list");
307        assert_eq!(contract.request.method, HttpMethod::Get);
308        assert_eq!(
309            contract.request.url.as_str(),
310            "https://api.bilibili.com/x/v3/fav/folder/collected/list"
311        );
312        assert_eq!(
313            contract.request.query.get("up_mid").map(String::as_str),
314            Some("7792521")
315        );
316        assert_eq!(
317            contract.request.query.get("platform").map(String::as_str),
318            Some("web")
319        );
320        assert_eq!(contract.cases.len(), 3);
321        assert_eq!(
322            contract.cases[0].response.rust_model.as_deref(),
323            Some("CollectedFolderListData")
324        );
325        Ok(())
326    }
327
328    #[test]
329    fn fav_resource_infos_contract_matches_endpoint_request() -> BpiResult<()> {
330        let contract = contract("resource-infos")?;
331
332        assert_eq!(contract.name, "fav.resource_infos");
333        assert_eq!(contract.request.method, HttpMethod::Get);
334        assert_eq!(
335            contract.request.url.as_str(),
336            "https://api.bilibili.com/x/v3/fav/resource/infos"
337        );
338        assert_eq!(
339            contract.request.query.get("resources").map(String::as_str),
340            Some("371494037:2")
341        );
342        assert_eq!(contract.cases.len(), 3);
343        assert_eq!(
344            contract.cases[0].response.rust_model.as_deref(),
345            Some("Vec<ResourceInfoItem>")
346        );
347        Ok(())
348    }
349
350    #[test]
351    fn fav_info_response_fixtures_parse_declared_models() -> BpiResult<()> {
352        let folder = ApiEnvelope::<FavFolderInfo>::from_slice(include_bytes!(
353            "../../tests/contracts/fav/read/folder-info/responses/success.json"
354        ))?
355        .into_payload()?;
356        assert_eq!(folder.id, 1052622027);
357
358        let created = ApiEnvelope::<CreatedFolderListData>::from_slice(include_bytes!(
359            "../../tests/contracts/fav/read/created-list/responses/success.json"
360        ))?
361        .into_payload()?;
362        assert_eq!(created.list.len(), 1);
363
364        let collected = ApiEnvelope::<CollectedFolderListData>::from_slice(include_bytes!(
365            "../../tests/contracts/fav/read/collected-list/responses/success.json"
366        ))?
367        .into_payload()?;
368        assert!(collected.list.is_empty());
369
370        let resources = ApiEnvelope::<Vec<ResourceInfoItem>>::from_slice(include_bytes!(
371            "../../tests/contracts/fav/read/resource-infos/responses/success.json"
372        ))?
373        .into_payload()?;
374        assert_eq!(resources.len(), 1);
375        Ok(())
376    }
377
378    fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
379        let path = format!("target/bpi-probe-runs/fav/read/{endpoint}/{profile}.response.json");
380        let bytes = std::fs::read(path).ok()?;
381        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
382        value
383            .get("response")
384            .and_then(|response| response.get("body"))
385            .cloned()
386    }
387
388    #[test]
389    fn fav_info_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
390        for profile in ["anonymous", "normal", "vip"] {
391            if let Some(body) = local_probe_body("folder-info", profile) {
392                let payload =
393                    serde_json::from_value::<ApiEnvelope<FavFolderInfo>>(body)?.into_payload()?;
394                assert_eq!(payload.id, 1052622027);
395            }
396
397            if let Some(body) = local_probe_body("created-list", profile) {
398                let payload = serde_json::from_value::<ApiEnvelope<CreatedFolderListData>>(body)?
399                    .into_payload()?;
400                assert!(payload.count >= payload.list.len() as u32);
401            }
402
403            if let Some(body) = local_probe_body("collected-list", profile) {
404                let payload = serde_json::from_value::<ApiEnvelope<CollectedFolderListData>>(body)?
405                    .into_payload()?;
406                assert!(payload.count >= payload.list.len() as u32);
407            }
408
409            if let Some(body) = local_probe_body("resource-infos", profile) {
410                let payload = serde_json::from_value::<ApiEnvelope<Vec<ResourceInfoItem>>>(body)?
411                    .into_payload()?;
412                assert!(!payload.is_empty());
413            }
414        }
415        Ok(())
416    }
417}