Skip to main content

bpi_rs/cheese/
info.rs

1//! 课程(PUGV)相关 API
2//!
3//! [参考文档](https://github.com/Yuelioi/bilibili-API-collect/tree/cfc5fddcc8a94b74d91970bb5b4eaeb349addc47/docs/cheese/info.md)
4
5use serde::{Deserialize, Serialize};
6
7// ==========================
8// 数据结构(/pugv/view/web/season)
9// ==========================
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct CourseInfo {
13    pub brief: CourseBrief,
14    pub coupon: CourseCoupon,
15    pub cover: String,
16    pub episode_page: CourseEpisodePage,
17    pub episode_sort: i32,
18    pub episodes: Vec<CourseEpisode>,
19    pub faq: CourseFaq,
20    pub faq1: CourseFaq1,
21    pub payment: CoursePayment,
22    pub purchase_note: CoursePurchaseNote,
23    pub purchase_protocol: CoursePurchaseProtocol,
24    pub release_bottom_info: String,
25    pub release_info: String,
26    pub release_info2: String,
27    pub release_status: String,
28    pub season_id: u64,
29    pub share_url: String,
30    pub short_link: String,
31    pub stat: CourseStat,
32    pub status: i32,
33    pub subtitle: String,
34    pub title: String,
35    pub up_info: CourseUpInfo,
36    pub user_status: CourseUserStatus,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct CourseBrief {
41    pub content: String,
42    pub img: Vec<CourseBriefImg>,
43    pub title: String,
44    pub r#type: i32,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct CourseBriefImg {
49    pub aspect_ratio: f64,
50    pub url: String,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct CourseCoupon {
55    pub amount: f64,
56    pub expire_time: String, // YYYY-MM-DD HH:MM:SS
57    pub start_time: String,  // YYYY-MM-DD HH:MM:SS
58    pub status: i32,
59    pub title: String,
60    pub token: String,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct CourseEpisodePage {
65    pub next: bool,
66    pub num: u32,
67    pub size: u32,
68    pub total: u32,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct CourseEpisode {
73    pub aid: u64,          // 课程分集 avid(与普通稿件部分不互通)
74    pub cid: u64,          // 课程分集 cid(与普通视频部分不互通)
75    pub duration: u64,     // 单位:秒
76    pub from: String,      // "pugv"
77    pub id: u64,           // 课程分集 epid(与番剧不互通)
78    pub index: u32,        // 课程分集数
79    pub page: u32,         // 一般为 1
80    pub play: u64,         // 分集播放量
81    pub release_date: u64, // 发布时间(时间戳)
82    pub status: i32,       // 1 可看、2 不可看
83    pub title: String,     // 分集标题
84    pub watched: bool,     // 是否观看(需登录 + 正确 Referer)
85    #[serde(rename = "watchedHistory")] // 文档里为驼峰
86    pub watched_history: u64,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct CourseFaq {
91    pub content: String,
92    pub link: String,
93    pub title: String,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct CourseFaq1 {
98    pub items: Vec<CourseFaqItem>,
99    pub title: String,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct CourseFaqItem {
104    pub answer: String,
105    pub question: String,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct CoursePayment {
110    pub desc: String,
111    pub discount_desc: String,
112    #[serde(default)]
113    pub discount_prefix: String,
114    pub pay_shade: String,
115    pub price: f64,
116    pub price_format: String,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct CoursePurchaseNote {
121    pub content: String,
122    pub link: String,
123    pub title: String,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct CoursePurchaseProtocol {
128    pub link: String,
129    pub title: String,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct CourseStat {
134    pub play: u64,
135    pub play_desc: String,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct CourseUpInfo {
140    pub avatar: String,
141    pub brief: String,
142    pub follower: u64,
143    pub is_follow: i32, // 0 未关注,1 已关注
144    pub link: String,
145    pub mid: u64,
146    pub pendant: CoursePendant,
147    pub uname: String,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct CoursePendant {
152    pub image: String,
153    pub name: String,
154    pub pid: u64,
155    // pub follower: u64,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct CourseUserStatus {
160    pub favored: i32, // 0 未收藏,1 已收藏
161    pub favored_count: u64,
162    pub payed: i32, // 0 未购买,1 已购买
163    #[serde(default)]
164    pub progress: Option<CourseProgress>,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct CourseProgress {
169    pub last_ep_id: u64,
170    pub last_ep_index: String,
171    pub last_time: u64, // 秒
172}
173
174// ==========================
175// 数据结构(/pugv/view/web/ep/list)
176// ==========================
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct CourseEpList {
180    pub items: Vec<CourseEpisode>, // 结构与 CourseEpisode 一致
181    pub page: CourseEpPage,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct CourseEpPage {
186    pub next: bool, // 是否存在下一页
187    pub num: u32,   // 当前页码
188    pub size: u32,  // 每页项数
189    pub total: u32, // 总计项数
190}
191
192// ==========================
193// 测试
194// ==========================
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::cheese::{CheeseEpListParams, CheeseInfoParams};
200    use crate::ids::{EpisodeId, SeasonId};
201    use crate::probe::contract::HttpMethod;
202    use crate::probe::endpoint_contract::EndpointContract;
203    use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
204
205    const TEST_SEASON_ID: u64 = 556;
206    const TEST_EP_ID: u64 = 20767;
207
208    fn contract(name: &str) -> BpiResult<EndpointContract> {
209        let bytes = match name {
210            "season-detail-season" => include_bytes!(
211                "../../tests/contracts/cheese/info/season-detail-season/contract.json"
212            )
213            .as_slice(),
214            "season-detail-episode" => include_bytes!(
215                "../../tests/contracts/cheese/info/season-detail-episode/contract.json"
216            )
217            .as_slice(),
218            "ep-list" => {
219                include_bytes!("../../tests/contracts/cheese/info/ep-list/contract.json").as_slice()
220            }
221            _ => unreachable!("unknown cheese info contract"),
222        };
223        EndpointContract::from_slice(bytes)
224    }
225
226    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
227    #[tokio::test]
228    async fn test_cheese_info_by_season_id() -> Result<(), Box<BpiError>> {
229        let bpi = BpiClient::new().expect("client should build");
230        let data = bpi
231            .cheese()
232            .info_by_season_id(SeasonId::new(TEST_SEASON_ID)?)
233            .await?;
234
235        assert_eq!(data.season_id, TEST_SEASON_ID);
236        tracing::info!("{:#?}", data);
237        Ok(())
238    }
239
240    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
241    #[tokio::test]
242    async fn test_cheese_info_by_ep_id() -> Result<(), Box<BpiError>> {
243        let bpi = BpiClient::new().expect("client should build");
244        let data = bpi
245            .cheese()
246            .info_by_ep_id(EpisodeId::new(TEST_EP_ID)?)
247            .await?;
248        assert_eq!(data.season_id, TEST_SEASON_ID);
249
250        tracing::info!("课程标题: {:?}", data.title);
251        tracing::info!("课程 ssid: {:?}", data.season_id);
252        Ok(())
253    }
254
255    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
256    #[tokio::test]
257    async fn test_cheese_ep_list() -> Result<(), Box<BpiError>> {
258        let bpi = BpiClient::new().expect("client should build");
259        let data = bpi
260            .cheese()
261            .ep_list(
262                CheeseEpListParams::new(SeasonId::new(TEST_SEASON_ID)?)
263                    .with_page_size(50)?
264                    .with_page(1)?,
265            )
266            .await?;
267        assert_eq!(data.items.first().unwrap().id, TEST_SEASON_ID);
268
269        tracing::info!("课程标题: {:?}", data.items.first().unwrap().title);
270        tracing::info!("课程 ssid: {:?}", data.items.first().unwrap());
271        Ok(())
272    }
273
274    #[test]
275    fn cheese_info_params_serializes_season_id() -> Result<(), BpiError> {
276        let params = CheeseInfoParams::from_season_id(SeasonId::new(TEST_SEASON_ID)?);
277
278        assert_eq!(
279            params.query_pairs(),
280            vec![("season_id", TEST_SEASON_ID.to_string())]
281        );
282        Ok(())
283    }
284
285    #[test]
286    fn cheese_ep_list_params_rejects_zero_page() -> Result<(), BpiError> {
287        let err = CheeseEpListParams::new(SeasonId::new(TEST_SEASON_ID)?)
288            .with_page(0)
289            .unwrap_err();
290
291        assert!(matches!(
292            err,
293            BpiError::InvalidParameter { field: "pn", .. }
294        ));
295        Ok(())
296    }
297
298    #[test]
299    fn cheese_info_by_season_contract_matches_endpoint_request() -> BpiResult<()> {
300        let contract = contract("season-detail-season")?;
301        let params = CheeseInfoParams::from_season_id(SeasonId::new(TEST_SEASON_ID)?);
302
303        assert_eq!(contract.name, "cheese.info.season_detail_by_season_id");
304        assert_eq!(contract.request.method, HttpMethod::Get);
305        assert_eq!(
306            contract.request.url.as_str(),
307            "https://api.bilibili.com/pugv/view/web/season"
308        );
309        assert_eq!(
310            contract.request.query.get("season_id").map(String::as_str),
311            Some("556")
312        );
313        assert_eq!(
314            params.query_pairs(),
315            vec![("season_id", TEST_SEASON_ID.to_string())]
316        );
317        assert_eq!(contract.cases.len(), 3);
318        assert_eq!(
319            contract.cases[0].response.rust_model.as_deref(),
320            Some("CourseInfo")
321        );
322        Ok(())
323    }
324
325    #[test]
326    fn cheese_info_by_episode_contract_matches_endpoint_request() -> BpiResult<()> {
327        let contract = contract("season-detail-episode")?;
328        let params = CheeseInfoParams::from_episode_id(EpisodeId::new(TEST_EP_ID)?);
329
330        assert_eq!(contract.name, "cheese.info.season_detail_by_ep_id");
331        assert_eq!(contract.request.method, HttpMethod::Get);
332        assert_eq!(
333            contract.request.url.as_str(),
334            "https://api.bilibili.com/pugv/view/web/season"
335        );
336        assert_eq!(
337            contract.request.query.get("ep_id").map(String::as_str),
338            Some("20767")
339        );
340        assert_eq!(
341            params.query_pairs(),
342            vec![("ep_id", TEST_EP_ID.to_string())]
343        );
344        assert_eq!(contract.cases.len(), 3);
345        assert_eq!(
346            contract.cases[0].response.fixture_kind.as_deref(),
347            Some("trimmed_probe_body")
348        );
349        Ok(())
350    }
351
352    #[test]
353    fn cheese_ep_list_contract_matches_endpoint_request() -> BpiResult<()> {
354        let contract = contract("ep-list")?;
355        let params = CheeseEpListParams::new(SeasonId::new(TEST_SEASON_ID)?)
356            .with_page_size(50)?
357            .with_page(1)?;
358
359        assert_eq!(contract.name, "cheese.info.ep_list");
360        assert_eq!(contract.request.method, HttpMethod::Get);
361        assert_eq!(
362            contract.request.url.as_str(),
363            "https://api.bilibili.com/pugv/view/web/ep/list"
364        );
365        assert_eq!(
366            contract.request.query.get("season_id").map(String::as_str),
367            Some("556")
368        );
369        assert_eq!(
370            contract.request.query.get("ps").map(String::as_str),
371            Some("50")
372        );
373        assert_eq!(
374            contract.request.query.get("pn").map(String::as_str),
375            Some("1")
376        );
377        assert_eq!(
378            params.query_pairs(),
379            vec![
380                ("season_id", TEST_SEASON_ID.to_string()),
381                ("ps", "50".to_string()),
382                ("pn", "1".to_string()),
383            ]
384        );
385        assert_eq!(
386            contract.cases[0].response.rust_model.as_deref(),
387            Some("CourseEpList")
388        );
389        Ok(())
390    }
391
392    #[test]
393    fn cheese_info_response_fixtures_parse_declared_model() -> BpiResult<()> {
394        for bytes in [
395            include_bytes!(
396                "../../tests/contracts/cheese/info/season-detail-season/responses/anonymous.success.json"
397            )
398            .as_slice(),
399            include_bytes!(
400                "../../tests/contracts/cheese/info/season-detail-season/responses/normal.success.json"
401            )
402            .as_slice(),
403            include_bytes!(
404                "../../tests/contracts/cheese/info/season-detail-season/responses/vip.success.json"
405            )
406            .as_slice(),
407            include_bytes!(
408                "../../tests/contracts/cheese/info/season-detail-episode/responses/anonymous.success.json"
409            )
410            .as_slice(),
411            include_bytes!(
412                "../../tests/contracts/cheese/info/season-detail-episode/responses/normal.success.json"
413            )
414            .as_slice(),
415            include_bytes!(
416                "../../tests/contracts/cheese/info/season-detail-episode/responses/vip.success.json"
417            )
418            .as_slice(),
419        ] {
420            let payload = ApiEnvelope::<CourseInfo>::from_slice(bytes)?.into_payload()?;
421
422            assert_eq!(payload.season_id, TEST_SEASON_ID);
423            assert_eq!(payload.episodes.len(), 2);
424            assert_eq!(payload.user_status.payed, 0);
425            assert_eq!(payload.title, "【暑期5折】法语0-B2高级班");
426        }
427        Ok(())
428    }
429
430    #[test]
431    fn cheese_ep_list_response_fixtures_parse_declared_model() -> BpiResult<()> {
432        for bytes in [
433            include_bytes!(
434                "../../tests/contracts/cheese/info/ep-list/responses/anonymous.success.json"
435            )
436            .as_slice(),
437            include_bytes!(
438                "../../tests/contracts/cheese/info/ep-list/responses/normal.success.json"
439            )
440            .as_slice(),
441            include_bytes!("../../tests/contracts/cheese/info/ep-list/responses/vip.success.json")
442                .as_slice(),
443        ] {
444            let payload = ApiEnvelope::<CourseEpList>::from_slice(bytes)?.into_payload()?;
445
446            assert_eq!(payload.page.total, 603);
447            assert_eq!(payload.items.len(), 2);
448            assert_eq!(payload.items[0].id, 20766);
449            assert_eq!(payload.items[0].aid, 640_041_584);
450            assert_eq!(payload.items[0].cid, 1_641_007_864);
451        }
452        Ok(())
453    }
454
455    fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
456        let path = format!("target/bpi-probe-runs/cheese/read/{endpoint}/{profile}.response.json");
457        let bytes = std::fs::read(path).ok()?;
458        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
459        value
460            .get("response")
461            .and_then(|response| response.get("body"))
462            .cloned()
463    }
464
465    #[test]
466    fn cheese_info_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
467        for profile in ["anonymous", "normal", "vip"] {
468            for endpoint in ["info-season", "info-episode"] {
469                let Some(body) = local_probe_body(endpoint, profile) else {
470                    continue;
471                };
472                let payload =
473                    serde_json::from_value::<ApiEnvelope<CourseInfo>>(body)?.into_payload()?;
474
475                assert_eq!(payload.season_id, TEST_SEASON_ID);
476                assert_eq!(payload.episodes.len(), 603);
477                assert_eq!(payload.user_status.payed, 0);
478            }
479
480            let Some(body) = local_probe_body("ep-list", profile) else {
481                continue;
482            };
483            let payload =
484                serde_json::from_value::<ApiEnvelope<CourseEpList>>(body)?.into_payload()?;
485
486            assert_eq!(payload.page.total, 603);
487            assert_eq!(payload.items.len(), 50);
488        }
489        Ok(())
490    }
491}