bpi-rs 0.2.0

Bilibili API client library for Rust
Documentation
use serde::de;
use serde::{Deserialize, Deserializer, Serialize};

use crate::ids::Mid;

/// Login/navigation state returned by `/x/web-interface/nav`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoginNav {
    /// Whether the current session is logged in.
    #[serde(rename = "isLogin")]
    pub is_login: bool,
    /// Logged-in user ID. Guest responses return `0`, exposed as `None`.
    #[serde(default, deserialize_with = "deserialize_optional_mid")]
    pub mid: Option<Mid>,
    /// Logged-in display name. Empty guest values are exposed as `None`.
    #[serde(default, deserialize_with = "deserialize_optional_string")]
    pub uname: Option<String>,
    /// Logged-in avatar URL. Empty guest values are exposed as `None`.
    #[serde(default, deserialize_with = "deserialize_optional_string")]
    pub face: Option<String>,
    /// WBI image keys. Bilibili returns these for guest sessions too.
    pub wbi_img: LoginWbiImg,
}

/// WBI image key URLs embedded in the login nav response.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoginWbiImg {
    /// URL containing the img key filename.
    pub img_url: String,
    /// URL containing the sub key filename.
    pub sub_url: String,
}

/// Authenticated user's social counters returned by `/x/web-interface/nav/stat`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoginStats {
    /// Number of followed users.
    pub following: u64,
    /// Number of followers.
    pub follower: u64,
    /// Number of published dynamic posts.
    pub dynamic_count: u64,
}

/// Current authenticated account coin balance.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct LoginCoinBalance {
    /// Current coin balance.
    pub money: f64,
}

/// Today's experience gained from coin operations.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct LoginTodayCoinExp {
    /// Experience gained today.
    pub value: u32,
}

/// Daily reward completion state returned by `/x/member/web/exp/reward`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoginDailyReward {
    /// Whether the daily login reward is complete.
    pub login: bool,
    /// Whether the daily watch reward is complete.
    pub watch: bool,
    /// Experience gained from daily coin operations.
    pub coins: u32,
    /// Whether the daily share reward is complete.
    pub share: bool,
    /// Whether the email-binding reward is complete.
    pub email: bool,
    /// Whether the phone-binding reward is complete.
    pub tel: bool,
    /// Whether the safe-question reward is complete.
    pub safe_question: bool,
    /// Whether the real-name verification reward is complete.
    pub identify_card: bool,
}

/// Authenticated account profile returned by `/x/member/web/account`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoginAccountInfo {
    /// Current user's ID.
    pub mid: Mid,
    /// Current user's display name.
    pub uname: String,
    /// Login username, which may differ from the display name.
    pub userid: String,
    /// Current profile signature.
    pub sign: String,
    /// Birthday string returned by Bilibili, usually `YYYY-MM-DD`.
    pub birthday: String,
    /// Sex label returned by Bilibili.
    pub sex: String,
    /// Whether the account has not set a custom nickname.
    pub nick_free: bool,
    /// Membership rank string returned by Bilibili.
    pub rank: String,
}

/// Authenticated account VIP state returned by `/x/vip/web/user/info`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoginVipInfo {
    /// Current user's ID.
    pub mid: Mid,
    /// VIP type returned by Bilibili.
    pub vip_type: u8,
    /// VIP status returned by Bilibili.
    pub vip_status: u8,
    /// VIP expiry timestamp in milliseconds.
    pub vip_due_date: u64,
    /// VIP payment type returned by Bilibili.
    pub vip_pay_type: u8,
    /// VIP theme type returned by Bilibili.
    pub theme_type: u8,
}

impl LoginVipInfo {
    /// Returns whether the account currently has an active VIP status.
    pub fn is_active(self) -> bool {
        self.vip_status == 1 && self.vip_due_date > 0
    }
}

fn deserialize_optional_mid<'de, D>(deserializer: D) -> Result<Option<Mid>, D::Error>
where
    D: Deserializer<'de>,
{
    match Option::<u64>::deserialize(deserializer)? {
        Some(0) | None => Ok(None),
        Some(value) => Mid::new(value).map(Some).map_err(de::Error::custom),
    }
}

