1use serde::{Deserialize, Serialize};
6
7#[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, pub start_time: String, 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, pub cid: u64, pub duration: u64, pub from: String, pub id: u64, pub index: u32, pub page: u32, pub play: u64, pub release_date: u64, pub status: i32, pub title: String, pub watched: bool, #[serde(rename = "watchedHistory")] 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, 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 }
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct CourseUserStatus {
160 pub favored: i32, pub favored_count: u64,
162 pub payed: i32, #[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, }
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct CourseEpList {
180 pub items: Vec<CourseEpisode>, pub page: CourseEpPage,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct CourseEpPage {
186 pub next: bool, pub num: u32, pub size: u32, pub total: u32, }
191
192#[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}