1use crate::{BiliClient, Credentials, Error, Result};
2use md5::Digest;
3use reqwest::header::{HeaderMap, SET_COOKIE};
4use serde::{Deserialize, Serialize};
5use std::{
6 fmt,
7 time::{SystemTime, UNIX_EPOCH},
8};
9
10const WEB_QR_WAITING_SCAN: i64 = 86_101;
11const WEB_QR_WAITING_CONFIRM: i64 = 86_090;
12const WEB_QR_EXPIRED: i64 = 86_038;
13const TV_QR_WAITING_SCAN: i64 = 86_039;
14const TV_QR_WAITING_CONFIRM: i64 = 86_090;
15const TV_QR_EXPIRED: i64 = 86_038;
16
17#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum QrLoginKind {
20 Web,
21 Tv,
22}
23
24#[derive(Clone, Eq, PartialEq)]
25pub struct QrLoginTicket {
26 pub kind: QrLoginKind,
27 pub url: String,
28 pub key: String,
29 tv_context: Option<TvLoginContext>,
30}
31
32impl fmt::Debug for QrLoginTicket {
33 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34 formatter
35 .debug_struct("QrLoginTicket")
36 .field("kind", &self.kind)
37 .field("has_url", &!self.url.is_empty())
38 .field("has_key", &!self.key.is_empty())
39 .field("has_tv_context", &self.tv_context.is_some())
40 .finish()
41 }
42}
43
44#[derive(Clone, Debug, Eq, PartialEq)]
45pub enum QrLoginState {
46 WaitingForScan,
47 WaitingForConfirm,
48 Expired,
49 Succeeded { credentials: Credentials },
50}
51
52impl BiliClient {
53 pub async fn create_web_qr_login(&self) -> Result<QrLoginTicket> {
54 let mut url = Self::endpoint_url(
55 &self.config.endpoints.passport_base,
56 "/x/passport-login/web/qrcode/generate",
57 )?;
58 url.query_pairs_mut()
59 .append_pair("source", "main-fe-header");
60 let response = self
61 .http
62 .get(url)
63 .headers(self.anonymous_headers()?)
64 .timeout(self.config.request_timeout)
65 .send()
66 .await
67 .map_err(BiliClient::http_error_without_url)?
68 .error_for_status()
69 .map_err(BiliClient::http_error_without_url)?
70 .json::<ApiData<WebQrGenerateData>>()
71 .await
72 .map_err(BiliClient::http_error_without_url)?;
73 let data = response.into_data()?;
74 let key = data
75 .qrcode_key
76 .or_else(|| qrcode_key_from_url(&data.url))
77 .ok_or(Error::MissingField("qrcode_key"))?;
78 Ok(QrLoginTicket {
79 kind: QrLoginKind::Web,
80 url: data.url,
81 key,
82 tv_context: None,
83 })
84 }
85
86 pub async fn poll_web_qr_login(&self, qrcode_key: &str) -> Result<QrLoginState> {
87 let mut url = Self::endpoint_url(
88 &self.config.endpoints.passport_base,
89 "/x/passport-login/web/qrcode/poll",
90 )?;
91 url.query_pairs_mut()
92 .append_pair("qrcode_key", qrcode_key)
93 .append_pair("source", "main-fe-header");
94 let response = self
95 .http
96 .get(url)
97 .headers(self.anonymous_headers()?)
98 .timeout(self.config.request_timeout)
99 .send()
100 .await
101 .map_err(BiliClient::http_error_without_url)?
102 .error_for_status()
103 .map_err(BiliClient::http_error_without_url)?;
104 let header_cookie = cookie_from_set_cookie_headers(response.headers());
105 let response = response
106 .json::<ApiData<WebQrPollData>>()
107 .await
108 .map_err(BiliClient::http_error_without_url)?;
109 let data = response.into_data()?;
110 match data.code {
111 WEB_QR_WAITING_SCAN => Ok(QrLoginState::WaitingForScan),
112 WEB_QR_WAITING_CONFIRM => Ok(QrLoginState::WaitingForConfirm),
113 WEB_QR_EXPIRED => Ok(QrLoginState::Expired),
114 0 => {
115 let cookie = if let Some(cookie) = header_cookie {
116 cookie
117 } else {
118 let url = data.url.ok_or(Error::MissingField("url"))?;
119 cookie_from_success_url(&url)?
120 };
121 Ok(QrLoginState::Succeeded {
122 credentials: Credentials {
123 cookie: Some(cookie),
124 access_key: None,
125 tv_access_key: None,
126 },
127 })
128 }
129 code => Err(Error::Api {
130 code,
131 message: data.message.unwrap_or_default(),
132 }),
133 }
134 }
135
136 pub async fn create_tv_qr_login(&self) -> Result<QrLoginTicket> {
137 let url = Self::endpoint_url(
138 &self.config.endpoints.tv_passport_base,
139 "/x/passport-tv-login/qrcode/auth_code",
140 )?;
141 let timestamp = current_timestamp_seconds();
142 let context = TvLoginContext::new(timestamp);
143 let params = context.params("", timestamp);
144 let response = self
145 .http
146 .post(url)
147 .headers(self.anonymous_headers()?)
148 .timeout(self.config.request_timeout)
149 .form(¶ms)
150 .send()
151 .await
152 .map_err(BiliClient::http_error_without_url)?
153 .error_for_status()
154 .map_err(BiliClient::http_error_without_url)?
155 .json::<ApiData<TvQrGenerateData>>()
156 .await
157 .map_err(BiliClient::http_error_without_url)?;
158 let data = response.into_data()?;
159 Ok(QrLoginTicket {
160 kind: QrLoginKind::Tv,
161 url: data.url,
162 key: data.auth_code,
163 tv_context: Some(context),
164 })
165 }
166
167 pub async fn poll_tv_qr_login(&self, ticket: &QrLoginTicket) -> Result<QrLoginState> {
168 if ticket.kind != QrLoginKind::Tv {
169 return Err(Error::InvalidInput(
170 "poll_tv_qr_login requires a TV QR login ticket".to_owned(),
171 ));
172 }
173 let context = ticket
174 .tv_context
175 .as_ref()
176 .ok_or(Error::MissingField("tv login context"))?;
177 let url = Self::endpoint_url(
178 &self.config.endpoints.tv_passport_poll_base,
179 "/x/passport-tv-login/qrcode/poll",
180 )?;
181 let params = context.params(&ticket.key, current_timestamp_seconds());
182 let response = self
183 .http
184 .post(url)
185 .headers(self.anonymous_headers()?)
186 .timeout(self.config.request_timeout)
187 .form(¶ms)
188 .send()
189 .await
190 .map_err(BiliClient::http_error_without_url)?
191 .error_for_status()
192 .map_err(BiliClient::http_error_without_url)?
193 .json::<ApiData<TvQrPollData>>()
194 .await
195 .map_err(BiliClient::http_error_without_url)?;
196 match response.code {
197 TV_QR_WAITING_SCAN => Ok(QrLoginState::WaitingForScan),
198 TV_QR_WAITING_CONFIRM => Ok(QrLoginState::WaitingForConfirm),
199 TV_QR_EXPIRED => Ok(QrLoginState::Expired),
200 0 => {
201 let data = response.data.ok_or(Error::MissingField("data"))?;
202 Ok(QrLoginState::Succeeded {
203 credentials: Credentials {
204 cookie: None,
205 access_key: None,
206 tv_access_key: Some(data.access_token),
207 },
208 })
209 }
210 code => Err(Error::Api {
211 code,
212 message: response.message,
213 }),
214 }
215 }
216}
217
218#[derive(Debug, Deserialize)]
219struct ApiData<T> {
220 code: i64,
221 #[serde(default)]
222 message: String,
223 data: Option<T>,
224}
225
226impl<T> ApiData<T> {
227 fn into_data(self) -> Result<T> {
228 if self.code != 0 {
229 return Err(Error::Api {
230 code: self.code,
231 message: self.message,
232 });
233 }
234 self.data.ok_or(Error::MissingField("data"))
235 }
236}
237
238#[derive(Debug, Deserialize)]
239struct WebQrGenerateData {
240 url: String,
241 qrcode_key: Option<String>,
242}
243
244#[derive(Debug, Deserialize)]
245struct WebQrPollData {
246 code: i64,
247 message: Option<String>,
248 url: Option<String>,
249}
250
251#[derive(Debug, Deserialize)]
252struct TvQrGenerateData {
253 url: String,
254 auth_code: String,
255}
256
257#[derive(Debug, Deserialize)]
258struct TvQrPollData {
259 access_token: String,
260}
261
262#[derive(Clone, Eq, PartialEq)]
263struct TvLoginContext {
264 device_id: String,
265 buvid: String,
266 fingerprint: String,
267}
268
269impl TvLoginContext {
270 fn new(timestamp: u64) -> Self {
271 let device_id = device_token("device", timestamp, 20);
272 let buvid = device_token("buvid", timestamp, 37);
273 let fingerprint = format!(
274 "{}{}",
275 timestamp,
276 device_token("fingerprint", timestamp, 45)
277 );
278 Self {
279 device_id,
280 buvid,
281 fingerprint,
282 }
283 }
284
285 fn params(&self, auth_code: &str, timestamp: u64) -> Vec<(&'static str, String)> {
286 let mut params = vec![
287 ("appkey", "4409e2ce8ffd12b8".to_owned()),
288 ("auth_code", auth_code.to_owned()),
289 ("bili_local_id", self.device_id.clone()),
290 ("build", "102801".to_owned()),
291 ("buvid", self.buvid.clone()),
292 ("channel", "master".to_owned()),
293 ("device", "OnePlus".to_owned()),
294 ("device_id", self.device_id.clone()),
295 ("device_name", "OnePlus7TPro".to_owned()),
296 ("device_platform", "Android10OnePlusHD1910".to_owned()),
297 ("fingerprint", self.fingerprint.clone()),
298 ("guid", self.buvid.clone()),
299 ("local_fingerprint", self.fingerprint.clone()),
300 ("local_id", self.buvid.clone()),
301 ("mobi_app", "android_tv_yst".to_owned()),
302 ("networkstate", "wifi".to_owned()),
303 ("platform", "android".to_owned()),
304 ("sys_ver", "29".to_owned()),
305 ("ts", timestamp.to_string()),
306 ];
307 let sign = crate::client::sign_ordered_params(¶ms, "59b43e04ad6965f34319062b478f83dd");
308 params.push(("sign", sign));
309 params
310 }
311}
312
313fn qrcode_key_from_url(raw: &str) -> Option<String> {
314 url::Url::parse(raw)
315 .ok()?
316 .query_pairs()
317 .find_map(|(key, value)| {
318 (key == "qrcode_key" && !value.is_empty()).then(|| value.into_owned())
319 })
320}
321
322fn cookie_from_success_url(raw: &str) -> Result<String> {
323 let url = url::Url::parse(raw)?;
324 let query = url.query().ok_or(Error::MissingField("url query"))?;
325 if query.is_empty() {
326 return Err(Error::MissingField("url query"));
327 }
328 Ok(query.replace('&', ";").replace(',', "%2C"))
329}
330
331fn cookie_from_set_cookie_headers(headers: &HeaderMap) -> Option<String> {
332 let pairs = headers
333 .get_all(SET_COOKIE)
334 .iter()
335 .filter_map(|value| value.to_str().ok())
336 .filter_map(cookie_pair_from_set_cookie)
337 .collect::<Vec<_>>();
338 (!pairs.is_empty()).then(|| pairs.join(";"))
339}
340
341fn cookie_pair_from_set_cookie(raw: &str) -> Option<String> {
342 let pair = raw.split(';').next()?.trim();
343 (!pair.is_empty()).then(|| pair.replace(',', "%2C"))
344}
345
346#[cfg(test)]
347fn tv_login_params(auth_code: &str, timestamp: u64) -> Vec<(&'static str, String)> {
348 TvLoginContext::new(timestamp).params(auth_code, timestamp)
349}
350
351fn device_token(label: &str, timestamp: u64, len: usize) -> String {
352 let digest = md5::Md5::digest(format!("{label}:{timestamp}:bbdown-rust").as_bytes());
353 let mut token = format!("{label}{digest:x}");
354 token.retain(|character| character.is_ascii_alphanumeric());
355 token.truncate(len);
356 while token.len() < len {
357 token.push('0');
358 }
359 token
360}
361
362fn current_timestamp_seconds() -> u64 {
363 SystemTime::now()
364 .duration_since(UNIX_EPOCH)
365 .map_or(0, |duration| duration.as_secs())
366}
367
368#[cfg(test)]
369mod tests {
370 use super::{
371 QrLoginState, QrLoginTicket, TvLoginContext, cookie_from_set_cookie_headers,
372 cookie_from_success_url, qrcode_key_from_url, tv_login_params,
373 };
374 use crate::{BiliClient, ClientConfig, Credentials, EndpointConfig, RestrictedAreaConfig};
375 use httpmock::MockServer;
376 use httpmock::prelude::*;
377 use reqwest::header::{HeaderMap, HeaderValue, SET_COOKIE};
378
379 #[test]
380 fn extracts_qrcode_key_from_login_url() {
381 assert_eq!(
382 qrcode_key_from_url("https://passport.example/scan?qrcode_key=abc123").as_deref(),
383 Some("abc123")
384 );
385 }
386
387 #[test]
388 fn converts_web_success_url_query_to_cookie() -> anyhow::Result<()> {
389 assert_eq!(
390 cookie_from_success_url(
391 "https://www.bilibili.com/?SESSDATA=abc%2Cdef&bili_jct=csrf&DedeUserID=1",
392 )?,
393 "SESSDATA=abc%2Cdef;bili_jct=csrf;DedeUserID=1"
394 );
395 Ok(())
396 }
397
398 #[test]
399 fn converts_set_cookie_headers_to_cookie() {
400 let mut headers = HeaderMap::new();
401 headers.append(
402 SET_COOKIE,
403 HeaderValue::from_static("SESSDATA=abc,def; Path=/; Domain=.bilibili.com"),
404 );
405 headers.append(
406 SET_COOKIE,
407 HeaderValue::from_static("bili_jct=csrf; Path=/; Domain=.bilibili.com"),
408 );
409
410 assert_eq!(
411 cookie_from_set_cookie_headers(&headers).as_deref(),
412 Some("SESSDATA=abc%2Cdef;bili_jct=csrf")
413 );
414 }
415
416 #[test]
417 fn qr_login_ticket_debug_is_redacted() {
418 let ticket = QrLoginTicket {
419 kind: super::QrLoginKind::Web,
420 url: "https://passport.example/scan?qrcode_key=SECRET".to_owned(),
421 key: "SECRET".to_owned(),
422 tv_context: None,
423 };
424 let debug = format!("{ticket:?}");
425
426 assert!(debug.contains("kind: Web"));
427 assert!(debug.contains("has_url: true"));
428 assert!(debug.contains("has_key: true"));
429 assert!(!debug.contains("SECRET"));
430 assert!(!debug.contains("qrcode_key"));
431 }
432
433 #[test]
434 fn signs_stable_tv_login_params_after_auth_code() {
435 let params = tv_login_params("AUTH", 1_700_000_000);
436 assert_eq!(
437 params,
438 vec![
439 ("appkey", "4409e2ce8ffd12b8".to_owned()),
440 ("auth_code", "AUTH".to_owned()),
441 ("bili_local_id", "device068a1f84f3b481".to_owned()),
442 ("build", "102801".to_owned()),
443 ("buvid", "buvid9bb49b85083b8fa445ee2eb127052e63".to_owned()),
444 ("channel", "master".to_owned()),
445 ("device", "OnePlus".to_owned()),
446 ("device_id", "device068a1f84f3b481".to_owned()),
447 ("device_name", "OnePlus7TPro".to_owned()),
448 ("device_platform", "Android10OnePlusHD1910".to_owned()),
449 (
450 "fingerprint",
451 "1700000000fingerprint2fee77e506dae703f7a1197bd676400600".to_owned()
452 ),
453 ("guid", "buvid9bb49b85083b8fa445ee2eb127052e63".to_owned()),
454 (
455 "local_fingerprint",
456 "1700000000fingerprint2fee77e506dae703f7a1197bd676400600".to_owned()
457 ),
458 (
459 "local_id",
460 "buvid9bb49b85083b8fa445ee2eb127052e63".to_owned()
461 ),
462 ("mobi_app", "android_tv_yst".to_owned()),
463 ("networkstate", "wifi".to_owned()),
464 ("platform", "android".to_owned()),
465 ("sys_ver", "29".to_owned()),
466 ("ts", "1700000000".to_owned()),
467 ("sign", "fcaa54c903154ca39a4e046b73469f74".to_owned()),
468 ]
469 );
470 }
471
472 #[test]
473 fn tv_login_context_reuses_device_identity_for_poll() {
474 let context = TvLoginContext::new(1_700_000_000);
475 let create_params = context.params("", 1_700_000_000);
476 let poll_params = context.params("AUTH", 1_700_000_050);
477
478 for key in [
479 "bili_local_id",
480 "buvid",
481 "device_id",
482 "fingerprint",
483 "guid",
484 "local_fingerprint",
485 "local_id",
486 ] {
487 assert_eq!(
488 param_value(&create_params, key),
489 param_value(&poll_params, key)
490 );
491 }
492 assert_eq!(param_value(&poll_params, "auth_code"), Some("AUTH"));
493 assert_eq!(param_value(&create_params, "ts"), Some("1700000000"));
494 assert_eq!(param_value(&poll_params, "ts"), Some("1700000050"));
495 assert_ne!(
496 param_value(&create_params, "sign"),
497 param_value(&poll_params, "sign")
498 );
499 }
500
501 #[tokio::test]
502 async fn polls_web_qr_login_states() -> anyhow::Result<()> {
503 let server = MockServer::start();
504 server.mock(|when, then| {
505 when.method(GET)
506 .path("/x/passport-login/web/qrcode/poll")
507 .query_param("qrcode_key", "WAIT")
508 .header_missing("cookie");
509 then.status(200).json_body_obj(&serde_json::json!({
510 "code": 0,
511 "data": {"code": 86101}
512 }));
513 });
514 server.mock(|when, then| {
515 when.method(GET)
516 .path("/x/passport-login/web/qrcode/poll")
517 .query_param("qrcode_key", "CONFIRM")
518 .header_missing("cookie");
519 then.status(200).json_body_obj(&serde_json::json!({
520 "code": 0,
521 "data": {"code": 86090}
522 }));
523 });
524 server.mock(|when, then| {
525 when.method(GET)
526 .path("/x/passport-login/web/qrcode/poll")
527 .query_param("qrcode_key", "DONE")
528 .header_missing("cookie");
529 then.status(200)
530 .header("Set-Cookie", "SESSDATA=sess; Path=/; Domain=.bilibili.com")
531 .json_body_obj(&serde_json::json!({
532 "code": 0,
533 "data": {
534 "code": 0,
535 "url": "https://passport.biligame.com/crossDomain?source=main_web&go_url=https%3A%2F%2Fpassport.bilibili.com"
536 }
537 }));
538 });
539 server.mock(|when, then| {
540 when.method(GET)
541 .path("/x/passport-login/web/qrcode/poll")
542 .query_param("qrcode_key", "EXPIRED")
543 .header_missing("cookie");
544 then.status(200).json_body_obj(&serde_json::json!({
545 "code": 0,
546 "data": {
547 "code": 86038,
548 "message": "expired"
549 }
550 }));
551 });
552 let client = test_client(&server);
553
554 assert_eq!(
555 client.poll_web_qr_login("WAIT").await?,
556 QrLoginState::WaitingForScan
557 );
558 assert_eq!(
559 client.poll_web_qr_login("CONFIRM").await?,
560 QrLoginState::WaitingForConfirm
561 );
562 assert_eq!(
563 client.poll_web_qr_login("DONE").await?,
564 QrLoginState::Succeeded {
565 credentials: Credentials {
566 cookie: Some("SESSDATA=sess".to_owned()),
567 access_key: None,
568 tv_access_key: None,
569 }
570 }
571 );
572 assert_eq!(
573 client.poll_web_qr_login("EXPIRED").await?,
574 QrLoginState::Expired
575 );
576 Ok(())
577 }
578
579 #[tokio::test]
580 async fn creates_and_polls_tv_qr_login() -> anyhow::Result<()> {
581 let server = MockServer::start();
582 server.mock(|when, then| {
583 when.method(POST)
584 .path("/x/passport-tv-login/qrcode/auth_code")
585 .header_missing("cookie")
586 .form_urlencoded_tuple("appkey", "4409e2ce8ffd12b8")
587 .form_urlencoded_tuple("auth_code", "")
588 .form_urlencoded_tuple("mobi_app", "android_tv_yst")
589 .form_urlencoded_tuple_exists("sign");
590 then.status(200).json_body_obj(&serde_json::json!({
591 "code": 0,
592 "data": {"url": "https://tv.example/scan", "auth_code": "AUTH"}
593 }));
594 });
595 server.mock(|when, then| {
596 when.method(POST)
597 .path("/x/passport-tv-login/qrcode/poll")
598 .header_missing("cookie")
599 .form_urlencoded_tuple("auth_code", "WAIT")
600 .form_urlencoded_tuple_exists("sign");
601 then.status(200).json_body_obj(&serde_json::json!({
602 "code": 86039,
603 "message": "waiting scan"
604 }));
605 });
606 server.mock(|when, then| {
607 when.method(POST)
608 .path("/x/passport-tv-login/qrcode/poll")
609 .header_missing("cookie")
610 .form_urlencoded_tuple("auth_code", "CONFIRM")
611 .form_urlencoded_tuple_exists("sign");
612 then.status(200).json_body_obj(&serde_json::json!({
613 "code": 86090,
614 "message": "waiting confirm"
615 }));
616 });
617 server.mock(|when, then| {
618 when.method(POST)
619 .path("/x/passport-tv-login/qrcode/poll")
620 .header_missing("cookie")
621 .form_urlencoded_tuple("auth_code", "EXPIRED")
622 .form_urlencoded_tuple_exists("sign");
623 then.status(200).json_body_obj(&serde_json::json!({
624 "code": 86038,
625 "message": "expired"
626 }));
627 });
628 server.mock(|when, then| {
629 when.method(POST)
630 .path("/x/passport-tv-login/qrcode/poll")
631 .header_missing("cookie")
632 .form_urlencoded_tuple("auth_code", "AUTH")
633 .form_urlencoded_tuple_exists("sign");
634 then.status(200).json_body_obj(&serde_json::json!({
635 "code": 0,
636 "data": {"access_token": "ACCESS"}
637 }));
638 });
639 let client = test_client(&server);
640 let ticket = client.create_tv_qr_login().await?;
641
642 assert_eq!(ticket.key, "AUTH");
643 let mut wait_ticket = ticket.clone();
644 wait_ticket.key = "WAIT".to_owned();
645 assert_eq!(
646 client.poll_tv_qr_login(&wait_ticket).await?,
647 QrLoginState::WaitingForScan
648 );
649 let mut confirm_ticket = ticket.clone();
650 confirm_ticket.key = "CONFIRM".to_owned();
651 assert_eq!(
652 client.poll_tv_qr_login(&confirm_ticket).await?,
653 QrLoginState::WaitingForConfirm
654 );
655 let mut expired_ticket = ticket.clone();
656 expired_ticket.key = "EXPIRED".to_owned();
657 assert_eq!(
658 client.poll_tv_qr_login(&expired_ticket).await?,
659 QrLoginState::Expired
660 );
661 assert_eq!(
662 client.poll_tv_qr_login(&ticket).await?,
663 QrLoginState::Succeeded {
664 credentials: Credentials {
665 cookie: None,
666 access_key: None,
667 tv_access_key: Some("ACCESS".to_owned()),
668 }
669 }
670 );
671 Ok(())
672 }
673
674 fn test_client(server: &MockServer) -> BiliClient {
675 BiliClient::new(ClientConfig {
676 endpoints: EndpointConfig {
677 api_base: server.base_url(),
678 pgc_base: server.base_url(),
679 intl_base: server.base_url(),
680 comment_base: server.base_url(),
681 passport_base: server.base_url(),
682 tv_passport_base: server.base_url(),
683 tv_passport_poll_base: server.base_url(),
684 },
685 credentials: Credentials {
686 cookie: Some("SESSDATA=old".to_owned()),
687 access_key: None,
688 tv_access_key: None,
689 },
690 restricted_area: RestrictedAreaConfig::default(),
691 user_agent: "test".to_owned(),
692 request_timeout: std::time::Duration::from_secs(30),
693 })
694 }
695
696 fn param_value<'a>(params: &'a [(&str, String)], key: &str) -> Option<&'a str> {
697 params
698 .iter()
699 .find_map(|(candidate, value)| (*candidate == key).then_some(value.as_str()))
700 }
701}