1use std::sync::{Arc, Mutex};
2use std::time::Duration;
3
4use reqwest::cookie::CookieStore;
5use reqwest::header::{COOKIE, HeaderValue, ORIGIN, REFERER, USER_AGENT};
6use reqwest::{Client, RequestBuilder, Url};
7
8use crate::BpiError;
9#[cfg(feature = "activity")]
10use crate::activity::ActivityClient;
11#[cfg(feature = "article")]
12use crate::article::ArticleClient;
13#[cfg(feature = "audio")]
14use crate::audio::AudioClient;
15#[cfg(feature = "bangumi")]
16use crate::bangumi::BangumiClient;
17#[cfg(feature = "cheese")]
18use crate::cheese::CheeseClient;
19#[cfg(feature = "clientinfo")]
20use crate::clientinfo::ClientInfoClient;
21#[cfg(feature = "comment")]
22use crate::comment::CommentClient;
23#[cfg(feature = "creativecenter")]
24use crate::creativecenter::CreativeCenterClient;
25#[cfg(feature = "danmaku")]
26use crate::danmaku::DanmakuClient;
27#[cfg(feature = "dynamic")]
28use crate::dynamic::DynamicClient;
29#[cfg(feature = "electric")]
30use crate::electric::ElectricClient;
31#[cfg(feature = "fav")]
32use crate::fav::FavClient;
33#[cfg(feature = "historytoview")]
34use crate::historytoview::HistoryToViewClient;
35#[cfg(feature = "live")]
36use crate::live::LiveClient;
37#[cfg(feature = "login")]
38use crate::login::LoginClient;
39#[cfg(feature = "manga")]
40use crate::manga::MangaClient;
41#[cfg(feature = "message")]
42use crate::message::MessageClient;
43#[cfg(feature = "misc")]
44use crate::misc::MiscClient;
45#[cfg(feature = "note")]
46use crate::note::NoteClient;
47#[cfg(feature = "opus")]
48use crate::opus::OpusClient;
49#[cfg(feature = "search")]
50use crate::search::SearchClient;
51use crate::session::Account;
52use crate::session::cookie::{format_cookie_pairs, parse_cookie_header as parse_cookie_pairs};
53use crate::sign::wbi::WbiKeyCache;
54#[cfg(feature = "user")]
55use crate::user::UserClient;
56#[cfg(feature = "video")]
57use crate::video::VideoClient;
58#[cfg(feature = "video_ranking")]
59use crate::video_ranking::VideoRankingClient;
60#[cfg(feature = "vip")]
61use crate::vip::VipClient;
62#[cfg(feature = "wallet")]
63use crate::wallet::WalletClient;
64#[cfg(feature = "web_widget")]
65use crate::web_widget::WebWidgetClient;
66
67const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
68 AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
69const DEFAULT_REFERER: &str = "https://www.bilibili.com/";
70const DEFAULT_ORIGIN: &str = "https://www.bilibili.com";
71const BILIBILI_URL: &str = "https://www.bilibili.com";
72const API_BILIBILI_URL: &str = "https://api.bilibili.com";
73const AUTH_COOKIE_NAMES: &[&str] = &[
74 "DedeUserID",
75 "DedeUserID__ckMd5",
76 "SESSDATA",
77 "bili_jct",
78 "buvid3",
79];
80
81#[derive(Debug)]
83pub struct BpiClientBuilder {
84 timeout: Duration,
85 connect_timeout: Duration,
86 user_agent: String,
87 referer: String,
88 origin: String,
89 no_proxy: bool,
90 proxies: Vec<reqwest::Proxy>,
91 cookie: Option<String>,
92 account: Option<Account>,
93 reqwest_client: Option<Client>,
94}
95
96impl Default for BpiClientBuilder {
97 fn default() -> Self {
98 Self {
99 timeout: Duration::from_secs(10),
100 connect_timeout: Duration::from_secs(10),
101 user_agent: DEFAULT_USER_AGENT.to_string(),
102 referer: DEFAULT_REFERER.to_string(),
103 origin: DEFAULT_ORIGIN.to_string(),
104 no_proxy: true,
105 proxies: Vec::new(),
106 cookie: None,
107 account: None,
108 reqwest_client: None,
109 }
110 }
111}
112
113impl BpiClientBuilder {
114 pub fn timeout(mut self, timeout: Duration) -> Self {
116 self.timeout = timeout;
117 self
118 }
119
120 pub fn connect_timeout(mut self, timeout: Duration) -> Self {
122 self.connect_timeout = timeout;
123 self
124 }
125
126 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
128 self.user_agent = user_agent.into();
129 self
130 }
131
132 pub fn referer(mut self, referer: impl Into<String>) -> Self {
134 self.referer = referer.into();
135 self
136 }
137
138 pub fn origin(mut self, origin: impl Into<String>) -> Self {
140 self.origin = origin.into();
141 self
142 }
143
144 pub fn no_proxy(mut self, enabled: bool) -> Self {
146 self.no_proxy = enabled;
147 self
148 }
149
150 pub fn proxy(mut self, proxy: reqwest::Proxy) -> Self {
152 self.proxies.push(proxy);
153 self
154 }
155
156 pub fn cookie(mut self, cookie: impl Into<String>) -> Self {
158 self.cookie = Some(cookie.into());
159 self
160 }
161
162 pub fn account(mut self, account: Account) -> Self {
164 self.account = Some(account);
165 self
166 }
167
168 pub fn reqwest_client(mut self, client: Client) -> Self {
170 self.reqwest_client = Some(client);
171 self
172 }
173
174 pub fn build(self) -> Result<BpiClient, BpiError> {
176 let jar = Arc::new(reqwest::cookie::Jar::default());
177 let mut account = None;
178 let mut cookie_header = None;
179
180 if let Some(cookie) = self.cookie {
181 let pairs = parse_cookie_pairs(&cookie)?;
182 add_cookie_pairs(&jar, &pairs);
183 cookie_header = Some(format_cookie_pairs(&pairs));
184
185 let cookie_account = Account::from_cookie_pairs(&pairs);
186 if cookie_account.is_complete() {
187 account = Some(cookie_account);
188 }
189 }
190
191 if let Some(configured_account) = self.account {
192 configured_account.validate_complete()?;
193 let pairs = configured_account.cookie_pairs();
194 add_cookie_pairs(&jar, &pairs);
195 cookie_header = Some(format_cookie_pairs(&pairs));
196 account = Some(configured_account);
197 }
198
199 let client = match self.reqwest_client {
200 Some(client) => client,
201 None => {
202 let mut builder = Client::builder()
203 .timeout(self.timeout)
204 .connect_timeout(self.connect_timeout)
205 .gzip(true)
206 .deflate(true)
207 .brotli(true)
208 .cookie_provider(jar.clone())
209 .pool_max_idle_per_host(0);
210
211 if self.no_proxy {
212 builder = builder.no_proxy();
213 }
214
215 for proxy in self.proxies {
216 builder = builder.proxy(proxy);
217 }
218
219 builder.build()?
220 }
221 };
222
223 Ok(BpiClient {
224 client,
225 jar,
226 account: Mutex::new(account),
227 user_agent: validate_header("user_agent", &self.user_agent)?,
228 referer: validate_header("referer", &self.referer)?,
229 origin: validate_header("origin", &self.origin)?,
230 cookie_header: Mutex::new(cookie_header),
231 wbi_key_cache: WbiKeyCache::default(),
232 })
233 }
234}
235
236pub struct BpiClient {
238 client: Client,
239 jar: Arc<reqwest::cookie::Jar>,
240 account: Mutex<Option<Account>>,
241 user_agent: HeaderValue,
242 referer: HeaderValue,
243 origin: HeaderValue,
244 cookie_header: Mutex<Option<String>>,
245 wbi_key_cache: WbiKeyCache,
246}
247
248impl BpiClient {
249 pub fn new() -> Result<Self, BpiError> {
251 Self::builder().build()
252 }
253
254 pub fn builder() -> BpiClientBuilder {
256 BpiClientBuilder::default()
257 }
258
259 pub fn set_account(&self, account: Account) -> Result<(), BpiError> {
261 account.validate_complete()?;
262
263 let pairs = account.cookie_pairs();
264 add_cookie_pairs(&self.jar, &pairs);
265 *self
266 .cookie_header
267 .lock()
268 .expect("cookie header mutex poisoned") = Some(format_cookie_pairs(&pairs));
269 *self.account.lock().expect("account mutex poisoned") = Some(account);
270 tracing::info!("Bilibili account configured");
271 Ok(())
272 }
273
274 pub fn clear_account(&self) {
276 *self.account.lock().expect("account mutex poisoned") = None;
277 *self
278 .cookie_header
279 .lock()
280 .expect("cookie header mutex poisoned") = None;
281 expire_auth_cookies(&self.jar);
282 tracing::info!("Bilibili account cleared");
283 }
284
285 pub fn set_account_from_cookie_str(&self, cookie_str: &str) -> Result<(), BpiError> {
287 let pairs = parse_cookie_pairs(cookie_str)?;
288 let account = Account::from_cookie_pairs(&pairs);
289 account.validate_complete()?;
290
291 add_cookie_pairs(&self.jar, &pairs);
292 *self
293 .cookie_header
294 .lock()
295 .expect("cookie header mutex poisoned") = Some(format_cookie_pairs(&pairs));
296 *self.account.lock().expect("account mutex poisoned") = Some(account);
297 Ok(())
298 }
299
300 pub fn has_login_cookies(&self) -> bool {
302 if self
303 .cookie_header
304 .lock()
305 .expect("cookie header mutex poisoned")
306 .as_deref()
307 .is_some_and(contains_login_cookie)
308 {
309 return true;
310 }
311
312 let url = Url::parse(API_BILIBILI_URL).expect("static Bilibili API URL is valid");
313 self.jar
314 .cookies(&url)
315 .and_then(|cookies| cookies.to_str().ok().map(contains_login_cookie))
316 .unwrap_or(false)
317 }
318
319 pub fn get_account(&self) -> Option<Account> {
321 self.account.lock().expect("account mutex poisoned").clone()
322 }
323
324 pub fn csrf(&self) -> Result<String, BpiError> {
326 let account = self.account.lock().expect("account mutex poisoned");
327 let account = account.as_ref().ok_or_else(BpiError::auth_required)?;
328
329 account.csrf().map(str::to_owned)
330 }
331
332 pub fn get(&self, url: &str) -> RequestBuilder {
334 self.apply_default_headers(url, self.client.get(url))
335 }
336
337 #[cfg(feature = "danmaku")]
339 pub(crate) fn get_without_response_decoding(
340 &self,
341 url: &str,
342 ) -> Result<RequestBuilder, BpiError> {
343 let client = Client::builder()
344 .no_gzip()
345 .no_brotli()
346 .no_deflate()
347 .no_proxy()
348 .cookie_provider(self.jar.clone())
349 .pool_max_idle_per_host(0)
350 .build()?;
351
352 Ok(self.apply_default_headers(url, client.get(url)))
353 }
354
355 pub fn post(&self, url: &str) -> RequestBuilder {
357 self.apply_default_headers(url, self.client.post(url))
358 }
359
360 fn apply_default_headers(&self, url: &str, builder: RequestBuilder) -> RequestBuilder {
361 let builder = builder
362 .header(USER_AGENT, self.user_agent.clone())
363 .header(REFERER, self.referer.clone())
364 .header(ORIGIN, self.origin.clone());
365
366 if !is_bilibili_url(url) {
367 return builder;
368 }
369
370 match self
371 .cookie_header
372 .lock()
373 .expect("cookie header mutex poisoned")
374 .as_ref()
375 {
376 Some(cookie_header) => builder.header(COOKIE, cookie_header),
377 None => builder,
378 }
379 }
380
381 pub(crate) fn wbi_key_cache(&self) -> &WbiKeyCache {
382 &self.wbi_key_cache
383 }
384
385 #[cfg(test)]
386 fn cookie_header_for_test(&self) -> Option<String> {
387 self.cookie_header
388 .lock()
389 .expect("cookie header mutex poisoned")
390 .clone()
391 }
392
393 #[cfg(test)]
394 fn insert_wbi_keys_for_test(
395 &self,
396 bucket: impl Into<String>,
397 keys: crate::sign::wbi::WbiKeys,
398 ) -> Result<(), BpiError> {
399 self.wbi_key_cache.insert(bucket, keys)
400 }
401
402 #[cfg(test)]
403 fn wbi_keys_for_test(
404 &self,
405 bucket: &str,
406 ) -> Result<Option<crate::sign::wbi::WbiKeys>, BpiError> {
407 self.wbi_key_cache.get(bucket)
408 }
409}
410
411impl BpiClient {
412 #[cfg(feature = "activity")]
414 pub fn activity(&self) -> ActivityClient<'_> {
415 ActivityClient::new(self)
416 }
417
418 #[cfg(feature = "article")]
420 pub fn article(&self) -> ArticleClient<'_> {
421 ArticleClient::new(self)
422 }
423
424 #[cfg(feature = "audio")]
426 pub fn audio(&self) -> AudioClient<'_> {
427 AudioClient::new(self)
428 }
429
430 #[cfg(feature = "bangumi")]
432 pub fn bangumi(&self) -> BangumiClient<'_> {
433 BangumiClient::new(self)
434 }
435
436 #[cfg(feature = "cheese")]
438 pub fn cheese(&self) -> CheeseClient<'_> {
439 CheeseClient::new(self)
440 }
441
442 #[cfg(feature = "clientinfo")]
444 pub fn clientinfo(&self) -> ClientInfoClient<'_> {
445 ClientInfoClient::new(self)
446 }
447
448 #[cfg(feature = "comment")]
450 pub fn comment(&self) -> CommentClient<'_> {
451 CommentClient::new(self)
452 }
453
454 #[cfg(feature = "creativecenter")]
456 pub fn creativecenter(&self) -> CreativeCenterClient<'_> {
457 CreativeCenterClient::new(self)
458 }
459
460 #[cfg(feature = "danmaku")]
462 pub fn danmaku(&self) -> DanmakuClient<'_> {
463 DanmakuClient::new(self)
464 }
465
466 #[cfg(feature = "dynamic")]
468 pub fn dynamic(&self) -> DynamicClient<'_> {
469 DynamicClient::new(self)
470 }
471
472 #[cfg(feature = "electric")]
474 pub fn electric(&self) -> ElectricClient<'_> {
475 ElectricClient::new(self)
476 }
477
478 #[cfg(feature = "fav")]
480 pub fn fav(&self) -> FavClient<'_> {
481 FavClient::new(self)
482 }
483
484 #[cfg(feature = "historytoview")]
486 pub fn historytoview(&self) -> HistoryToViewClient<'_> {
487 HistoryToViewClient::new(self)
488 }
489
490 #[cfg(feature = "login")]
492 pub fn login(&self) -> LoginClient<'_> {
493 LoginClient::new(self)
494 }
495
496 #[cfg(feature = "live")]
498 pub fn live(&self) -> LiveClient<'_> {
499 LiveClient::new(self)
500 }
501
502 #[cfg(feature = "manga")]
504 pub fn manga(&self) -> MangaClient<'_> {
505 MangaClient::new(self)
506 }
507
508 #[cfg(feature = "misc")]
510 pub fn misc(&self) -> MiscClient<'_> {
511 MiscClient::new(self)
512 }
513
514 #[cfg(feature = "message")]
516 pub fn message(&self) -> MessageClient<'_> {
517 MessageClient::new(self)
518 }
519
520 #[cfg(feature = "note")]
522 pub fn note(&self) -> NoteClient<'_> {
523 NoteClient::new(self)
524 }
525
526 #[cfg(feature = "opus")]
528 pub fn opus(&self) -> OpusClient<'_> {
529 OpusClient::new(self)
530 }
531
532 #[cfg(feature = "search")]
534 pub fn search(&self) -> SearchClient<'_> {
535 SearchClient::new(self)
536 }
537
538 #[cfg(feature = "video")]
540 pub fn video(&self) -> VideoClient<'_> {
541 VideoClient::new(self)
542 }
543
544 #[cfg(feature = "video_ranking")]
546 pub fn video_ranking(&self) -> VideoRankingClient<'_> {
547 VideoRankingClient::new(self)
548 }
549
550 #[cfg(feature = "vip")]
552 pub fn vip(&self) -> VipClient<'_> {
553 VipClient::new(self)
554 }
555
556 #[cfg(feature = "wallet")]
558 pub fn wallet(&self) -> WalletClient<'_> {
559 WalletClient::new(self)
560 }
561
562 #[cfg(feature = "user")]
564 pub fn user(&self) -> UserClient<'_> {
565 UserClient::new(self)
566 }
567
568 #[cfg(feature = "web_widget")]
570 pub fn web_widget(&self) -> WebWidgetClient<'_> {
571 WebWidgetClient::new(self)
572 }
573
574 pub fn from_config(config: &Account) -> Result<Self, BpiError> {
576 Self::builder().account(config.clone()).build()
577 }
578}
579
580fn add_cookie_pairs(jar: &reqwest::cookie::Jar, pairs: &[(String, String)]) {
581 let url = Url::parse(BILIBILI_URL).expect("static Bilibili URL is valid");
582 for (key, value) in pairs {
583 let cookie = format!("{key}={value}; Domain=.bilibili.com; Path=/");
584 jar.add_cookie_str(&cookie, &url);
585 }
586}
587
588fn expire_auth_cookies(jar: &reqwest::cookie::Jar) {
589 let url = Url::parse(BILIBILI_URL).expect("static Bilibili URL is valid");
590 for key in AUTH_COOKIE_NAMES {
591 let cookie = format!(
592 "{key}=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Domain=.bilibili.com; Path=/"
593 );
594 jar.add_cookie_str(&cookie, &url);
595 }
596}
597
598fn contains_login_cookie(cookie_header: &str) -> bool {
599 parse_cookie_pairs(cookie_header)
600 .map(|pairs| {
601 pairs
602 .iter()
603 .any(|(key, value)| key.eq_ignore_ascii_case("SESSDATA") && !value.is_empty())
604 })
605 .unwrap_or(false)
606}
607
608fn validate_header(field: &'static str, value: &str) -> Result<HeaderValue, BpiError> {
609 HeaderValue::from_str(value)
610 .map_err(|_| BpiError::invalid_parameter(field, "invalid header value"))
611}
612
613fn is_bilibili_url(url: &str) -> bool {
614 Url::parse(url)
615 .ok()
616 .and_then(|url| url.host_str().map(|host| host.ends_with("bilibili.com")))
617 .unwrap_or(false)
618}
619
620#[cfg(test)]
621mod tests {
622 use super::*;
623
624 #[test]
625 fn builder_creates_owned_client_without_account_side_effects() -> Result<(), BpiError> {
626 let client = BpiClient::builder().build()?;
627
628 assert!(client.get_account().is_none());
629 Ok(())
630 }
631
632 #[test]
633 fn builder_keeps_cookie_state_isolated_between_clients() -> Result<(), BpiError> {
634 let first = BpiClient::builder().cookie("SESSDATA=first").build()?;
635 let second = BpiClient::builder().cookie("SESSDATA=second").build()?;
636
637 assert_ne!(
638 first.cookie_header_for_test(),
639 second.cookie_header_for_test()
640 );
641 Ok(())
642 }
643
644 #[test]
645 fn clear_account_removes_login_cookie_state() -> Result<(), BpiError> {
646 let client = BpiClient::builder()
647 .cookie("DedeUserID=42; SESSDATA=session; bili_jct=csrf; buvid3=buvid")
648 .build()?;
649 assert!(client.has_login_cookies());
650
651 client.clear_account();
652
653 assert!(!client.has_login_cookies());
654 assert!(client.cookie_header_for_test().is_none());
655 assert!(client.get_account().is_none());
656 Ok(())
657 }
658
659 #[test]
660 fn has_login_cookies_requires_sessdata_cookie() -> Result<(), BpiError> {
661 let client = BpiClient::builder().cookie("buvid3=buvid").build()?;
662
663 assert!(!client.has_login_cookies());
664 Ok(())
665 }
666
667 #[test]
668 fn cookie_string_preserves_dede_user_id_ckmd5_cookie() -> Result<(), BpiError> {
669 let client = BpiClient::builder()
670 .cookie(
671 "DedeUserID=42; DedeUserID__ckMd5=ck; SESSDATA=session; bili_jct=csrf; buvid3=buvid",
672 )
673 .build()?;
674
675 let cookie_header = client
676 .cookie_header_for_test()
677 .ok_or_else(|| BpiError::unsupported_response("missing cookie header"))?;
678
679 assert!(cookie_header.contains("DedeUserID__ckMd5=ck"));
680 Ok(())
681 }
682
683 #[test]
684 fn builder_rejects_cookie_strings_without_pairs() {
685 let result = BpiClient::builder().cookie("not-a-cookie").build();
686
687 assert!(matches!(
688 result,
689 Err(BpiError::InvalidParameter {
690 field: "cookie",
691 ..
692 })
693 ));
694 }
695
696 #[test]
697 fn builder_rejects_incomplete_structured_account() {
698 let result = BpiClient::builder().account(Account::default()).build();
699
700 assert!(matches!(
701 result,
702 Err(BpiError::InvalidParameter {
703 field: "account",
704 ..
705 })
706 ));
707 }
708
709 #[test]
710 fn set_account_rejects_incomplete_account_without_replacing_existing_state()
711 -> Result<(), BpiError> {
712 let client = BpiClient::builder()
713 .cookie("DedeUserID=42; SESSDATA=session; bili_jct=csrf; buvid3=buvid")
714 .build()?;
715
716 let err = client.set_account(Account::default()).unwrap_err();
717
718 assert!(matches!(
719 err,
720 BpiError::InvalidParameter {
721 field: "account",
722 ..
723 }
724 ));
725 assert_eq!(client.csrf()?, "csrf");
726 assert!(client.has_login_cookies());
727 Ok(())
728 }
729
730 #[test]
731 fn set_account_from_cookie_str_rejects_incomplete_login_cookie_without_replacing_existing_state()
732 -> Result<(), BpiError> {
733 let client = BpiClient::builder()
734 .cookie("DedeUserID=42; SESSDATA=session; bili_jct=csrf; buvid3=buvid")
735 .build()?;
736
737 let err = client
738 .set_account_from_cookie_str("buvid3=guest-buvid")
739 .unwrap_err();
740
741 assert!(matches!(
742 err,
743 BpiError::InvalidParameter {
744 field: "account",
745 ..
746 }
747 ));
748 assert_eq!(client.csrf()?, "csrf");
749 assert!(client.has_login_cookies());
750 Ok(())
751 }
752
753 #[test]
754 fn builder_applies_default_headers_to_requests() -> Result<(), BpiError> {
755 let client = BpiClient::builder()
756 .user_agent("test-agent")
757 .referer("https://example.com/referer")
758 .origin("https://example.com")
759 .build()?;
760
761 let request = client.get("https://api.bilibili.com/x/test").build()?;
762
763 assert_eq!(request.headers()[USER_AGENT], "test-agent");
764 assert_eq!(request.headers()[REFERER], "https://example.com/referer");
765 assert_eq!(request.headers()[ORIGIN], "https://example.com");
766 Ok(())
767 }
768
769 #[cfg(feature = "danmaku")]
770 #[test]
771 fn raw_response_request_keeps_default_headers_and_cookie() -> Result<(), BpiError> {
772 let client = BpiClient::builder()
773 .user_agent("test-agent")
774 .cookie("DedeUserID=42; SESSDATA=session; bili_jct=csrf; buvid3=buvid")
775 .build()?;
776
777 let request = client
778 .get_without_response_decoding("https://api.bilibili.com/x/v2/dm/history")?
779 .build()?;
780
781 assert_eq!(request.headers()[USER_AGENT], "test-agent");
782 assert!(
783 request
784 .headers()
785 .get(COOKIE)
786 .and_then(|value| value.to_str().ok())
787 .is_some_and(|value| value.contains("SESSDATA=session"))
788 );
789 Ok(())
790 }
791
792 #[test]
793 fn builder_accepts_explicit_proxy_configuration() -> Result<(), BpiError> {
794 let proxy = reqwest::Proxy::http("http://127.0.0.1:8080")?;
795
796 let client = BpiClient::builder().no_proxy(false).proxy(proxy).build()?;
797
798 assert!(client.get_account().is_none());
799 Ok(())
800 }
801
802 #[test]
803 fn builder_keeps_wbi_key_cache_isolated_between_clients() -> Result<(), BpiError> {
804 let first = BpiClient::new()?;
805 let second = BpiClient::new()?;
806
807 first.insert_wbi_keys_for_test(
808 "2026-07-02T10",
809 crate::sign::wbi::WbiKeys::new("abcdefghijklmnopqrstuvwxyz123456", "sub-key-a")?,
810 )?;
811 second.insert_wbi_keys_for_test(
812 "2026-07-02T10",
813 crate::sign::wbi::WbiKeys::new("ABCDEFGHIJKLMNOPQRSTUVWXYZ654321", "sub-key-b")?,
814 )?;
815
816 assert_ne!(
817 first.wbi_keys_for_test("2026-07-02T10")?,
818 second.wbi_keys_for_test("2026-07-02T10")?
819 );
820 Ok(())
821 }
822
823 #[cfg(feature = "web_widget")]
824 #[test]
825 fn web_widget_domain_client_can_be_created() -> Result<(), BpiError> {
826 let client = BpiClient::new()?;
827
828 let _web_widget = client.web_widget();
829
830 Ok(())
831 }
832
833 #[cfg(feature = "activity")]
834 #[test]
835 fn activity_domain_client_can_be_created() -> Result<(), BpiError> {
836 let client = BpiClient::new()?;
837
838 let _activity = client.activity();
839
840 Ok(())
841 }
842
843 #[cfg(feature = "audio")]
844 #[test]
845 fn audio_domain_client_can_be_created() -> Result<(), BpiError> {
846 let client = BpiClient::new()?;
847
848 let _audio = client.audio();
849
850 Ok(())
851 }
852
853 #[cfg(feature = "article")]
854 #[test]
855 fn article_domain_client_can_be_created() -> Result<(), BpiError> {
856 let client = BpiClient::new()?;
857
858 let _article = client.article();
859
860 Ok(())
861 }
862
863 #[cfg(feature = "bangumi")]
864 #[test]
865 fn bangumi_domain_client_can_be_created() -> Result<(), BpiError> {
866 let client = BpiClient::new()?;
867
868 let _bangumi = client.bangumi();
869
870 Ok(())
871 }
872
873 #[cfg(feature = "cheese")]
874 #[test]
875 fn cheese_domain_client_can_be_created() -> Result<(), BpiError> {
876 let client = BpiClient::new()?;
877
878 let _cheese = client.cheese();
879
880 Ok(())
881 }
882
883 #[cfg(feature = "wallet")]
884 #[test]
885 fn wallet_domain_client_can_be_created() -> Result<(), BpiError> {
886 let client = BpiClient::new()?;
887
888 let _wallet = client.wallet();
889
890 Ok(())
891 }
892
893 #[cfg(feature = "opus")]
894 #[test]
895 fn opus_domain_client_can_be_created() -> Result<(), BpiError> {
896 let client = BpiClient::new()?;
897
898 let _opus = client.opus();
899
900 Ok(())
901 }
902
903 #[cfg(feature = "misc")]
904 #[test]
905 fn misc_domain_client_can_be_created() -> Result<(), BpiError> {
906 let client = BpiClient::new()?;
907
908 let _misc = client.misc();
909
910 Ok(())
911 }
912
913 #[cfg(feature = "message")]
914 #[test]
915 fn message_domain_client_can_be_created() -> Result<(), BpiError> {
916 let client = BpiClient::new()?;
917
918 let _message = client.message();
919
920 Ok(())
921 }
922
923 #[cfg(feature = "vip")]
924 #[test]
925 fn vip_domain_client_can_be_created() -> Result<(), BpiError> {
926 let client = BpiClient::new()?;
927
928 let _vip = client.vip();
929
930 Ok(())
931 }
932
933 #[cfg(feature = "comment")]
934 #[test]
935 fn comment_domain_client_can_be_created() -> Result<(), BpiError> {
936 let client = BpiClient::new()?;
937
938 let _comment = client.comment();
939
940 Ok(())
941 }
942
943 #[cfg(feature = "creativecenter")]
944 #[test]
945 fn creativecenter_domain_client_can_be_created() -> Result<(), BpiError> {
946 let client = BpiClient::new()?;
947
948 let _creativecenter = client.creativecenter();
949
950 Ok(())
951 }
952
953 #[cfg(feature = "electric")]
954 #[test]
955 fn electric_domain_client_can_be_created() -> Result<(), BpiError> {
956 let client = BpiClient::new()?;
957
958 let _electric = client.electric();
959
960 Ok(())
961 }
962
963 #[cfg(feature = "fav")]
964 #[test]
965 fn fav_domain_client_can_be_created() -> Result<(), BpiError> {
966 let client = BpiClient::new()?;
967
968 let _fav = client.fav();
969
970 Ok(())
971 }
972
973 #[cfg(feature = "historytoview")]
974 #[test]
975 fn historytoview_domain_client_can_be_created() -> Result<(), BpiError> {
976 let client = BpiClient::new()?;
977
978 let _historytoview = client.historytoview();
979
980 Ok(())
981 }
982
983 #[cfg(feature = "manga")]
984 #[test]
985 fn manga_domain_client_can_be_created() -> Result<(), BpiError> {
986 let client = BpiClient::new()?;
987
988 let _manga = client.manga();
989
990 Ok(())
991 }
992
993 #[cfg(feature = "note")]
994 #[test]
995 fn note_domain_client_can_be_created() -> Result<(), BpiError> {
996 let client = BpiClient::new()?;
997
998 let _note = client.note();
999
1000 Ok(())
1001 }
1002
1003 #[cfg(feature = "dynamic")]
1004 #[test]
1005 fn dynamic_domain_client_can_be_created() -> Result<(), BpiError> {
1006 let client = BpiClient::new()?;
1007
1008 let _dynamic = client.dynamic();
1009
1010 Ok(())
1011 }
1012
1013 #[cfg(feature = "danmaku")]
1014 #[test]
1015 fn danmaku_domain_client_can_be_created() -> Result<(), BpiError> {
1016 let client = BpiClient::new()?;
1017
1018 let _danmaku = client.danmaku();
1019
1020 Ok(())
1021 }
1022
1023 #[cfg(feature = "search")]
1024 #[test]
1025 fn search_domain_client_can_be_created() -> Result<(), BpiError> {
1026 let client = BpiClient::new()?;
1027
1028 let _search = client.search();
1029
1030 Ok(())
1031 }
1032
1033 #[cfg(feature = "user")]
1034 #[test]
1035 fn user_domain_client_can_be_created() -> Result<(), BpiError> {
1036 let client = BpiClient::new()?;
1037
1038 let _user = client.user();
1039
1040 Ok(())
1041 }
1042
1043 #[cfg(feature = "video_ranking")]
1044 #[test]
1045 fn video_ranking_domain_client_can_be_created() -> Result<(), BpiError> {
1046 let client = BpiClient::new()?;
1047
1048 let _video_ranking = client.video_ranking();
1049
1050 Ok(())
1051 }
1052
1053 #[cfg(all(feature = "article", feature = "video", feature = "fav"))]
1054 #[test]
1055 fn module_clients_expose_write_capability_futures() -> Result<(), BpiError> {
1056 let client = BpiClient::new()?;
1057
1058 std::mem::drop(
1059 client
1060 .article()
1061 .like(crate::article::ArticleLikeParams::new(1, true)?),
1062 );
1063 std::mem::drop(
1064 client
1065 .video()
1066 .like(crate::video::VideoLikeParams::from_aid(1, 1)?),
1067 );
1068 std::mem::drop(
1069 client
1070 .fav()
1071 .add_folder(crate::fav::FavFolderAddParams::new("title")?.privacy(1)?),
1072 );
1073
1074 Ok(())
1075 }
1076}