use serde::{Deserialize, Serialize};
pub(crate) const HOMEPAGE_RECOMMENDATIONS_ENDPOINT: &str =
"https://api.bilibili.com/x/web-interface/wbi/index/top/feed/rcmd";
pub(crate) const RELATED_VIDEOS_ENDPOINT: &str =
"https://api.bilibili.com/x/web-interface/archive/related";
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Owner {
pub mid: u64,
pub name: String,
pub face: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Stat {
pub view: u64,
pub aid: u64,
pub danmaku: u64,
pub reply: u64,
pub favorite: u64,
pub coin: u64,
pub share: u64,
pub now_rank: u64,
pub his_rank: u64,
pub like: u64,
pub dislike: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct HomeRmdStat {
pub view: u64,
pub danmaku: u64,
pub like: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Rights {
pub bp: u8,
pub elec: u8,
pub download: u8,
pub movie: u8,
pub pay: u8,
pub hd5: u8,
pub no_reprint: u8,
pub autoplay: u8,
pub ugc_pay: u8,
pub is_cooperation: u8,
pub ugc_pay_preview: u8,
pub no_background: u8,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Dimension {
pub width: u32,
pub height: u32,
pub rotate: u8,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RelatedVideo {
pub aid: u64,
pub videos: u32,
pub tid: u32,
pub tname: String,
pub copyright: u8,
pub pic: String,
pub title: String,
pub pubdate: u64,
pub ctime: u64,
pub desc: String,
pub state: i8,
pub duration: u64,
pub rights: Rights,
pub owner: Owner,
pub stat: Stat,
pub dynamic: String,
pub cid: u64,
pub dimension: Dimension,
pub bvid: String,
#[serde(default)]
pub short_link_v2: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RcmdReason {
#[serde(rename = "reason_type")]
pub reason_type: u8,
pub content: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RcmdItem {
pub av_feature: Option<serde_json::Value>,
pub business_info: Option<serde_json::Value>,
pub bvid: String,
pub cid: u64,
pub duration: u64,
pub goto: String,
pub id: u64,
pub is_followed: u8,
pub is_stock: u8,
pub owner: Owner,
pub pic: String,
pub pos: u8,
pub pubdate: u64,
pub rcmd_reason: Option<RcmdReason>,
pub room_info: Option<serde_json::Value>,
pub show_info: u8,
pub stat: Option<HomeRmdStat>,
pub title: String,
pub track_id: String,
pub uri: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RcmdFeedResponseData {
pub item: Vec<RcmdItem>,
pub mid: u64,
pub preload_expose_pct: f32,
pub preload_floor_expose_pct: f32,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ids::Aid;
use crate::probe::contract::HttpMethod;
use crate::probe::endpoint_contract::EndpointContract;
use crate::video::params::{VideoHomepageRecommendationsParams, VideoRelatedParams};
use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
use tracing::info;
const TEST_AID: u64 = 10001;
#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_video_related_videos_by_aid() -> Result<(), BpiError> {
let bpi = BpiClient::new().expect("client should build");
let data = bpi
.video()
.related_videos(VideoRelatedParams::from_aid(Aid::new(TEST_AID)?))
.await?;
info!("单视频推荐列表: {:?}", data);
assert!(!data.is_empty());
assert!(data.len() <= 40);
Ok(())
}
#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_video_homepage_recommendations() -> Result<(), BpiError> {
let bpi = BpiClient::new().expect("client should build");
let params = VideoHomepageRecommendationsParams::new()
.page_size(12)?
.fresh_idx(1)?
.fetch_row(1)?;
let data = bpi.video().homepage_recommendations(params).await?;
info!("首页推荐列表: {:?}", data);
assert!(!data.item.is_empty());
assert!(data.item.len() <= 30);
Ok(())
}
fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
let bytes = match endpoint {
"related-videos" => include_bytes!(
"../../tests/contracts/video/player-read/related-videos/contract.json"
)
.as_slice(),
"homepage-recommendations" => include_bytes!(
"../../tests/contracts/video/player-read/homepage-recommendations/contract.json"
)
.as_slice(),
_ => unreachable!("unknown video recommend contract"),
};
EndpointContract::from_slice(bytes)
}
#[test]
fn video_related_videos_contract_matches_endpoint_request() -> BpiResult<()> {
let contract = contract("related-videos")?;
let params = VideoRelatedParams::from_bvid("BV1xx411c7mD".parse()?);
assert_eq!(contract.name, "video.related_videos");
assert_eq!(contract.request.method, HttpMethod::Get);
assert_eq!(contract.request.url.as_str(), RELATED_VIDEOS_ENDPOINT);
assert_eq!(
contract.request.query.get("bvid").map(String::as_str),
Some("BV1xx411c7mD")
);
assert_eq!(
params.query_pairs(),
vec![("bvid", "BV1xx411c7mD".to_string())]
);
assert_eq!(contract.cases.len(), 3);
Ok(())
}
#[test]
fn video_related_videos_response_fixture_parses_declared_model() -> BpiResult<()> {
let payload = ApiEnvelope::<Vec<RelatedVideo>>::from_slice(include_bytes!(
"../../tests/contracts/video/player-read/related-videos/responses/success.json"
))?
.into_payload()?;
assert_eq!(payload.len(), 1);
Ok(())
}
#[test]
fn video_homepage_recommendations_contract_matches_endpoint_request() -> BpiResult<()> {
let contract = contract("homepage-recommendations")?;
let params = VideoHomepageRecommendationsParams::new();
assert_eq!(contract.name, "video.homepage_recommendations");
assert_eq!(contract.request.method, HttpMethod::Get);
assert_eq!(
contract.request.url.as_str(),
HOMEPAGE_RECOMMENDATIONS_ENDPOINT
);
assert!(contract.request.auth.requires_wbi());
assert_eq!(
contract.request.query.get("ps").map(String::as_str),
Some("12")
);
assert_eq!(params.query_pairs().len(), 6);
assert_eq!(contract.cases.len(), 3);
Ok(())
}
#[test]
fn video_homepage_recommendations_response_fixture_parses_declared_model() -> BpiResult<()> {
let payload = ApiEnvelope::<RcmdFeedResponseData>::from_slice(include_bytes!(
"../../tests/contracts/video/player-read/homepage-recommendations/responses/success.json"
))?
.into_payload()?;
assert_eq!(payload.item.len(), 1);
Ok(())
}
fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
let path =
format!("target/bpi-probe-runs/video/player-read/{endpoint}/{profile}.response.json");
let bytes = std::fs::read(path).ok()?;
let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
value
.get("response")
.and_then(|response| response.get("body"))
.cloned()
}
#[test]
fn video_recommend_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
for profile in ["anonymous", "normal", "vip"] {
if let Some(body) = local_probe_body("related-videos", profile) {
let payload = serde_json::from_value::<ApiEnvelope<Vec<RelatedVideo>>>(body)?
.into_payload()?;
assert!(!payload.is_empty());
}
if let Some(body) = local_probe_body("homepage-recommendations", profile) {
let payload = serde_json::from_value::<ApiEnvelope<RcmdFeedResponseData>>(body)?
.into_payload()?;
assert!(!payload.item.is_empty());
}
}
Ok(())
}
}