fn deserialize_optional_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
    D: Deserializer<'de>,
{
    Ok(Option::<String>::deserialize(deserializer)?
        .and_then(|value| (!value.trim().is_empty()).then_some(value)))
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde::de::DeserializeOwned;

    use crate::probe::endpoint_contract::EndpointContract;
    use crate::{ApiEnvelope, BpiError};

    const READ_INFO_ENDPOINTS: &[&str] = &["account-info", "coin", "nav", "stat", "today-coin-exp"];

    fn local_vip_info_probe_body(name: &str) -> Option<serde_json::Value> {
        let path = format!("target/bpi-probe-runs/login/vip-info/vip-info/{name}.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()
    }

    fn local_read_info_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
        let path =
            format!("target/bpi-probe-runs/login/read-info/{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()
    }

    fn read_info_payload<T>(endpoint: &str, profile: &str) -> Result<Option<T>, BpiError>
    where
        T: DeserializeOwned,
    {
        let Some(body) = local_read_info_probe_body(endpoint, profile) else {
            return Ok(None);
        };

        serde_json::from_value::<ApiEnvelope<T>>(body)?
            .into_payload()
            .map(Some)
    }

    fn fixture_bytes(
        endpoint: &str,
        case: &crate::probe::endpoint_contract::EndpointCase,
    ) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
        let fixture = case
            .response
            .fixture
            .as_deref()
            .ok_or_else(|| BpiError::unsupported_response("contract case missing fixture"))?;
        let path = format!("tests/contracts/login/{endpoint}/{fixture}");

        Ok(std::fs::read(path)?)
    }

    fn assert_fixture_matches_model(
        model: &str,
        bytes: &[u8],
    ) -> Result<(), Box<dyn std::error::Error>> {
        match model {
            "LoginAccountInfo" => {
                let payload = ApiEnvelope::<LoginAccountInfo>::from_slice(bytes)?.into_payload()?;
                assert!(payload.mid.get() > 0);
            }
            "LoginCoinBalance" => {
                let payload = ApiEnvelope::<LoginCoinBalance>::from_slice(bytes)?.into_payload()?;
                assert!(payload.money >= 0.0);
            }
            "LoginNav" => {
                let payload = ApiEnvelope::<LoginNav>::from_slice(bytes)?.into_payload()?;
                assert!(payload.is_login);
            }
            "LoginStats" => {
                let payload = ApiEnvelope::<LoginStats>::from_slice(bytes)?.into_payload()?;
                assert!(payload.following > 0);
            }
            "LoginTodayCoinExp" => {
                let payload =
                    ApiEnvelope::<LoginTodayCoinExp>::from_slice(bytes)?.into_payload()?;
                assert!(payload.value <= 50);
            }
            "LoginDailyReward" => {
                let payload = ApiEnvelope::<LoginDailyReward>::from_slice(bytes)?.into_payload()?;
                assert!(payload.coins <= 50);
            }
            "LoginVipInfo" => {
                let payload = ApiEnvelope::<LoginVipInfo>::from_slice(bytes)?.into_payload()?;
                assert!(payload.mid.get() > 0);
            }
            _ => {
                return Err(Box::new(BpiError::unsupported_response(format!(
                    "unknown login response model {model}"
                ))));
            }
        }

        Ok(())
    }

    fn assert_login_contract_fixtures_parse(
        endpoint: &str,
    ) -> Result<(), Box<dyn std::error::Error>> {
        let contract = EndpointContract::from_slice(&std::fs::read(format!(
            "tests/contracts/login/{endpoint}/contract.json"
        ))?)?;

        for case in &contract.cases {
            if case.response.fixture.is_none() {
                assert!(
                    case.response.error.is_some() || case.response.http_status.is_some(),
                    "contract case without fixture must document observed error/status"
                );
                continue;
            }

            let bytes = fixture_bytes(endpoint, case)?;
            if let Some(model) = &case.response.rust_model {
                assert_fixture_matches_model(model, &bytes)?;
            } else if case.response.error.as_deref() == Some("requires_login") {
                let err = ApiEnvelope::<serde_json::Value>::from_slice(&bytes)?
                    .ensure_success()
                    .unwrap_err();
                assert!(err.requires_login());
            }
        }

        Ok(())
    }

    #[test]
    fn login_vip_info_matches_local_probe_outputs_when_available() -> Result<(), BpiError> {
        let Some(normal_body) = local_vip_info_probe_body("normal") else {
            return Ok(());
        };
        let Some(active_body) = local_vip_info_probe_body("vip") else {
            return Ok(());
        };

        let normal: LoginVipInfo =
            serde_json::from_value::<ApiEnvelope<LoginVipInfo>>(normal_body)?.into_payload()?;
        let active: LoginVipInfo =
            serde_json::from_value::<ApiEnvelope<LoginVipInfo>>(active_body)?.into_payload()?;

        assert!(!normal.is_active());
        assert!(active.is_active());
        Ok(())
    }

    #[test]
    fn login_vip_info_anonymous_probe_returns_login_required_when_available() -> Result<(), BpiError>
    {
        let Some(body) = local_vip_info_probe_body("anonymous") else {
            return Ok(());
        };

        let err = serde_json::from_value::<ApiEnvelope<LoginVipInfo>>(body)?
            .ensure_success()
            .unwrap_err();

        assert!(err.requires_login());
        Ok(())
    }

    #[test]
    fn login_read_info_models_match_local_probe_outputs_when_available() -> Result<(), BpiError> {
        for profile in ["normal", "vip"] {
            if let Some(nav) = read_info_payload::<LoginNav>("nav", profile)? {
                assert!(nav.is_login);
                assert!(nav.mid.is_some());
            }

            let _ = read_info_payload::<LoginStats>("stat", profile)?;

            if let Some(coin) = read_info_payload::<LoginCoinBalance>("coin", profile)? {
                assert!(coin.money >= 0.0);
            }

            if let Some(exp) = read_info_payload::<LoginTodayCoinExp>("today-coin-exp", profile)? {
                assert!(exp.value <= 50);
            }

            if let Some(account) = read_info_payload::<LoginAccountInfo>("account-info", profile)? {
                assert!(account.mid.get() > 0);
                assert!(!account.uname.trim().is_empty());
            }
        }
        Ok(())
    }

    #[test]
    fn login_read_info_anonymous_probes_return_login_required_when_available()
    -> Result<(), BpiError> {
        for endpoint in READ_INFO_ENDPOINTS {
            let Some(body) = local_read_info_probe_body(endpoint, "anonymous") else {
                continue;
            };

            let err = serde_json::from_value::<ApiEnvelope<serde_json::Value>>(body)?
                .ensure_success()
                .unwrap_err();

            assert!(err.requires_login());
        }
        Ok(())
    }

    #[test]
    fn login_contract_response_fixtures_parse_declared_models()
    -> Result<(), Box<dyn std::error::Error>> {
        assert_login_contract_fixtures_parse("vip-info")?;
        assert_login_contract_fixtures_parse("daily-reward")?;
        for endpoint in READ_INFO_ENDPOINTS {
            assert_login_contract_fixtures_parse(endpoint)?;
        }
        Ok(())
    }
}