1use crate::BilibiliRequest;
7use crate::audio::AudioClient;
8use crate::audio::params::{AudioCoinParams, AudioCollectionToFavParams, AudioCollectionToParams};
9use crate::response::BpiResult;
10use serde::{Deserialize, Serialize};
11
12const COLLECTION_TO_FAV_ENDPOINT: &str =
13 "https://api.bilibili.com/medialist/gateway/coll/resource/deal";
14const COLLECTION_TO_ENDPOINT: &str =
15 "https://www.bilibili.com/audio/music-service-c/web/collections/songs-coll";
16const COIN_ENDPOINT: &str = "https://www.bilibili.com/audio/music-service-c/web/coin/add";
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct PromptData {
20 prompt: bool,
22}
23
24impl<'a> AudioClient<'a> {
25 pub async fn favorite(&self, params: AudioCollectionToFavParams) -> BpiResult<PromptData> {
27 let csrf = self.client.csrf()?;
28
29 self.client
30 .post(COLLECTION_TO_FAV_ENDPOINT)
31 .form(¶ms.form_pairs(&csrf))
32 .send_bpi_payload("audio.favorite")
33 .await
34 }
35
36 pub async fn collect(&self, params: AudioCollectionToParams) -> BpiResult<bool> {
38 let csrf = self.client.csrf()?;
39
40 self.client
41 .post(COLLECTION_TO_ENDPOINT)
42 .form(¶ms.form_pairs(&csrf))
43 .send_bpi_payload("audio.collect")
44 .await
45 }
46
47 pub async fn coin(&self, params: AudioCoinParams) -> BpiResult<String> {
49 let csrf = self.client.csrf()?;
50
51 self.client
52 .post(COIN_ENDPOINT)
53 .form(¶ms.form_pairs(&csrf))
54 .send_bpi_payload("audio.coin")
55 .await
56 }
57}
58
59#[cfg(test)]
60mod tests {
61
62 use crate::audio::params::AudioSongParams;
63 use crate::ids::AudioId;
64 use crate::probe::contract::HttpMethod;
65 use crate::probe::endpoint_contract::EndpointContract;
66 use crate::{ApiEnvelope, BpiResult};
67
68 const TEST_SID: u64 = 13603;
71
72 fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
73 let bytes = match endpoint {
74 "collection-status" => {
75 include_bytes!("../../tests/contracts/audio/collection-status/contract.json")
76 .as_slice()
77 }
78 "coin-count" => {
79 include_bytes!("../../tests/contracts/audio/coin-count/contract.json").as_slice()
80 }
81 _ => unreachable!("unknown audio action contract"),
82 };
83
84 EndpointContract::from_slice(bytes)
85 }
86
87 #[test]
88 fn audio_collection_status_contract_matches_endpoint_request() -> BpiResult<()> {
89 let contract = contract("collection-status")?;
90 let params = AudioSongParams::new(AudioId::new(TEST_SID)?);
91
92 assert_eq!(contract.name, "audio.collection_status");
93 assert_eq!(contract.request.method, HttpMethod::Get);
94 assert_eq!(
95 contract.request.url.as_str(),
96 "https://www.bilibili.com/audio/music-service-c/web/collections/songs-coll"
97 );
98 assert_eq!(
99 contract.request.query.get("sid").map(String::as_str),
100 Some("13603")
101 );
102 assert_eq!(params.query_pairs(), vec![("sid", "13603".to_string())]);
103 assert_eq!(contract.cases.len(), 3);
104 assert_eq!(
105 contract.cases[0].response.error.as_deref(),
106 Some("requires_login")
107 );
108 assert_eq!(
109 contract.cases[1].response.rust_model.as_deref(),
110 Some("bool")
111 );
112 Ok(())
113 }
114
115 #[test]
116 fn audio_collection_status_response_fixtures_parse_declared_model() -> BpiResult<()> {
117 for bytes in [
118 include_bytes!(
119 "../../tests/contracts/audio/collection-status/responses/normal.success.json"
120 )
121 .as_slice(),
122 include_bytes!(
123 "../../tests/contracts/audio/collection-status/responses/vip.success.json"
124 )
125 .as_slice(),
126 ] {
127 let _payload = ApiEnvelope::<bool>::from_slice(bytes)?.into_payload()?;
128 }
129 Ok(())
130 }
131
132 #[test]
133 fn audio_collection_status_anonymous_fixture_records_login_error() -> BpiResult<()> {
134 let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
135 "../../tests/contracts/audio/collection-status/responses/anonymous.error.json"
136 ))?
137 .ensure_success()
138 .unwrap_err();
139
140 assert_eq!(err.code(), Some(4_511_003));
141 Ok(())
142 }
143
144 #[test]
145 fn audio_coin_count_contract_matches_endpoint_request() -> BpiResult<()> {
146 let contract = contract("coin-count")?;
147 let params = AudioSongParams::new(AudioId::new(TEST_SID)?);
148
149 assert_eq!(contract.name, "audio.coin_count");
150 assert_eq!(contract.request.method, HttpMethod::Get);
151 assert_eq!(
152 contract.request.url.as_str(),
153 "https://www.bilibili.com/audio/music-service-c/web/coin/audio"
154 );
155 assert_eq!(
156 contract.request.query.get("sid").map(String::as_str),
157 Some("13603")
158 );
159 assert_eq!(params.query_pairs(), vec![("sid", "13603".to_string())]);
160 assert_eq!(contract.cases.len(), 3);
161 assert_eq!(
162 contract.cases[0].response.error.as_deref(),
163 Some("requires_login")
164 );
165 assert_eq!(
166 contract.cases[1].response.rust_model.as_deref(),
167 Some("i32")
168 );
169 Ok(())
170 }
171
172 #[test]
173 fn audio_coin_count_response_fixtures_parse_declared_model() -> BpiResult<()> {
174 for bytes in [
175 include_bytes!("../../tests/contracts/audio/coin-count/responses/normal.success.json")
176 .as_slice(),
177 include_bytes!("../../tests/contracts/audio/coin-count/responses/vip.success.json")
178 .as_slice(),
179 ] {
180 let payload = ApiEnvelope::<i32>::from_slice(bytes)?.into_payload()?;
181
182 assert!((0..=2).contains(&payload));
183 }
184 Ok(())
185 }
186
187 #[test]
188 fn audio_coin_count_anonymous_fixture_records_login_error() -> BpiResult<()> {
189 let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
190 "../../tests/contracts/audio/coin-count/responses/anonymous.error.json"
191 ))?
192 .ensure_success()
193 .unwrap_err();
194
195 assert_eq!(err.code(), Some(4_511_003));
196 Ok(())
197 }
198
199 fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
200 let path =
201 format!("target/bpi-probe-runs/audio/public-read/{endpoint}/{profile}.response.json");
202 let bytes = std::fs::read(path).ok()?;
203 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
204 value
205 .get("response")
206 .and_then(|response| response.get("body"))
207 .cloned()
208 }
209
210 #[test]
211 fn audio_action_read_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
212 for profile in ["normal", "vip"] {
213 if let Some(body) = local_probe_body("collection-status", profile) {
214 let _payload = serde_json::from_value::<ApiEnvelope<bool>>(body)?.into_payload()?;
215 }
216
217 if let Some(body) = local_probe_body("coin-count", profile) {
218 let payload = serde_json::from_value::<ApiEnvelope<i32>>(body)?.into_payload()?;
219
220 assert!((0..=2).contains(&payload));
221 }
222 }
223 Ok(())
224 }
225}