1use serde::{Deserialize, Serialize};
5
6pub(crate) const HOMEPAGE_RECOMMENDATIONS_ENDPOINT: &str =
7 "https://api.bilibili.com/x/web-interface/wbi/index/top/feed/rcmd";
8pub(crate) const RELATED_VIDEOS_ENDPOINT: &str =
9 "https://api.bilibili.com/x/web-interface/archive/related";
10
11#[derive(Debug, Clone, Deserialize, Serialize)]
15pub struct Owner {
16 pub mid: u64,
18 pub name: String,
20 pub face: String,
22}
23
24#[derive(Debug, Clone, Deserialize, Serialize)]
26pub struct Stat {
27 pub view: u64,
29 pub aid: u64,
31 pub danmaku: u64,
33 pub reply: u64,
35 pub favorite: u64,
37 pub coin: u64,
39 pub share: u64,
41 pub now_rank: u64,
43 pub his_rank: u64,
45 pub like: u64,
47 pub dislike: u64,
49}
50
51#[derive(Debug, Clone, Deserialize, Serialize)]
53pub struct HomeRmdStat {
54 pub view: u64,
56 pub danmaku: u64,
58 pub like: u64,
60}
61
62#[derive(Debug, Clone, Deserialize, Serialize)]
64pub struct Rights {
65 pub bp: u8,
66 pub elec: u8,
67 pub download: u8,
68 pub movie: u8,
69 pub pay: u8,
70 pub hd5: u8,
71 pub no_reprint: u8,
72 pub autoplay: u8,
73 pub ugc_pay: u8,
74 pub is_cooperation: u8,
75 pub ugc_pay_preview: u8,
76 pub no_background: u8,
77}
78
79#[derive(Debug, Clone, Deserialize, Serialize)]
81pub struct Dimension {
82 pub width: u32,
83 pub height: u32,
84 pub rotate: u8,
85}
86
87#[derive(Debug, Clone, Deserialize, Serialize)]
89pub struct RelatedVideo {
90 pub aid: u64,
91 pub videos: u32,
92 pub tid: u32,
93 pub tname: String,
94 pub copyright: u8,
95 pub pic: String,
96 pub title: String,
97 pub pubdate: u64,
98 pub ctime: u64,
99 pub desc: String,
100 pub state: i8,
101 pub duration: u64,
102 pub rights: Rights,
103 pub owner: Owner,
104 pub stat: Stat,
105 pub dynamic: String,
106 pub cid: u64,
107 pub dimension: Dimension,
108 pub bvid: String,
109 #[serde(default)]
110 pub short_link_v2: String,
111}
112
113#[derive(Debug, Clone, Deserialize, Serialize)]
115pub struct RcmdReason {
116 #[serde(rename = "reason_type")]
118 pub reason_type: u8,
119 pub content: Option<String>,
121}
122
123#[derive(Debug, Clone, Deserialize, Serialize)]
125pub struct RcmdItem {
126 pub av_feature: Option<serde_json::Value>,
127 pub business_info: Option<serde_json::Value>,
129 pub bvid: String,
131 pub cid: u64,
133 pub duration: u64,
135 pub goto: String,
137 pub id: u64,
139 pub is_followed: u8,
141 pub is_stock: u8,
142 pub owner: Owner,
144 pub pic: String,
146 pub pos: u8,
147 pub pubdate: u64,
149 pub rcmd_reason: Option<RcmdReason>,
151 pub room_info: Option<serde_json::Value>,
153 pub show_info: u8,
154 pub stat: Option<HomeRmdStat>,
156 pub title: String,
158 pub track_id: String,
159 pub uri: String,
161}
162
163#[derive(Debug, Clone, Deserialize, Serialize)]
165pub struct RcmdFeedResponseData {
166 pub item: Vec<RcmdItem>,
168 pub mid: u64,
170 pub preload_expose_pct: f32,
171 pub preload_floor_expose_pct: f32,
172}
173
174#[cfg(test)]
177mod tests {
178 use super::*;
179 use crate::ids::Aid;
180 use crate::probe::contract::HttpMethod;
181 use crate::probe::endpoint_contract::EndpointContract;
182 use crate::video::params::{VideoHomepageRecommendationsParams, VideoRelatedParams};
183 use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
184 use tracing::info;
185
186 const TEST_AID: u64 = 10001;
187
188 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
189 #[tokio::test]
190 async fn test_video_related_videos_by_aid() -> Result<(), BpiError> {
191 let bpi = BpiClient::new().expect("client should build");
192 let data = bpi
193 .video()
194 .related_videos(VideoRelatedParams::from_aid(Aid::new(TEST_AID)?))
195 .await?;
196
197 info!("单视频推荐列表: {:?}", data);
198
199 assert!(!data.is_empty());
200 assert!(data.len() <= 40);
201
202 Ok(())
203 }
204
205 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
206 #[tokio::test]
207 async fn test_video_homepage_recommendations() -> Result<(), BpiError> {
208 let bpi = BpiClient::new().expect("client should build");
209 let params = VideoHomepageRecommendationsParams::new()
210 .page_size(12)?
211 .fresh_idx(1)?
212 .fetch_row(1)?;
213 let data = bpi.video().homepage_recommendations(params).await?;
214
215 info!("首页推荐列表: {:?}", data);
216
217 assert!(!data.item.is_empty());
218 assert!(data.item.len() <= 30);
219
220 Ok(())
221 }
222
223 fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
224 let bytes = match endpoint {
225 "related-videos" => include_bytes!(
226 "../../tests/contracts/video/player-read/related-videos/contract.json"
227 )
228 .as_slice(),
229 "homepage-recommendations" => include_bytes!(
230 "../../tests/contracts/video/player-read/homepage-recommendations/contract.json"
231 )
232 .as_slice(),
233 _ => unreachable!("unknown video recommend contract"),
234 };
235
236 EndpointContract::from_slice(bytes)
237 }
238
239 #[test]
240 fn video_related_videos_contract_matches_endpoint_request() -> BpiResult<()> {
241 let contract = contract("related-videos")?;
242 let params = VideoRelatedParams::from_bvid("BV1xx411c7mD".parse()?);
243
244 assert_eq!(contract.name, "video.related_videos");
245 assert_eq!(contract.request.method, HttpMethod::Get);
246 assert_eq!(contract.request.url.as_str(), RELATED_VIDEOS_ENDPOINT);
247 assert_eq!(
248 contract.request.query.get("bvid").map(String::as_str),
249 Some("BV1xx411c7mD")
250 );
251 assert_eq!(
252 params.query_pairs(),
253 vec![("bvid", "BV1xx411c7mD".to_string())]
254 );
255 assert_eq!(contract.cases.len(), 3);
256 Ok(())
257 }
258
259 #[test]
260 fn video_related_videos_response_fixture_parses_declared_model() -> BpiResult<()> {
261 let payload = ApiEnvelope::<Vec<RelatedVideo>>::from_slice(include_bytes!(
262 "../../tests/contracts/video/player-read/related-videos/responses/success.json"
263 ))?
264 .into_payload()?;
265
266 assert_eq!(payload.len(), 1);
267 Ok(())
268 }
269
270 #[test]
271 fn video_homepage_recommendations_contract_matches_endpoint_request() -> BpiResult<()> {
272 let contract = contract("homepage-recommendations")?;
273 let params = VideoHomepageRecommendationsParams::new();
274
275 assert_eq!(contract.name, "video.homepage_recommendations");
276 assert_eq!(contract.request.method, HttpMethod::Get);
277 assert_eq!(
278 contract.request.url.as_str(),
279 HOMEPAGE_RECOMMENDATIONS_ENDPOINT
280 );
281 assert!(contract.request.auth.requires_wbi());
282 assert_eq!(
283 contract.request.query.get("ps").map(String::as_str),
284 Some("12")
285 );
286 assert_eq!(params.query_pairs().len(), 6);
287 assert_eq!(contract.cases.len(), 3);
288 Ok(())
289 }
290
291 #[test]
292 fn video_homepage_recommendations_response_fixture_parses_declared_model() -> BpiResult<()> {
293 let payload = ApiEnvelope::<RcmdFeedResponseData>::from_slice(include_bytes!(
294 "../../tests/contracts/video/player-read/homepage-recommendations/responses/success.json"
295 ))?
296 .into_payload()?;
297
298 assert_eq!(payload.item.len(), 1);
299 Ok(())
300 }
301
302 fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
303 let path =
304 format!("target/bpi-probe-runs/video/player-read/{endpoint}/{profile}.response.json");
305 let bytes = std::fs::read(path).ok()?;
306 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
307 value
308 .get("response")
309 .and_then(|response| response.get("body"))
310 .cloned()
311 }
312
313 #[test]
314 fn video_recommend_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
315 for profile in ["anonymous", "normal", "vip"] {
316 if let Some(body) = local_probe_body("related-videos", profile) {
317 let payload = serde_json::from_value::<ApiEnvelope<Vec<RelatedVideo>>>(body)?
318 .into_payload()?;
319
320 assert!(!payload.is_empty());
321 }
322
323 if let Some(body) = local_probe_body("homepage-recommendations", profile) {
324 let payload = serde_json::from_value::<ApiEnvelope<RcmdFeedResponseData>>(body)?
325 .into_payload()?;
326
327 assert!(!payload.item.is_empty());
328 }
329 }
330 Ok(())
331 }
332}