use crate::BilibiliRequest;
use crate::historytoview::HistoryToViewClient;
use crate::historytoview::params::{HistoryDeleteParams, HistoryShadowSetParams};
use crate::response::BpiResult;
use serde::{Deserialize, Serialize};
const HISTORY_DELETE_ENDPOINT: &str = "https://api.bilibili.com/x/v2/history/delete";
const HISTORY_CLEAR_ENDPOINT: &str = "https://api.bilibili.com/x/v2/history/clear";
const HISTORY_SHADOW_SET_ENDPOINT: &str = "https://api.bilibili.com/x/v2/history/shadow/set";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryCursor {
pub max: u64,
pub view_at: u64,
pub business: String,
pub ps: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryTab {
#[serde(rename = "type")]
pub type_name: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum HistoryCovers {
Array(Vec<String>),
String(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryDetail {
pub oid: u64,
pub epid: Option<u64>,
pub bvid: Option<String>,
pub page: Option<u32>,
pub cid: Option<u64>,
pub part: Option<String>,
pub business: String,
pub dt: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryListItem {
pub title: String,
pub badge: Option<String>,
pub long_title: Option<String>,
pub cover: Option<String>,
pub covers: Option<Vec<String>>,
pub uri: Option<String>,
pub history: HistoryDetail,
pub videos: Option<u32>,
pub author_name: Option<String>,
pub author_face: Option<String>,
pub author_mid: Option<u64>,
pub view_at: u64,
pub progress: i32,
pub show_title: Option<String>,
pub duration: Option<u32>,
pub current: Option<String>,
pub total: Option<u32>,
pub new_desc: Option<String>,
pub is_finish: Option<u8>,
pub is_fav: u8,
pub kid: u64,
pub tag_name: Option<String>,
pub live_status: Option<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryListData {
pub cursor: HistoryCursor,
pub tab: Vec<HistoryTab>,
pub list: Vec<HistoryListItem>,
}
impl<'a> HistoryToViewClient<'a> {
pub async fn delete_history(
&self,
params: HistoryDeleteParams,
) -> BpiResult<Option<serde_json::Value>> {
let csrf = self.client.csrf()?;
self.client
.post(HISTORY_DELETE_ENDPOINT)
.form(¶ms.form_pairs(&csrf))
.send_bpi_optional_payload("historytoview.history.delete")
.await
}
pub async fn clear_history(&self) -> BpiResult<Option<serde_json::Value>> {
let csrf = self.client.csrf()?;
let payload = [("csrf", &csrf)];
self.client
.post(HISTORY_CLEAR_ENDPOINT)
.form(&payload)
.send_bpi_optional_payload("historytoview.history.clear")
.await
}
pub async fn set_history_shadow(
&self,
params: HistoryShadowSetParams,
) -> BpiResult<Option<serde_json::Value>> {
let csrf = self.client.csrf()?;
self.client
.post(HISTORY_SHADOW_SET_ENDPOINT)
.form(¶ms.form_pairs(&csrf))
.send_bpi_optional_payload("historytoview.history.shadow_set")
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::probe::contract::HttpMethod;
use crate::probe::endpoint_contract::EndpointContract;
use crate::{ApiEnvelope, BpiResult};
fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
let bytes = match endpoint {
"history-list" => include_bytes!(
"../../tests/contracts/historytoview/read/history-list/contract.json"
)
.as_slice(),
"history-shadow" => include_bytes!(
"../../tests/contracts/historytoview/read/history-shadow/contract.json"
)
.as_slice(),
_ => unreachable!("unknown historytoview history contract endpoint"),
};
EndpointContract::from_slice(bytes)
}
#[test]
fn history_list_contract_matches_endpoint_request() -> BpiResult<()> {
let contract = contract("history-list")?;
assert_eq!(contract.name, "historytoview.history_list");
assert_eq!(contract.request.method, HttpMethod::Get);
assert_eq!(
contract.request.url.as_str(),
"https://api.bilibili.com/x/web-interface/history/cursor"
);
assert_eq!(
contract.request.query.get("ps").map(String::as_str),
Some("5")
);
assert_eq!(contract.cases.len(), 3);
assert_eq!(contract.cases[0].response.api_code, Some(-101));
assert_eq!(
contract.cases[1].response.rust_model.as_deref(),
Some("HistoryListData")
);
Ok(())
}
#[test]
fn history_shadow_contract_matches_endpoint_request() -> BpiResult<()> {
let contract = contract("history-shadow")?;
assert_eq!(contract.name, "historytoview.history_shadow");
assert_eq!(contract.request.method, HttpMethod::Get);
assert_eq!(
contract.request.url.as_str(),
"https://api.bilibili.com/x/v2/history/shadow"
);
assert!(contract.request.query.is_empty());
assert_eq!(contract.cases.len(), 3);
assert_eq!(contract.cases[0].response.api_code, Some(-101));
assert_eq!(
contract.cases[1].response.rust_model.as_deref(),
Some("bool")
);
Ok(())
}
#[test]
fn history_response_fixtures_parse_declared_models() -> BpiResult<()> {
let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
"../../tests/contracts/historytoview/read/history-list/responses/anonymous.requires_login.json"
))?
.ensure_success()
.unwrap_err();
assert!(err.requires_login());
let list = ApiEnvelope::<HistoryListData>::from_slice(include_bytes!(
"../../tests/contracts/historytoview/read/history-list/responses/authenticated.success.json"
))?
.into_payload()?;
assert_eq!(list.cursor.ps, 5);
assert_eq!(list.list.len(), 1);
let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
"../../tests/contracts/historytoview/read/history-shadow/responses/anonymous.requires_login.json"
))?
.ensure_success()
.unwrap_err();
assert!(err.requires_login());
let shadow = ApiEnvelope::<bool>::from_slice(include_bytes!(
"../../tests/contracts/historytoview/read/history-shadow/responses/authenticated.success.json"
))?
.into_payload()?;
assert!(!shadow);
Ok(())
}
fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
let path =
format!("target/bpi-probe-runs/historytoview/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 history_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
for profile in ["anonymous", "normal", "vip"] {
if let Some(body) = local_probe_body("history-list", profile) {
let envelope = serde_json::from_value::<ApiEnvelope<HistoryListData>>(body)?;
if profile == "anonymous" {
let err = envelope.ensure_success().unwrap_err();
assert!(err.requires_login());
} else {
let payload = envelope.into_payload()?;
assert!(payload.cursor.ps > 0);
assert!(payload.cursor.ps as usize >= payload.list.len());
}
}
if let Some(body) = local_probe_body("history-shadow", profile) {
let envelope = serde_json::from_value::<ApiEnvelope<bool>>(body)?;
if profile == "anonymous" {
let err = envelope.ensure_success().unwrap_err();
assert!(err.requires_login());
} else {
let _ = envelope.into_payload()?;
}
}
}
Ok(())
}
}