Skip to main content

bpi_rs/
client.rs

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/// Configures a [`BpiClient`] before construction.
82#[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    /// Sets the total request timeout.
115    pub fn timeout(mut self, timeout: Duration) -> Self {
116        self.timeout = timeout;
117        self
118    }
119
120    /// Sets the TCP connect timeout.
121    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
122        self.connect_timeout = timeout;
123        self
124    }
125
126    /// Sets the default user-agent applied by [`BpiClient::get`] and [`BpiClient::post`].
127    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
128        self.user_agent = user_agent.into();
129        self
130    }
131
132    /// Sets the default referer header applied by [`BpiClient::get`] and [`BpiClient::post`].
133    pub fn referer(mut self, referer: impl Into<String>) -> Self {
134        self.referer = referer.into();
135        self
136    }
137
138    /// Sets the default origin header applied by [`BpiClient::get`] and [`BpiClient::post`].
139    pub fn origin(mut self, origin: impl Into<String>) -> Self {
140        self.origin = origin.into();
141        self
142    }
143
144    /// Controls whether reqwest should bypass system proxies.
145    pub fn no_proxy(mut self, enabled: bool) -> Self {
146        self.no_proxy = enabled;
147        self
148    }
149
150    /// Adds an explicit proxy to the reqwest client builder.
151    pub fn proxy(mut self, proxy: reqwest::Proxy) -> Self {
152        self.proxies.push(proxy);
153        self
154    }
155
156    /// Seeds the client session from a raw Cookie header string.
157    pub fn cookie(mut self, cookie: impl Into<String>) -> Self {
158        self.cookie = Some(cookie.into());
159        self
160    }
161
162    /// Seeds the client session from structured account values.
163    pub fn account(mut self, account: Account) -> Self {
164        self.account = Some(account);
165        self
166    }
167
168    /// Uses an externally configured reqwest client.
169    pub fn reqwest_client(mut self, client: Client) -> Self {
170        self.reqwest_client = Some(client);
171        self
172    }
173
174    /// Builds a client without reading files, initializing global logging, or using shared state.
175    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
236/// Bilibili API client.
237pub 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    /// Creates an owned client with default configuration.
250    pub fn new() -> Result<Self, BpiError> {
251        Self::builder().build()
252    }
253
254    /// Starts configuring a client.
255    pub fn builder() -> BpiClientBuilder {
256        BpiClientBuilder::default()
257    }
258
259    /// Sets account information and updates this client's cookie state.
260    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    /// Clears account information from this client.
275    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    /// Sets account information from a raw Cookie header string.
286    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    /// Checks whether this client has login cookies.
301    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    /// Returns the current account information.
320    pub fn get_account(&self) -> Option<Account> {
321        self.account.lock().expect("account mutex poisoned").clone()
322    }
323
324    /// Gets the current CSRF token from account information.
325    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    /// Creates a GET request with this client's default Bilibili headers.
333    pub fn get(&self, url: &str) -> RequestBuilder {
334        self.apply_default_headers(url, self.client.get(url))
335    }
336
337    /// Creates a GET request that preserves raw compressed response bytes.
338    #[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    /// Creates a POST request with this client's default Bilibili headers.
356    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    /// Creates an activity domain client.
413    #[cfg(feature = "activity")]
414    pub fn activity(&self) -> ActivityClient<'_> {
415        ActivityClient::new(self)
416    }
417
418    /// Creates an article domain client.
419    #[cfg(feature = "article")]
420    pub fn article(&self) -> ArticleClient<'_> {
421        ArticleClient::new(self)
422    }
423
424    /// Creates an audio domain client.
425    #[cfg(feature = "audio")]
426    pub fn audio(&self) -> AudioClient<'_> {
427        AudioClient::new(self)
428    }
429
430    /// Creates a bangumi domain client.
431    #[cfg(feature = "bangumi")]
432    pub fn bangumi(&self) -> BangumiClient<'_> {
433        BangumiClient::new(self)
434    }
435
436    /// Creates a cheese course domain client.
437    #[cfg(feature = "cheese")]
438    pub fn cheese(&self) -> CheeseClient<'_> {
439        CheeseClient::new(self)
440    }
441
442    /// Creates a client info domain client.
443    #[cfg(feature = "clientinfo")]
444    pub fn clientinfo(&self) -> ClientInfoClient<'_> {
445        ClientInfoClient::new(self)
446    }
447
448    /// Creates a comment domain client.
449    #[cfg(feature = "comment")]
450    pub fn comment(&self) -> CommentClient<'_> {
451        CommentClient::new(self)
452    }
453
454    /// Creates a creative center domain client.
455    #[cfg(feature = "creativecenter")]
456    pub fn creativecenter(&self) -> CreativeCenterClient<'_> {
457        CreativeCenterClient::new(self)
458    }
459
460    /// Creates a danmaku domain client.
461    #[cfg(feature = "danmaku")]
462    pub fn danmaku(&self) -> DanmakuClient<'_> {
463        DanmakuClient::new(self)
464    }
465
466    /// Creates a dynamic domain client.
467    #[cfg(feature = "dynamic")]
468    pub fn dynamic(&self) -> DynamicClient<'_> {
469        DynamicClient::new(self)
470    }
471
472    /// Creates an electric charging domain client.
473    #[cfg(feature = "electric")]
474    pub fn electric(&self) -> ElectricClient<'_> {
475        ElectricClient::new(self)
476    }
477
478    /// Creates a favorite domain client.
479    #[cfg(feature = "fav")]
480    pub fn fav(&self) -> FavClient<'_> {
481        FavClient::new(self)
482    }
483
484    /// Creates a history and to-view domain client.
485    #[cfg(feature = "historytoview")]
486    pub fn historytoview(&self) -> HistoryToViewClient<'_> {
487        HistoryToViewClient::new(self)
488    }
489
490    /// Creates a login domain client.
491    #[cfg(feature = "login")]
492    pub fn login(&self) -> LoginClient<'_> {
493        LoginClient::new(self)
494    }
495
496    /// Creates a live domain client.
497    #[cfg(feature = "live")]
498    pub fn live(&self) -> LiveClient<'_> {
499        LiveClient::new(self)
500    }
501
502    /// Creates a manga domain client.
503    #[cfg(feature = "manga")]
504    pub fn manga(&self) -> MangaClient<'_> {
505        MangaClient::new(self)
506    }
507
508    /// Creates a misc domain client.
509    #[cfg(feature = "misc")]
510    pub fn misc(&self) -> MiscClient<'_> {
511        MiscClient::new(self)
512    }
513
514    /// Creates a message domain client.
515    #[cfg(feature = "message")]
516    pub fn message(&self) -> MessageClient<'_> {
517        MessageClient::new(self)
518    }
519
520    /// Creates a note domain client.
521    #[cfg(feature = "note")]
522    pub fn note(&self) -> NoteClient<'_> {
523        NoteClient::new(self)
524    }
525
526    /// Creates an opus domain client.
527    #[cfg(feature = "opus")]
528    pub fn opus(&self) -> OpusClient<'_> {
529        OpusClient::new(self)
530    }
531
532    /// Creates a search domain client.
533    #[cfg(feature = "search")]
534    pub fn search(&self) -> SearchClient<'_> {
535        SearchClient::new(self)
536    }
537
538    /// Creates a video domain client.
539    #[cfg(feature = "video")]
540    pub fn video(&self) -> VideoClient<'_> {
541        VideoClient::new(self)
542    }
543
544    /// Creates a video ranking domain client.
545    #[cfg(feature = "video_ranking")]
546    pub fn video_ranking(&self) -> VideoRankingClient<'_> {
547        VideoRankingClient::new(self)
548    }
549
550    /// Creates a VIP domain client.
551    #[cfg(feature = "vip")]
552    pub fn vip(&self) -> VipClient<'_> {
553        VipClient::new(self)
554    }
555
556    /// Creates a wallet domain client.
557    #[cfg(feature = "wallet")]
558    pub fn wallet(&self) -> WalletClient<'_> {
559        WalletClient::new(self)
560    }
561
562    /// Creates a user domain client.
563    #[cfg(feature = "user")]
564    pub fn user(&self) -> UserClient<'_> {
565        UserClient::new(self)
566    }
567
568    /// Creates a web widget domain client.
569    #[cfg(feature = "web_widget")]
570    pub fn web_widget(&self) -> WebWidgetClient<'_> {
571        WebWidgetClient::new(self)
572    }
573
574    /// Creates a client from structured account configuration.
575    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}