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 crate::{ BilibiliRequest, BpiClient, BpiError, BpiResponse };
6use serde::{ Deserialize, Serialize };
7
8// ==========================
9// 数据结构(/pugv/view/web/season)
10// ==========================
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct CourseInfo {
14    pub brief: CourseBrief,
15    pub coupon: CourseCoupon,
16    pub cover: String,
17    pub episode_page: CourseEpisodePage,
18    pub episode_sort: i32,
19    pub episodes: Vec<CourseEpisode>,
20    pub faq: CourseFaq,
21    pub faq1: CourseFaq1,
22    pub payment: CoursePayment,
23    pub purchase_note: CoursePurchaseNote,
24    pub purchase_protocol: CoursePurchaseProtocol,
25    pub release_bottom_info: String,
26    pub release_info: String,
27    pub release_info2: String,
28    pub release_status: String,
29    pub season_id: u64,
30    pub share_url: String,
31    pub short_link: String,
32    pub stat: CourseStat,
33    pub status: i32,
34    pub subtitle: String,
35    pub title: String,
36    pub up_info: CourseUpInfo,
37    pub user_status: CourseUserStatus,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct CourseBrief {
42    pub content: String,
43    pub img: Vec<CourseBriefImg>,
44    pub title: String,
45    pub r#type: i32,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct CourseBriefImg {
50    pub aspect_ratio: f64,
51    pub url: String,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct CourseCoupon {
56    pub amount: f64,
57    pub expire_time: String, // YYYY-MM-DD HH:MM:SS
58    pub start_time: String, // YYYY-MM-DD HH:MM:SS
59    pub status: i32,
60    pub title: String,
61    pub token: String,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct CourseEpisodePage {
66    pub next: bool,
67    pub num: u32,
68    pub size: u32,
69    pub total: u32,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct CourseEpisode {
74    pub aid: u64, // 课程分集 avid(与普通稿件部分不互通)
75    pub cid: u64, // 课程分集 cid(与普通视频部分不互通)
76    pub duration: u64, // 单位:秒
77    pub from: String, // "pugv"
78    pub id: u64, // 课程分集 epid(与番剧不互通)
79    pub index: u32, // 课程分集数
80    pub page: u32, // 一般为 1
81    pub play: u64, // 分集播放量
82    pub release_date: u64, // 发布时间(时间戳)
83    pub status: i32, // 1 可看、2 不可看
84    pub title: String, // 分集标题
85    pub watched: bool, // 是否观看(需登录 + 正确 Referer)
86    #[serde(rename = "watchedHistory")] // 文档里为驼峰
87    pub watched_history: u64,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct CourseFaq {
92    pub content: String,
93    pub link: String,
94    pub title: String,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct CourseFaq1 {
99    pub items: Vec<CourseFaqItem>,
100    pub title: String,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct CourseFaqItem {
105    pub answer: String,
106    pub question: String,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct CoursePayment {
111    pub desc: String,
112    pub discount_desc: String,
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    pub progress: CourseProgress,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct CourseProgress {
168    pub last_ep_id: u64,
169    pub last_ep_index: String,
170    pub last_time: u64, // 秒
171}
172
173// ==========================
174// 数据结构(/pugv/view/web/ep/list)
175// ==========================
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct CourseEpList {
179    pub items: Vec<CourseEpisode>, // 结构与 CourseEpisode 一致
180    pub page: CourseEpPage,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct CourseEpPage {
185    pub next: bool, // 是否存在下一页
186    pub num: u32, // 当前页码
187    pub size: u32, // 每页项数
188    pub total: u32, // 总计项数
189}
190
191// ==========================
192// API 封装
193// ==========================
194
195impl BpiClient {
196    /// 获取课程基本信息
197    ///
198    /// 通过课程 season_id 或分集 ep_id 获取课程的详细信息,包括课程简介、
199    /// 分集列表、UP主信息、统计数据等。
200    ///
201    /// # 参数
202    /// | 名称 | 类型 | 说明 |
203    /// | ---- | ---- | ---- |
204    /// | `season_id` | `Option<u64>` | 课程 season_id,与 ep_id 二选一 |
205    /// | `ep_id` | `Option<u64>` | 课程分集 ep_id,与 season_id 二选一 |
206    ///
207    /// # 错误
208    /// 当 season_id 和 ep_id 都未提供时返回参数错误
209    ///
210    /// # 文档
211    /// [获取课程基本信息](https://github.com/Yuelioi/bilibili-API-collect/tree/cfc5fddcc8a94b74d91970bb5b4eaeb349addc47/docs/cheese/info.md#获取课程基本信息)
212    pub async fn cheese_info(
213        &self,
214        season_id: Option<u64>,
215        ep_id: Option<u64>
216    ) -> Result<BpiResponse<CourseInfo>, BpiError> {
217        if season_id.is_none() && ep_id.is_none() {
218            return Err(
219                BpiError::parse("cheese_info: season_id 与 ep_id 必须至少提供一个".to_string())
220            );
221        }
222        // 构造查询参数
223        let mut req = self
224            .get("https://api.bilibili.com/pugv/view/web/season")
225            .with_bilibili_headers();
226
227        if let Some(sid) = season_id {
228            req = req.query(&[("season_id", sid)]);
229        }
230        if let Some(eid) = ep_id {
231            req = req.query(&[("ep_id", eid)]);
232        }
233
234        req.send_bpi("获取课程基本信息").await
235    }
236
237    /// 通过 season_id 获取课程基本信息
238    ///
239    /// # 参数
240    /// | 名称 | 类型 | 说明 |
241    /// | ---- | ---- | ---- |
242    /// | `season_id` | u64 | 课程 season_id |
243    pub async fn cheese_info_by_season_id(
244        &self,
245        season_id: u64
246    ) -> Result<BpiResponse<CourseInfo>, BpiError> {
247        self.cheese_info(Some(season_id), None).await
248    }
249
250    /// 通过 ep_id 获取课程基本信息
251    ///
252    /// # 参数
253    /// | 名称 | 类型 | 说明 |
254    /// | ---- | ---- | ---- |
255    /// | `ep_id` | u64 | 课程分集 ep_id |
256    pub async fn cheese_info_by_ep_id(
257        &self,
258        ep_id: u64
259    ) -> Result<BpiResponse<CourseInfo>, BpiError> {
260        self.cheese_info(None, Some(ep_id)).await
261    }
262
263    /// 获取课程分集列表
264    ///
265    /// 获取指定课程的所有分集信息,支持分页查询。
266    ///
267    /// # 参数
268    /// | 名称 | 类型 | 说明 |
269    /// | ---- | ---- | ---- |
270    /// | `season_id` | u64 | 课程 season_id |
271    /// | `ps` | `Option<u32>` | 每页数量,可选,默认值由 API 决定 |
272    /// | `pn` | `Option<u32>` | 页码,可选,默认为 1 |
273    ///
274    /// # 文档
275    /// [获取课程分集列表](https://github.com/Yuelioi/bilibili-API-collect/tree/cfc5fddcc8a94b74d91970bb5b4eaeb349addc47/docs/cheese/info.md#获取课程分集列表)
276    pub async fn cheese_ep_list(
277        &self,
278        season_id: u64,
279        ps: Option<u32>,
280        pn: Option<u32>
281    ) -> Result<BpiResponse<CourseEpList>, BpiError> {
282        let mut req = self
283            .get("https://api.bilibili.com/pugv/view/web/ep/list")
284            .query(&[("season_id", season_id)]);
285
286        if let Some(ps) = ps {
287            req = req.query(&[("ps", ps)]);
288        }
289        if let Some(pn) = pn {
290            req = req.query(&[("pn", pn)]);
291        }
292
293        req.send_bpi("获取课程分集列表").await
294    }
295}
296
297// ==========================
298// 测试
299// ==========================
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    const TEST_SEASON_ID: u64 = 556;
306    const TEST_EP_ID: u64 = 20767;
307
308    #[tokio::test]
309    async fn test_cheese_info_by_season_id() -> Result<(), Box<BpiError>> {
310        let bpi = BpiClient::new();
311        let data = bpi.cheese_info_by_season_id(TEST_SEASON_ID).await?.into_data()?;
312
313        assert_eq!(data.season_id, TEST_SEASON_ID);
314        tracing::info!("{:#?}", data);
315        Ok(())
316    }
317
318    #[tokio::test]
319    async fn test_cheese_info_by_ep_id() -> Result<(), Box<BpiError>> {
320        let bpi = BpiClient::new();
321        let data = bpi.cheese_info_by_ep_id(TEST_EP_ID).await?.into_data()?;
322        assert_eq!(data.season_id, TEST_SEASON_ID);
323
324        tracing::info!("课程标题: {:?}", data.title);
325        tracing::info!("课程 ssid: {:?}", data.season_id);
326        Ok(())
327    }
328
329    #[tokio::test]
330    async fn test_cheese_ep_list() -> Result<(), Box<BpiError>> {
331        let bpi = BpiClient::new();
332        let data = bpi.cheese_ep_list(TEST_SEASON_ID, Some(50), Some(1)).await?.into_data()?;
333        assert_eq!(data.items.first().unwrap().id, TEST_SEASON_ID);
334
335        tracing::info!("课程标题: {:?}", data.items.first().unwrap().title);
336        tracing::info!("课程 ssid: {:?}", data.items.first().unwrap());
337        Ok(())
338    }
339}