1use crate::models::{
2 DanmakuTrack, DownloadEntry, DownloadPlan, EpisodeMetadata, FlvSegment, MediaStream, Owner,
3 PageMetadata, ResolvedContent, SeasonMetadata, SeasonResolution, StreamDiagnostics,
4 StreamQuality, StreamResolverAttempt, StreamResolverOutcome, StreamSet, StreamSource,
5 SubtitleFormat, SubtitleTrack, Tag, VideoMetadata,
6};
7use crate::{Credentials, Error, Input, Result, Selection};
8use md5::{Digest, Md5};
9use reqwest::header::{COOKIE, HeaderMap, HeaderValue, REFERER, USER_AGENT};
10use serde::Deserialize;
11use std::fmt;
12use std::time::{Duration, SystemTime, UNIX_EPOCH};
13use url::Url;
14
15#[non_exhaustive]
16#[derive(Clone, Debug)]
17pub struct EndpointConfig {
18 pub api_base: String,
19 pub pgc_base: String,
20 pub intl_base: String,
21 pub comment_base: String,
22 pub passport_base: String,
23 pub tv_passport_base: String,
24 pub tv_passport_poll_base: String,
25}
26
27impl Default for EndpointConfig {
28 fn default() -> Self {
29 Self {
30 api_base: "https://api.bilibili.com".to_owned(),
31 pgc_base: "https://api.bilibili.com".to_owned(),
32 intl_base: "https://api.bilibili.tv".to_owned(),
33 comment_base: "https://comment.bilibili.com".to_owned(),
34 passport_base: "https://passport.bilibili.com".to_owned(),
35 tv_passport_base: "https://passport.snm0516.aisee.tv".to_owned(),
36 tv_passport_poll_base: "https://passport.bilibili.com".to_owned(),
37 }
38 }
39}
40
41impl EndpointConfig {
42 #[must_use]
43 pub fn with_api_base(mut self, api_base: impl Into<String>) -> Self {
44 self.api_base = api_base.into();
45 self
46 }
47
48 #[must_use]
49 pub fn with_pgc_base(mut self, pgc_base: impl Into<String>) -> Self {
50 self.pgc_base = pgc_base.into();
51 self
52 }
53
54 #[must_use]
55 pub fn with_intl_base(mut self, intl_base: impl Into<String>) -> Self {
56 self.intl_base = intl_base.into();
57 self
58 }
59
60 #[must_use]
61 pub fn with_comment_base(mut self, comment_base: impl Into<String>) -> Self {
62 self.comment_base = comment_base.into();
63 self
64 }
65
66 #[must_use]
67 pub fn with_passport_base(mut self, passport_base: impl Into<String>) -> Self {
68 self.passport_base = passport_base.into();
69 self
70 }
71
72 #[must_use]
73 pub fn with_tv_passport_base(mut self, tv_passport_base: impl Into<String>) -> Self {
74 self.tv_passport_base = tv_passport_base.into();
75 self
76 }
77
78 #[must_use]
79 pub fn with_tv_passport_poll_base(mut self, tv_passport_poll_base: impl Into<String>) -> Self {
80 self.tv_passport_poll_base = tv_passport_poll_base.into();
81 self
82 }
83}
84
85#[non_exhaustive]
86#[derive(Clone, Debug)]
87pub struct ClientConfig {
88 pub endpoints: EndpointConfig,
89 pub credentials: Credentials,
90 pub restricted_area: RestrictedAreaConfig,
91 pub user_agent: String,
92 pub request_timeout: Duration,
93}
94
95impl Default for ClientConfig {
96 fn default() -> Self {
97 Self {
98 endpoints: EndpointConfig::default(),
99 credentials: Credentials::default(),
100 restricted_area: RestrictedAreaConfig::default(),
101 user_agent: "bbdown-rs/0.1".to_owned(),
102 request_timeout: Duration::from_secs(30),
103 }
104 }
105}
106
107impl ClientConfig {
108 #[must_use]
109 pub fn new(endpoints: EndpointConfig, credentials: Credentials) -> Self {
110 Self {
111 endpoints,
112 credentials,
113 ..Self::default()
114 }
115 }
116
117 #[must_use]
118 pub fn with_endpoints(mut self, endpoints: EndpointConfig) -> Self {
119 self.endpoints = endpoints;
120 self
121 }
122
123 #[must_use]
124 pub fn with_credentials(mut self, credentials: Credentials) -> Self {
125 self.credentials = credentials;
126 self
127 }
128
129 #[must_use]
130 pub fn with_restricted_area(mut self, restricted_area: RestrictedAreaConfig) -> Self {
131 self.restricted_area = restricted_area;
132 self
133 }
134
135 #[must_use]
136 pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
137 self.user_agent = user_agent.into();
138 self
139 }
140
141 #[must_use]
142 pub fn with_request_timeout(mut self, request_timeout: Duration) -> Self {
143 self.request_timeout = request_timeout;
144 self
145 }
146}
147
148#[non_exhaustive]
149#[derive(Clone, Debug, Default, Eq, PartialEq)]
150pub struct RestrictedAreaConfig {
151 pub area_hint: Option<RestrictedArea>,
152 pub proxies: Vec<RestrictedAreaProxy>,
153}
154
155impl RestrictedAreaConfig {
156 #[must_use]
157 pub fn new(
158 area_hint: Option<RestrictedArea>,
159 proxies: impl IntoIterator<Item = RestrictedAreaProxy>,
160 ) -> Self {
161 Self {
162 area_hint,
163 proxies: proxies.into_iter().collect(),
164 }
165 }
166
167 #[must_use]
168 pub fn with_area_hint(mut self, area_hint: RestrictedArea) -> Self {
169 self.area_hint = Some(area_hint);
170 self
171 }
172
173 #[must_use]
174 pub fn with_proxy(mut self, proxy: RestrictedAreaProxy) -> Self {
175 self.proxies.push(proxy);
176 self
177 }
178
179 #[must_use]
180 pub fn with_proxies(mut self, proxies: impl IntoIterator<Item = RestrictedAreaProxy>) -> Self {
181 self.proxies.extend(proxies);
182 self
183 }
184
185 #[must_use]
186 pub fn ordered_proxies(&self) -> Vec<RestrictedAreaProxy> {
187 let mut ordered = Vec::new();
188 let mut priorities = self
189 .proxies
190 .iter()
191 .map(|proxy| proxy.order_priority)
192 .collect::<Vec<_>>();
193 priorities.sort_unstable();
194 priorities.dedup();
195 for priority in priorities {
196 if let Some(area_hint) = self.area_hint {
197 self.push_matching(&mut ordered, |proxy| {
198 proxy.order_priority == priority && proxy.area == Some(area_hint)
199 });
200 }
201 self.push_matching(&mut ordered, |proxy| {
202 proxy.order_priority == priority && proxy.area.is_none()
203 });
204 for area in [
205 RestrictedArea::Cn,
206 RestrictedArea::Th,
207 RestrictedArea::Hk,
208 RestrictedArea::Tw,
209 ] {
210 self.push_matching(&mut ordered, |proxy| {
211 proxy.order_priority == priority && proxy.area == Some(area)
212 });
213 }
214 self.push_matching(&mut ordered, |proxy| proxy.order_priority == priority);
215 }
216 ordered
217 }
218
219 fn push_matching(
220 &self,
221 ordered: &mut Vec<RestrictedAreaProxy>,
222 mut predicate: impl FnMut(&RestrictedAreaProxy) -> bool,
223 ) {
224 for proxy in self.proxies.iter().filter(|proxy| predicate(proxy)) {
225 if !ordered.iter().any(|candidate| candidate == proxy) {
226 ordered.push(proxy.clone());
227 }
228 }
229 }
230}
231
232#[derive(Clone, Copy, Debug, Eq, PartialEq)]
233pub enum RestrictedArea {
234 Cn,
235 Th,
236 Hk,
237 Tw,
238}
239
240impl RestrictedArea {
241 #[must_use]
242 pub fn as_str(self) -> &'static str {
243 match self {
244 Self::Cn => "cn",
245 Self::Th => "th",
246 Self::Hk => "hk",
247 Self::Tw => "tw",
248 }
249 }
250}
251
252#[derive(Clone, Copy, Debug, Eq, PartialEq)]
253pub enum RestrictedAreaProxyKind {
254 PlayUrl,
255 BilibiliApi,
256}
257
258#[derive(Clone, Eq)]
259pub struct RestrictedAreaProxy {
260 pub base_url: String,
261 pub area: Option<RestrictedArea>,
262 pub kind: RestrictedAreaProxyKind,
263 order_priority: u8,
264}
265
266impl PartialEq for RestrictedAreaProxy {
267 fn eq(&self, other: &Self) -> bool {
268 self.base_url == other.base_url && self.area == other.area && self.kind == other.kind
269 }
270}
271
272impl fmt::Debug for RestrictedAreaProxy {
273 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
274 formatter
275 .debug_struct("RestrictedAreaProxy")
276 .field("base_url", &redact_url_string(&self.base_url))
277 .field("area", &self.area.map(RestrictedArea::as_str))
278 .field("kind", &self.kind)
279 .field("order_priority", &self.order_priority)
280 .finish()
281 }
282}
283
284impl RestrictedAreaProxy {
285 #[must_use]
286 pub fn playurl(base_url: impl Into<String>, area: Option<RestrictedArea>) -> Self {
287 Self {
288 base_url: base_url.into(),
289 area,
290 kind: RestrictedAreaProxyKind::PlayUrl,
291 order_priority: 0,
292 }
293 }
294
295 #[must_use]
296 pub fn bilibili_api(base_url: impl Into<String>, area: Option<RestrictedArea>) -> Self {
297 Self {
298 base_url: base_url.into(),
299 area,
300 kind: RestrictedAreaProxyKind::BilibiliApi,
301 order_priority: 0,
302 }
303 }
304
305 #[doc(hidden)]
306 #[must_use]
307 pub fn with_order_priority(mut self, order_priority: u8) -> Self {
308 self.order_priority = order_priority;
309 self
310 }
311}
312
313#[derive(Clone, Debug)]
314pub struct BiliClient {
315 pub(crate) http: reqwest::Client,
316 pub(crate) config: ClientConfig,
317}
318
319impl BiliClient {
320 #[must_use]
321 pub fn new(config: ClientConfig) -> Self {
322 Self {
323 http: reqwest::Client::new(),
324 config,
325 }
326 }
327
328 pub async fn resolve_input(
329 &self,
330 raw: &str,
331 selection: Option<Selection>,
332 ) -> Result<ResolvedContent> {
333 let input = Input::parse(raw)?;
334 self.resolve(input, selection).await
335 }
336
337 pub async fn resolve(
338 &self,
339 input: Input,
340 selection: Option<Selection>,
341 ) -> Result<ResolvedContent> {
342 match input {
343 Input::Aid(aid) => self
344 .fetch_video_by_aid(aid, TagPolicy::Fetch)
345 .await
346 .map(ResolvedContent::Video),
347 Input::Bvid(bvid) => self
348 .fetch_video_by_bvid(&bvid, TagPolicy::Fetch)
349 .await
350 .map(ResolvedContent::Video),
351 Input::Episode(epid) => self
352 .fetch_season_by_ep(epid, selection.or(Some(Selection::Current)))
353 .await
354 .map(ResolvedContent::Season),
355 Input::Season(season_id) => {
356 let selection = selection.ok_or(Error::SelectionRequired {
357 input_kind: "season",
358 })?;
359 self.fetch_season_by_season_id(season_id, selection)
360 .await
361 .map(ResolvedContent::Season)
362 }
363 Input::Media(media_id) => {
364 let selection = selection.ok_or(Error::SelectionRequired {
365 input_kind: "media",
366 })?;
367 self.fetch_season_by_media_id(media_id, selection)
368 .await
369 .map(ResolvedContent::Season)
370 }
371 Input::IntlEpisode(epid) => self
372 .fetch_intl_season_by_ep(epid, selection.or(Some(Selection::Current)))
373 .await
374 .map(ResolvedContent::Season),
375 }
376 }
377
378 pub async fn plan_download(
379 &self,
380 raw: &str,
381 selection: Option<Selection>,
382 ) -> Result<DownloadPlan> {
383 let input = Input::parse(raw)?;
384 self.plan(input, selection).await
385 }
386
387 pub async fn plan(&self, input: Input, selection: Option<Selection>) -> Result<DownloadPlan> {
388 match input {
389 Input::Aid(aid) => {
390 let video = self.fetch_video_by_aid(aid, TagPolicy::Skip).await?;
391 self.plan_video(video, selection).await
392 }
393 Input::Bvid(bvid) => {
394 let video = self.fetch_video_by_bvid(&bvid, TagPolicy::Skip).await?;
395 self.plan_video(video, selection).await
396 }
397 Input::Episode(epid) => {
398 let season = self
399 .fetch_season_by_ep(epid, selection.or(Some(Selection::Current)))
400 .await?;
401 self.plan_season(season, StreamSource::PgcWeb).await
402 }
403 Input::Season(season_id) => {
404 let selection = selection.ok_or(Error::SelectionRequired {
405 input_kind: "season",
406 })?;
407 let season = self.fetch_season_by_season_id(season_id, selection).await?;
408 self.plan_season(season, StreamSource::PgcWeb).await
409 }
410 Input::Media(media_id) => {
411 let selection = selection.ok_or(Error::SelectionRequired {
412 input_kind: "media",
413 })?;
414 let season = self.fetch_season_by_media_id(media_id, selection).await?;
415 self.plan_season(season, StreamSource::PgcWeb).await
416 }
417 Input::IntlEpisode(epid) => {
418 let season = self
419 .fetch_intl_season_by_ep(epid, selection.or(Some(Selection::Current)))
420 .await?;
421 self.plan_season(season, StreamSource::IntlWeb).await
422 }
423 }
424 }
425
426 async fn fetch_video_by_aid(&self, aid: u64, tag_policy: TagPolicy) -> Result<VideoMetadata> {
427 let mut url = Self::endpoint_url(&self.config.endpoints.api_base, "/x/web-interface/view")?;
428 url.query_pairs_mut().append_pair("aid", &aid.to_string());
429 self.fetch_video(url, tag_policy).await
430 }
431
432 async fn fetch_video_by_bvid(
433 &self,
434 bvid: &str,
435 tag_policy: TagPolicy,
436 ) -> Result<VideoMetadata> {
437 let mut url = Self::endpoint_url(&self.config.endpoints.api_base, "/x/web-interface/view")?;
438 url.query_pairs_mut().append_pair("bvid", bvid);
439 self.fetch_video(url, tag_policy).await
440 }
441
442 async fn fetch_video(&self, url: Url, tag_policy: TagPolicy) -> Result<VideoMetadata> {
443 let response: ApiData<ViewData> = self.get_json(url).await?;
444 let data = response.into_data()?;
445 let aid = data.aid.ok_or(Error::MissingField("data.aid"))?;
446 let tags = match tag_policy {
447 TagPolicy::Fetch => self.fetch_tags(aid).await?,
448 TagPolicy::Skip => Vec::new(),
449 };
450 let pages = data
451 .pages
452 .into_iter()
453 .map(|page| PageMetadata {
454 index: page.page,
455 aid,
456 cid: page.cid,
457 epid: None,
458 title: page.part.unwrap_or_else(|| data.title.clone()),
459 duration_seconds: page.duration,
460 })
461 .collect();
462
463 Ok(VideoMetadata {
464 aid,
465 bvid: data.bvid,
466 title: data.title,
467 description: data.desc.unwrap_or_default(),
468 cover_url: data.pic,
469 pub_time: data.pubdate,
470 owner: data.owner.map(|owner| Owner {
471 mid: owner.mid,
472 name: owner.name,
473 }),
474 tags,
475 pages,
476 })
477 }
478
479 async fn fetch_tags(&self, aid: u64) -> Result<Vec<Tag>> {
480 let mut url = Self::endpoint_url(&self.config.endpoints.api_base, "/x/tag/archive/tags")?;
481 url.query_pairs_mut().append_pair("aid", &aid.to_string());
482 let response: ApiData<Vec<TagData>> = self.get_json(url).await?;
483 Ok(response
484 .into_data()?
485 .into_iter()
486 .filter_map(|tag| {
487 tag.tag_id
488 .zip(tag.tag_name)
489 .map(|(id, name)| Tag { id, name })
490 })
491 .collect())
492 }
493
494 async fn fetch_season_by_ep(
495 &self,
496 epid: u64,
497 selection: Option<Selection>,
498 ) -> Result<SeasonResolution> {
499 let mut url = Self::endpoint_url(&self.config.endpoints.pgc_base, "/pgc/view/web/season")?;
500 url.query_pairs_mut()
501 .append_pair("ep_id", &epid.to_string());
502 let season = self.fetch_pgc_season(url).await?;
503 let selection = selection.unwrap_or(Selection::Current);
504 Self::resolve_season_selection(season, Some(&selection), Some(epid), "episode")
505 }
506
507 async fn fetch_season_by_season_id(
508 &self,
509 season_id: u64,
510 selection: Selection,
511 ) -> Result<SeasonResolution> {
512 let mut url = Self::endpoint_url(&self.config.endpoints.pgc_base, "/pgc/view/web/season")?;
513 url.query_pairs_mut()
514 .append_pair("season_id", &season_id.to_string());
515 let season = self.fetch_pgc_season(url).await?;
516 Self::resolve_season_selection(season, Some(&selection), None, "season")
517 }
518
519 async fn fetch_season_by_media_id(
520 &self,
521 media_id: u64,
522 selection: Selection,
523 ) -> Result<SeasonResolution> {
524 let mut review_url =
525 Self::endpoint_url(&self.config.endpoints.pgc_base, "/pgc/review/user")?;
526 review_url
527 .query_pairs_mut()
528 .append_pair("media_id", &media_id.to_string());
529 let review: ApiResult<PgcReviewResult> = self.get_json(review_url).await?;
530 let epid = review
531 .into_result()?
532 .media
533 .and_then(|media| media.new_ep)
534 .map(|episode| episode.id)
535 .ok_or(Error::MissingField("result.media.new_ep.id"))?;
536 let mut resolution = self.fetch_season_by_ep(epid, Some(selection)).await?;
537 resolution.season.media_id = Some(media_id);
538 Ok(resolution)
539 }
540
541 async fn fetch_intl_season_by_ep(
542 &self,
543 epid: u64,
544 selection: Option<Selection>,
545 ) -> Result<SeasonResolution> {
546 let mut url = Self::endpoint_url(
547 &self.config.endpoints.intl_base,
548 "/intl/gateway/v2/ogv/view/app/season",
549 )?;
550 {
551 let mut query = url.query_pairs_mut();
552 query
553 .append_pair("ep_id", &epid.to_string())
554 .append_pair("platform", "android")
555 .append_pair("s_locale", "zh_SG")
556 .append_pair("mobi_app", "bstar_a");
557 if let Some(access_key) = self.config.credentials.access_key.as_deref() {
558 query.append_pair("access_key", access_key);
559 }
560 }
561 let response: IntlSeasonRoot = self.get_json(url).await?;
562 let result = response.into_result()?;
563 if let Some(message) = result.access_limit_message() {
564 return Err(Error::AccessRestricted(message));
565 }
566 let season = season_from_intl(result, Some(epid));
567 let selection = selection.unwrap_or(Selection::Current);
568 Self::resolve_season_selection(season, Some(&selection), Some(epid), "intl episode")
569 }
570
571 async fn fetch_pgc_season(&self, url: Url) -> Result<SeasonMetadata> {
572 let response: ApiResult<PgcSeasonResult> = self.get_json(url).await?;
573 Ok(season_from_pgc(response.into_result()?))
574 }
575
576 fn resolve_season_selection(
577 season: SeasonMetadata,
578 selection: Option<&Selection>,
579 current_epid: Option<u64>,
580 input_kind: &'static str,
581 ) -> Result<SeasonResolution> {
582 let selected_episodes = match selection {
583 Some(Selection::All) => season.episodes.clone(),
584 Some(Selection::Latest) => season.episodes[..season.main_episode_count]
585 .last()
586 .cloned()
587 .into_iter()
588 .collect(),
589 Some(Selection::Episode(epid)) => season
590 .episodes
591 .iter()
592 .find(|episode| episode.epid == *epid)
593 .cloned()
594 .into_iter()
595 .collect(),
596 Some(Selection::Page(page)) => season
597 .episodes
598 .iter()
599 .find(|episode| episode.index == *page)
600 .cloned()
601 .into_iter()
602 .collect(),
603 Some(Selection::Current) | None => {
604 let epid = current_epid.ok_or(Error::SelectionRequired { input_kind })?;
605 season
606 .episodes
607 .iter()
608 .find(|episode| episode.epid == epid)
609 .cloned()
610 .into_iter()
611 .collect()
612 }
613 };
614
615 if selected_episodes.is_empty() {
616 return Err(Error::MissingField("selected episode"));
617 }
618
619 Ok(SeasonResolution {
620 season,
621 selected_episodes,
622 })
623 }
624
625 async fn plan_video(
626 &self,
627 video: VideoMetadata,
628 selection: Option<Selection>,
629 ) -> Result<DownloadPlan> {
630 let pages = Self::select_video_pages(&video, selection.as_ref())?;
631 let mut entries = Vec::new();
632 for page in pages {
633 entries.push(
634 self.plan_entry(PlanEntrySeed {
635 index: page.index,
636 aid: page.aid,
637 bvid: video.bvid.clone(),
638 cid: page.cid,
639 epid: None,
640 title: page.title,
641 source: StreamSource::NormalWeb,
642 })
643 .await?,
644 );
645 }
646 Ok(DownloadPlan {
647 title: video.title,
648 entries,
649 })
650 }
651
652 async fn plan_season(
653 &self,
654 season: SeasonResolution,
655 source: StreamSource,
656 ) -> Result<DownloadPlan> {
657 let mut entries = Vec::new();
658 for episode in season.selected_episodes {
659 entries.push(
660 self.plan_entry(PlanEntrySeed {
661 index: episode.index,
662 aid: episode.aid,
663 bvid: episode.bvid,
664 cid: episode.cid,
665 epid: Some(episode.epid),
666 title: episode_display_title(&episode.title, episode.long_title.as_deref()),
667 source: source.clone(),
668 })
669 .await?,
670 );
671 }
672 Ok(DownloadPlan {
673 title: season.season.title,
674 entries,
675 })
676 }
677
678 async fn plan_entry(&self, seed: PlanEntrySeed) -> Result<DownloadEntry> {
679 let resolved_streams = self
680 .fetch_stream_set(seed.source.clone(), seed.aid, seed.cid, seed.epid)
681 .await?;
682 let subtitles = self
683 .fetch_subtitles(
684 resolved_streams.source.clone(),
685 seed.aid,
686 seed.cid,
687 seed.epid,
688 )
689 .await
690 .unwrap_or_default();
691 Ok(DownloadEntry {
692 index: seed.index,
693 aid: seed.aid,
694 bvid: seed.bvid,
695 cid: seed.cid,
696 epid: seed.epid,
697 title: seed.title,
698 source: resolved_streams.source,
699 streams: resolved_streams.streams,
700 diagnostics: resolved_streams.diagnostics,
701 subtitles,
702 danmaku: DanmakuTrack {
703 cid: seed.cid,
704 xml_url: Self::endpoint_url(
705 &self.config.endpoints.comment_base,
706 &format!("/{}.xml", seed.cid),
707 )?
708 .to_string(),
709 },
710 })
711 }
712
713 fn select_video_pages(
714 video: &VideoMetadata,
715 selection: Option<&Selection>,
716 ) -> Result<Vec<PageMetadata>> {
717 let pages = match selection {
718 Some(Selection::All) => video.pages.clone(),
719 Some(Selection::Latest) => video.pages.last().cloned().into_iter().collect(),
720 Some(Selection::Page(page)) => video
721 .pages
722 .iter()
723 .find(|candidate| candidate.index == *page)
724 .cloned()
725 .into_iter()
726 .collect(),
727 Some(Selection::Current) | None => video.pages.first().cloned().into_iter().collect(),
728 Some(Selection::Episode(_)) => {
729 return Err(Error::InvalidInput(
730 "episode selection is only valid for PGC inputs".to_owned(),
731 ));
732 }
733 };
734 if pages.is_empty() {
735 return Err(Error::MissingField("selected page"));
736 }
737 Ok(pages)
738 }
739
740 async fn fetch_stream_set(
741 &self,
742 source: StreamSource,
743 aid: u64,
744 cid: u64,
745 epid: Option<u64>,
746 ) -> Result<ResolvedStreamSet> {
747 if source == StreamSource::PgcWeb {
748 return self.fetch_pgc_stream_set(aid, cid, epid).await;
749 }
750 let mut url = match source {
751 StreamSource::NormalWeb => {
752 Self::endpoint_url(&self.config.endpoints.api_base, "/x/player/playurl")?
753 }
754 StreamSource::PgcWeb | StreamSource::PgcProxy => unreachable!(),
755 StreamSource::IntlWeb => Self::endpoint_url(
756 &self.config.endpoints.intl_base,
757 "/intl/gateway/v2/ogv/playurl",
758 )?,
759 };
760 {
761 let mut query = url.query_pairs_mut();
762 match source {
763 StreamSource::NormalWeb => {
764 query
765 .append_pair("avid", &aid.to_string())
766 .append_pair("cid", &cid.to_string())
767 .append_pair("qn", "0")
768 .append_pair("fnval", "4048")
769 .append_pair("fnver", "0")
770 .append_pair("fourk", "1")
771 .append_pair("try_look", "1")
772 .append_pair("otype", "json");
773 }
774 StreamSource::PgcWeb | StreamSource::PgcProxy => unreachable!(),
775 StreamSource::IntlWeb => {
776 let epid = epid.ok_or(Error::MissingField("epid"))?;
777 for (key, value) in intl_ogv_playurl_params(
778 epid,
779 cid,
780 self.config.credentials.access_key.as_deref(),
781 current_unix_timestamp(),
782 ) {
783 query.append_pair(key, &value);
784 }
785 }
786 }
787 }
788 let streams = self.fetch_playurl_stream_set(url).await?;
789 Ok(ResolvedStreamSet::official(source, streams))
790 }
791
792 async fn fetch_pgc_stream_set(
793 &self,
794 aid: u64,
795 cid: u64,
796 epid: Option<u64>,
797 ) -> Result<ResolvedStreamSet> {
798 let epid = epid.ok_or(Error::MissingField("epid"))?;
799 let official_url = Self::pgc_playurl_url(&self.config.endpoints.pgc_base, aid, cid, epid)?;
800 match self.fetch_playurl_stream_set(official_url.clone()).await {
801 Ok(streams) => Ok(ResolvedStreamSet::official(StreamSource::PgcWeb, streams)),
802 Err(error)
803 if self.config.restricted_area.proxies.is_empty()
804 || !is_restricted_area_fallback_error(&error) =>
805 {
806 Err(error)
807 }
808 Err(error) => {
809 let mut attempts = vec![resolver_attempt(
810 StreamSource::PgcWeb,
811 None,
812 Some(redact_url_for_diagnostics(&official_url)),
813 StreamResolverOutcome::Failed,
814 Some(resolver_error_message(&error)),
815 )];
816 for proxy in self.config.restricted_area.ordered_proxies() {
817 let request_urls = match self.pgc_proxy_playurl_urls(&proxy, aid, cid, epid) {
818 Ok(urls) => urls,
819 Err(error) => {
820 attempts.push(resolver_attempt(
821 StreamSource::PgcProxy,
822 proxy.area,
823 Some(redact_url_string(&proxy.base_url)),
824 StreamResolverOutcome::Failed,
825 Some(resolver_error_message(&error)),
826 ));
827 continue;
828 }
829 };
830 for request_url in request_urls {
831 match self
832 .fetch_proxy_playurl_stream_set(request_url.clone())
833 .await
834 {
835 Ok(streams) => {
836 attempts.push(resolver_attempt(
837 StreamSource::PgcProxy,
838 proxy.area,
839 Some(redact_url_for_diagnostics(&request_url)),
840 StreamResolverOutcome::Succeeded,
841 None,
842 ));
843 return Ok(ResolvedStreamSet {
844 source: StreamSource::PgcProxy,
845 streams,
846 diagnostics: StreamDiagnostics { attempts },
847 });
848 }
849 Err(error) => attempts.push(resolver_attempt(
850 StreamSource::PgcProxy,
851 proxy.area,
852 Some(redact_url_for_diagnostics(&request_url)),
853 StreamResolverOutcome::Failed,
854 Some(resolver_error_message(&error)),
855 )),
856 }
857 }
858 }
859 Err(Error::AccessRestricted(format!(
860 "restricted-area resolver failed: {}",
861 summarize_resolver_attempts(&attempts)
862 )))
863 }
864 }
865 }
866
867 fn pgc_playurl_url(base_url: &str, aid: u64, cid: u64, epid: u64) -> Result<Url> {
868 let mut url = Self::endpoint_url(base_url, "/pgc/player/web/v2/playurl")?;
869 append_pgc_playurl_params(&mut url, aid, cid, epid, None, None);
870 Ok(url)
871 }
872
873 fn pgc_proxy_playurl_urls(
874 &self,
875 proxy: &RestrictedAreaProxy,
876 aid: u64,
877 cid: u64,
878 epid: u64,
879 ) -> Result<Vec<Url>> {
880 let mut urls = match proxy.kind {
881 RestrictedAreaProxyKind::PlayUrl => vec![Url::parse(&proxy.base_url)?],
882 RestrictedAreaProxyKind::BilibiliApi => vec![
883 Self::endpoint_url_preserving_query(&proxy.base_url, "/pgc/player/web/playurl")?,
884 Self::endpoint_url_preserving_query(&proxy.base_url, "/pgc/player/web/v2/playurl")?,
885 ],
886 };
887 for url in &mut urls {
888 append_pgc_playurl_params(
889 url,
890 aid,
891 cid,
892 epid,
893 proxy.area,
894 self.config.credentials.access_key.as_deref(),
895 );
896 }
897 Ok(urls)
898 }
899
900 async fn fetch_playurl_stream_set(&self, url: Url) -> Result<StreamSet> {
901 let response: PlayUrlRoot = self.get_json(url).await?;
902 response.into_stream_set()
903 }
904
905 async fn fetch_proxy_playurl_stream_set(&self, url: Url) -> Result<StreamSet> {
906 let response: PlayUrlRoot = self.get_json_without_cookie(url).await?;
907 response.into_stream_set()
908 }
909
910 async fn fetch_subtitles(
911 &self,
912 source: StreamSource,
913 aid: u64,
914 cid: u64,
915 epid: Option<u64>,
916 ) -> Result<Vec<SubtitleTrack>> {
917 match source {
918 StreamSource::NormalWeb | StreamSource::PgcWeb | StreamSource::PgcProxy => {
919 let mut url = Self::endpoint_url(&self.config.endpoints.api_base, "/x/player/v2")?;
920 url.query_pairs_mut()
921 .append_pair("aid", &aid.to_string())
922 .append_pair("cid", &cid.to_string());
923 let response: ApiData<PlayerV2Data> = self.get_json(url).await?;
924 Ok(response.into_data()?.into_subtitles())
925 }
926 StreamSource::IntlWeb => {
927 let epid = epid.ok_or(Error::MissingField("epid"))?;
928 let mut url = Self::endpoint_url(
929 &self.config.endpoints.intl_base,
930 "/intl/gateway/web/v2/subtitle",
931 )?;
932 {
933 let mut query = url.query_pairs_mut();
934 query
935 .append_pair("episode_id", &epid.to_string())
936 .append_pair("platform", "web")
937 .append_pair("s_locale", "en_US");
938 if let Some(access_key) = self.config.credentials.access_key.as_deref() {
939 query.append_pair("access_key", access_key);
940 }
941 }
942 let response: ApiData<IntlSubtitleData> = self.get_json(url).await?;
943 Ok(response.into_data()?.into_subtitles())
944 }
945 }
946 }
947
948 async fn get_json<T>(&self, url: Url) -> Result<T>
949 where
950 T: for<'de> Deserialize<'de>,
951 {
952 self.get_json_with_cookie(url, true).await
953 }
954
955 async fn get_json_without_cookie<T>(&self, url: Url) -> Result<T>
956 where
957 T: for<'de> Deserialize<'de>,
958 {
959 self.get_json_with_cookie(url, false).await
960 }
961
962 async fn get_json_with_cookie<T>(&self, url: Url, include_cookie: bool) -> Result<T>
963 where
964 T: for<'de> Deserialize<'de>,
965 {
966 let response = self
967 .http
968 .get(url)
969 .headers(self.headers(include_cookie)?)
970 .timeout(self.config.request_timeout)
971 .send()
972 .await
973 .map_err(Self::http_error_without_url)?;
974 let response = response
975 .error_for_status()
976 .map_err(Self::http_error_without_url)?;
977 response
978 .json::<T>()
979 .await
980 .map_err(Self::http_error_without_url)
981 }
982
983 pub(crate) fn anonymous_headers(&self) -> Result<HeaderMap> {
984 self.headers(false)
985 }
986
987 pub(crate) fn media_headers(&self) -> Result<HeaderMap> {
988 self.headers(false)
989 }
990
991 fn headers(&self, include_cookie: bool) -> Result<HeaderMap> {
992 let mut headers = HeaderMap::new();
993 headers.insert(
994 USER_AGENT,
995 HeaderValue::from_str(&self.config.user_agent)
996 .unwrap_or_else(|_| HeaderValue::from_static("bbdown-rs/0.1")),
997 );
998 headers.insert(
999 REFERER,
1000 HeaderValue::from_static("https://www.bilibili.com/"),
1001 );
1002 if include_cookie
1003 && let Some(cookie) = self.config.credentials.cookie.as_deref()
1004 && !cookie.is_empty()
1005 {
1006 let value = HeaderValue::from_str(cookie)
1007 .map_err(|_| Error::InvalidInput("invalid cookie header".to_owned()))?;
1008 headers.insert(COOKIE, value);
1009 }
1010 Ok(headers)
1011 }
1012
1013 pub(crate) fn http_error_without_url(error: reqwest::Error) -> Error {
1014 Error::Http(error.without_url())
1015 }
1016
1017 pub(crate) fn endpoint_url(base: &str, path: &str) -> Result<Url> {
1018 let mut url = Url::parse(base)?;
1019 set_endpoint_path(&mut url, path);
1020 url.set_query(None);
1021 url.set_fragment(None);
1022 Ok(url)
1023 }
1024
1025 fn endpoint_url_preserving_query(base: &str, path: &str) -> Result<Url> {
1026 let mut url = Url::parse(base)?;
1027 set_endpoint_path(&mut url, path);
1028 url.set_fragment(None);
1029 Ok(url)
1030 }
1031}
1032
1033fn set_endpoint_path(url: &mut Url, path: &str) {
1034 let base_path = url.path().trim_end_matches('/');
1035 let suffix = path.trim_start_matches('/');
1036 let next_path = if base_path.is_empty() {
1037 format!("/{suffix}")
1038 } else {
1039 format!("{base_path}/{suffix}")
1040 };
1041 url.set_path(&next_path);
1042}
1043
1044fn season_from_pgc(result: PgcSeasonResult) -> SeasonMetadata {
1045 let PgcSeasonResult {
1046 season_id,
1047 media_id,
1048 title,
1049 season_title,
1050 evaluate,
1051 cover,
1052 episodes,
1053 section,
1054 areas,
1055 styles,
1056 } = result;
1057 let mut episodes = episodes_to_metadata(episodes, 0);
1058 let main_episode_count = episodes.len();
1059 let section_episodes = section
1060 .into_iter()
1061 .flat_map(|section| section.episodes.into_iter())
1062 .collect();
1063 episodes.extend(episodes_to_metadata(section_episodes, main_episode_count));
1064 SeasonMetadata {
1065 season_id,
1066 media_id,
1067 title: title.or(season_title).unwrap_or_default(),
1068 description: evaluate.unwrap_or_default(),
1069 cover_url: cover,
1070 main_episode_count,
1071 areas: areas.into_iter().filter_map(PgcName::into_name).collect(),
1072 tags: styles.into_iter().filter_map(PgcName::into_name).collect(),
1073 episodes,
1074 }
1075}
1076
1077fn season_from_intl(result: IntlSeasonResult, current_epid: Option<u64>) -> SeasonMetadata {
1078 let IntlSeasonResult {
1079 season_id,
1080 media_id,
1081 title,
1082 season_title,
1083 evaluate,
1084 cover,
1085 episodes,
1086 modules,
1087 areas,
1088 styles,
1089 ..
1090 } = result;
1091 let mut module_episode_groups = modules
1092 .into_iter()
1093 .filter_map(|module| module.data)
1094 .map(|data| data.episodes)
1095 .filter(|episodes| !episodes.is_empty())
1096 .collect::<Vec<_>>();
1097 let module_episodes = current_epid
1098 .and_then(|epid| {
1099 module_episode_groups
1100 .iter()
1101 .find(|episodes| episodes.iter().any(|episode| episode.epid() == Some(epid)))
1102 .cloned()
1103 })
1104 .unwrap_or_else(|| module_episode_groups.drain(..).flatten().collect());
1105 let episodes = if episodes.is_empty() {
1106 module_episodes
1107 } else {
1108 episodes
1109 };
1110 let episodes = episodes_to_metadata(episodes, 0);
1111 let main_episode_count = episodes.len();
1112 SeasonMetadata {
1113 season_id,
1114 media_id,
1115 title: title.or(season_title).unwrap_or_default(),
1116 description: evaluate.unwrap_or_default(),
1117 cover_url: cover,
1118 main_episode_count,
1119 areas: areas.into_iter().filter_map(PgcName::into_name).collect(),
1120 tags: styles.into_iter().filter_map(PgcName::into_name).collect(),
1121 episodes,
1122 }
1123}
1124
1125fn episodes_to_metadata(episodes: Vec<PgcEpisode>, start_index: usize) -> Vec<EpisodeMetadata> {
1126 let mut output = Vec::new();
1127 for episode in episodes {
1128 if let Some(mut episode) = episode_from_pgc(0, episode) {
1129 let index = start_index
1130 .checked_add(output.len())
1131 .and_then(|value| value.checked_add(1));
1132 if let Some(index) = index.and_then(|value| u32::try_from(value).ok()) {
1133 episode.index = index;
1134 output.push(episode);
1135 }
1136 }
1137 }
1138 output
1139}
1140
1141fn episode_from_pgc(index: usize, episode: PgcEpisode) -> Option<EpisodeMetadata> {
1142 let epid = episode.epid()?;
1143 Some(EpisodeMetadata {
1144 index: u32::try_from(index + 1).ok()?,
1145 aid: episode.aid?,
1146 bvid: episode.bvid,
1147 cid: episode.cid?,
1148 epid,
1149 title: episode.title.unwrap_or_default(),
1150 long_title: episode.long_title,
1151 pub_time: episode.pub_time,
1152 })
1153}
1154
1155#[derive(Debug, Deserialize)]
1156struct ApiData<T> {
1157 code: i64,
1158 #[serde(default)]
1159 message: String,
1160 data: Option<T>,
1161}
1162
1163impl<T> ApiData<T> {
1164 fn into_data(self) -> Result<T> {
1165 if self.code != 0 {
1166 return Err(Error::Api {
1167 code: self.code,
1168 message: self.message,
1169 });
1170 }
1171 self.data.ok_or(Error::MissingField("data"))
1172 }
1173}
1174
1175#[derive(Debug, Deserialize)]
1176struct ApiResult<T> {
1177 code: i64,
1178 #[serde(default)]
1179 message: String,
1180 result: Option<T>,
1181}
1182
1183impl<T> ApiResult<T> {
1184 fn into_result(self) -> Result<T> {
1185 if self.code != 0 {
1186 return Err(Error::Api {
1187 code: self.code,
1188 message: self.message,
1189 });
1190 }
1191 self.result.ok_or(Error::MissingField("result"))
1192 }
1193}
1194
1195#[derive(Debug, Deserialize)]
1196struct ViewData {
1197 aid: Option<u64>,
1198 bvid: Option<String>,
1199 title: String,
1200 desc: Option<String>,
1201 pic: Option<String>,
1202 pubdate: Option<i64>,
1203 owner: Option<ViewOwner>,
1204 #[serde(default)]
1205 pages: Vec<ViewPage>,
1206}
1207
1208#[derive(Debug, Deserialize)]
1209struct ViewOwner {
1210 mid: u64,
1211 name: String,
1212}
1213
1214#[derive(Debug, Deserialize)]
1215struct ViewPage {
1216 page: u32,
1217 cid: u64,
1218 part: Option<String>,
1219 duration: Option<u32>,
1220}
1221
1222#[derive(Debug, Deserialize)]
1223struct TagData {
1224 tag_id: Option<u64>,
1225 tag_name: Option<String>,
1226}
1227
1228#[derive(Debug, Deserialize)]
1229struct PgcSeasonResult {
1230 season_id: Option<u64>,
1231 media_id: Option<u64>,
1232 title: Option<String>,
1233 season_title: Option<String>,
1234 evaluate: Option<String>,
1235 cover: Option<String>,
1236 #[serde(default)]
1237 episodes: Vec<PgcEpisode>,
1238 #[serde(default)]
1239 section: Vec<PgcSection>,
1240 #[serde(default)]
1241 areas: Vec<PgcName>,
1242 #[serde(default)]
1243 styles: Vec<PgcName>,
1244}
1245
1246#[derive(Debug, Deserialize)]
1247struct IntlSeasonRoot {
1248 code: i64,
1249 #[serde(default)]
1250 message: String,
1251 result: Option<IntlSeasonResult>,
1252 data: Option<IntlSeasonResult>,
1253}
1254
1255impl IntlSeasonRoot {
1256 fn into_result(self) -> Result<IntlSeasonResult> {
1257 if self.code != 0 {
1258 return Err(Error::Api {
1259 code: self.code,
1260 message: self.message,
1261 });
1262 }
1263 self.result
1264 .or(self.data)
1265 .ok_or(Error::MissingField("result"))
1266 }
1267}
1268
1269#[derive(Debug, Deserialize)]
1270struct IntlSeasonResult {
1271 season_id: Option<u64>,
1272 media_id: Option<u64>,
1273 title: Option<String>,
1274 season_title: Option<String>,
1275 evaluate: Option<String>,
1276 cover: Option<String>,
1277 status: Option<i64>,
1278 limit: Option<IntlLimit>,
1279 #[serde(default)]
1280 episodes: Vec<PgcEpisode>,
1281 #[serde(default)]
1282 modules: Vec<IntlModule>,
1283 #[serde(default)]
1284 areas: Vec<PgcName>,
1285 #[serde(default)]
1286 styles: Vec<PgcName>,
1287}
1288
1289impl IntlSeasonResult {
1290 fn access_limit_message(&self) -> Option<String> {
1291 if self.has_episodes() {
1292 return None;
1293 }
1294 let content = self
1295 .limit
1296 .as_ref()
1297 .and_then(|limit| limit.content.as_deref())
1298 .map(str::trim)
1299 .filter(|content| !content.is_empty());
1300 if let Some(content) = content {
1301 return Some(content.to_owned());
1302 }
1303 if self.status == Some(13) {
1304 return Some("intl content is not available in the current region".to_owned());
1305 }
1306 None
1307 }
1308
1309 fn has_episodes(&self) -> bool {
1310 !self.episodes.is_empty()
1311 || self.modules.iter().any(|module| {
1312 module
1313 .data
1314 .as_ref()
1315 .is_some_and(|data| !data.episodes.is_empty())
1316 })
1317 }
1318}
1319
1320#[derive(Debug, Deserialize)]
1321struct IntlLimit {
1322 content: Option<String>,
1323}
1324
1325#[derive(Debug, Deserialize)]
1326struct IntlModule {
1327 data: Option<IntlModuleData>,
1328}
1329
1330#[derive(Debug, Deserialize)]
1331struct IntlModuleData {
1332 #[serde(default)]
1333 episodes: Vec<PgcEpisode>,
1334}
1335
1336#[derive(Clone, Debug, Deserialize)]
1337struct PgcEpisode {
1338 aid: Option<u64>,
1339 bvid: Option<String>,
1340 cid: Option<u64>,
1341 id: Option<u64>,
1342 ep_id: Option<u64>,
1343 episode_id: Option<u64>,
1344 title: Option<String>,
1345 long_title: Option<String>,
1346 pub_time: Option<i64>,
1347}
1348
1349impl PgcEpisode {
1350 fn epid(&self) -> Option<u64> {
1351 self.ep_id.or(self.episode_id).or(self.id)
1352 }
1353}
1354
1355#[derive(Debug, Deserialize)]
1356struct PgcSection {
1357 #[serde(default)]
1358 episodes: Vec<PgcEpisode>,
1359}
1360
1361#[derive(Debug, Deserialize)]
1362#[serde(untagged)]
1363enum PgcName {
1364 Object { name: Option<String> },
1365 Text(String),
1366}
1367
1368impl PgcName {
1369 fn into_name(self) -> Option<String> {
1370 match self {
1371 Self::Object { name } => name,
1372 Self::Text(name) => Some(name),
1373 }
1374 .filter(|name| !name.is_empty())
1375 }
1376}
1377
1378#[derive(Debug, Deserialize)]
1379struct PgcReviewResult {
1380 media: Option<PgcReviewMedia>,
1381}
1382
1383#[derive(Debug, Deserialize)]
1384struct PgcReviewMedia {
1385 new_ep: Option<PgcReviewEpisode>,
1386}
1387
1388#[derive(Debug, Deserialize)]
1389struct PgcReviewEpisode {
1390 id: u64,
1391}
1392
1393#[derive(Clone, Debug)]
1394struct PlanEntrySeed {
1395 index: u32,
1396 aid: u64,
1397 bvid: Option<String>,
1398 cid: u64,
1399 epid: Option<u64>,
1400 title: String,
1401 source: StreamSource,
1402}
1403
1404#[derive(Clone, Debug)]
1405struct ResolvedStreamSet {
1406 source: StreamSource,
1407 streams: StreamSet,
1408 diagnostics: StreamDiagnostics,
1409}
1410
1411impl ResolvedStreamSet {
1412 fn official(source: StreamSource, streams: StreamSet) -> Self {
1413 Self {
1414 source,
1415 streams,
1416 diagnostics: StreamDiagnostics::default(),
1417 }
1418 }
1419}
1420
1421#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1422enum TagPolicy {
1423 Fetch,
1424 Skip,
1425}
1426
1427fn episode_display_title(title: &str, long_title: Option<&str>) -> String {
1428 match long_title.filter(|value| !value.is_empty()) {
1429 Some(long_title) if title.is_empty() => long_title.to_owned(),
1430 Some(long_title) => format!("{title} {long_title}"),
1431 None => title.to_owned(),
1432 }
1433}
1434
1435fn append_pgc_playurl_params(
1436 url: &mut Url,
1437 aid: u64,
1438 cid: u64,
1439 epid: u64,
1440 area: Option<RestrictedArea>,
1441 access_key: Option<&str>,
1442) {
1443 let mut query = url.query_pairs_mut();
1444 query
1445 .append_pair("avid", &aid.to_string())
1446 .append_pair("cid", &cid.to_string())
1447 .append_pair("ep_id", &epid.to_string())
1448 .append_pair("module", "bangumi")
1449 .append_pair("qn", "0")
1450 .append_pair("fnval", "4048")
1451 .append_pair("fnver", "0")
1452 .append_pair("fourk", "1")
1453 .append_pair("otype", "json");
1454 if let Some(area) = area {
1455 query.append_pair("area", area.as_str());
1456 }
1457 if let Some(access_key) = access_key.filter(|value| !value.is_empty()) {
1458 query.append_pair("access_key", access_key);
1459 }
1460}
1461
1462fn is_restricted_area_fallback_error(error: &Error) -> bool {
1463 match error {
1464 Error::Api { message, .. } | Error::AccessRestricted(message) => {
1465 is_restricted_area_message(message)
1466 }
1467 _ => false,
1468 }
1469}
1470
1471fn is_restricted_area_message(message: &str) -> bool {
1472 let lower = message.to_ascii_lowercase();
1473 [
1474 "area restricted",
1475 "area limit",
1476 "region restricted",
1477 "region limit",
1478 "not available in your region",
1479 "地区限制",
1480 "地區限制",
1481 "区域限制",
1482 "區域限制",
1483 "所在地区不可观看",
1484 "所在地區不可觀看",
1485 "所在地区无法观看",
1486 "所在地區無法觀看",
1487 "所在的地区不可观看",
1488 "所在的地區不可觀看",
1489 "所在的地区无法观看",
1490 "所在的地區無法觀看",
1491 "地区不可观看",
1492 "地區不可觀看",
1493 "地区无法观看",
1494 "地區無法觀看",
1495 ]
1496 .iter()
1497 .any(|needle| lower.contains(needle))
1498}
1499
1500fn resolver_attempt(
1501 source: StreamSource,
1502 area: Option<RestrictedArea>,
1503 endpoint: Option<String>,
1504 outcome: StreamResolverOutcome,
1505 message: Option<String>,
1506) -> StreamResolverAttempt {
1507 StreamResolverAttempt {
1508 source,
1509 outcome,
1510 area: area.map(|area| area.as_str().to_owned()),
1511 endpoint,
1512 message,
1513 }
1514}
1515
1516fn resolver_error_message(error: &Error) -> String {
1517 match error {
1518 Error::Api { code, message } => {
1519 format!("API code {code}: {}", sanitize_diagnostic_text(message))
1520 }
1521 Error::AccessRestricted(message) => {
1522 format!("access restricted: {}", sanitize_diagnostic_text(message))
1523 }
1524 Error::MissingField(field) => format!("missing field: {field}"),
1525 Error::Http(error) => format!(
1526 "HTTP error: {}",
1527 sanitize_diagnostic_text(&error.to_string())
1528 ),
1529 Error::Json(error) => format!(
1530 "JSON error: {}",
1531 sanitize_diagnostic_text(&error.to_string())
1532 ),
1533 Error::Url(error) => format!(
1534 "URL error: {}",
1535 sanitize_diagnostic_text(&error.to_string())
1536 ),
1537 Error::InvalidInput(message) => {
1538 format!("invalid input: {}", sanitize_diagnostic_text(message))
1539 }
1540 Error::SelectionRequired { input_kind } => {
1541 format!("{input_kind} links require an explicit selection")
1542 }
1543 Error::Unsupported(message) => {
1544 format!("unsupported: {}", sanitize_diagnostic_text(message))
1545 }
1546 Error::Io(error) => format!(
1547 "I/O error: {}",
1548 sanitize_diagnostic_text(&error.to_string())
1549 ),
1550 Error::MuxFailed { status } => {
1551 format!(
1552 "ffmpeg mux failed with status {}",
1553 sanitize_diagnostic_text(status)
1554 )
1555 }
1556 }
1557}
1558
1559fn summarize_resolver_attempts(attempts: &[StreamResolverAttempt]) -> String {
1560 attempts
1561 .iter()
1562 .map(|attempt| {
1563 let area = attempt
1564 .area
1565 .as_deref()
1566 .map(|area| format!(" area={area}"))
1567 .unwrap_or_default();
1568 let message = attempt
1569 .message
1570 .as_deref()
1571 .map(|message| format!(" ({message})"))
1572 .unwrap_or_default();
1573 format!("{:?}{area} {:?}{message}", attempt.source, attempt.outcome)
1574 })
1575 .collect::<Vec<_>>()
1576 .join("; ")
1577}
1578
1579fn redact_url_for_diagnostics(url: &Url) -> String {
1580 let mut redacted = url.clone();
1581 let _ = redacted.set_username("");
1582 let _ = redacted.set_password(None);
1583 redacted.set_path("");
1584 redacted.set_query(None);
1585 redacted.set_fragment(None);
1586 redacted.to_string().trim_end_matches('/').to_owned()
1587}
1588
1589fn redact_url_string(raw: &str) -> String {
1590 Url::parse(raw).map_or_else(
1591 |_| redact_unparsed_url_for_diagnostics(raw),
1592 |url| redact_url_for_diagnostics(&url),
1593 )
1594}
1595
1596fn sanitize_diagnostic_text(raw: &str) -> String {
1597 let without_urls = redact_urls_in_text(raw);
1598 let redacted = redact_sensitive_key_values(&without_urls);
1599 let lower = redacted.to_ascii_lowercase();
1600 if SENSITIVE_DIAGNOSTIC_KEYS
1601 .iter()
1602 .any(|key| lower.contains(key))
1603 {
1604 "redacted diagnostic message".to_owned()
1605 } else {
1606 redacted
1607 }
1608}
1609
1610const SENSITIVE_DIAGNOSTIC_KEYS: &[&str] = &[
1611 "access_key",
1612 "access_token",
1613 "proxy_token",
1614 "token",
1615 "jwt",
1616 "api_key",
1617 "x-api-key",
1618 "authorization",
1619 "sessdata",
1620 "bili_jct",
1621 "cookie",
1622];
1623
1624fn redact_urls_in_text(raw: &str) -> String {
1625 let mut output = String::with_capacity(raw.len());
1626 let mut index = 0;
1627 while let Some(relative_start) = find_next_url_start(&raw[index..]) {
1628 let start = index + relative_start;
1629 output.push_str(&raw[index..start]);
1630 let end = raw[start..]
1631 .find(is_message_url_delimiter)
1632 .map_or(raw.len(), |relative_end| start + relative_end);
1633 let token = &raw[start..end];
1634 let (url_token, trailing) = split_trailing_url_punctuation(token);
1635 match Url::parse(url_token) {
1636 Ok(url) => output.push_str(&redact_url_for_diagnostics(&url)),
1637 Err(_) => output.push_str(&redact_unparsed_url_for_diagnostics(url_token)),
1638 }
1639 output.push_str(trailing);
1640 index = end;
1641 }
1642 output.push_str(&raw[index..]);
1643 output
1644}
1645
1646fn find_next_url_start(raw: &str) -> Option<usize> {
1647 let lower = raw.to_ascii_lowercase();
1648 match (lower.find("http://"), lower.find("https://")) {
1649 (Some(http), Some(https)) => Some(http.min(https)),
1650 (Some(http), None) => Some(http),
1651 (None, Some(https)) => Some(https),
1652 (None, None) => None,
1653 }
1654}
1655
1656fn is_message_url_delimiter(character: char) -> bool {
1657 character.is_whitespace() || matches!(character, '"' | '\'' | '<' | '>' | '(' | ')')
1658}
1659
1660fn split_trailing_url_punctuation(token: &str) -> (&str, &str) {
1661 let trimmed_len = token.trim_end_matches([',', '.', ';']).len();
1662 token.split_at(trimmed_len)
1663}
1664
1665fn redact_sensitive_key_values(raw: &str) -> String {
1666 SENSITIVE_DIAGNOSTIC_KEYS
1667 .iter()
1668 .fold(raw.to_owned(), |value, key| {
1669 redact_sensitive_key_value(&value, key)
1670 })
1671}
1672
1673fn redact_sensitive_key_value(raw: &str, key: &str) -> String {
1674 let with_equals = redact_sensitive_key_value_with_separator(raw, key, '=');
1675 redact_sensitive_key_value_with_separator(&with_equals, key, ':')
1676}
1677
1678fn redact_sensitive_key_value_with_separator(raw: &str, key: &str, separator: char) -> String {
1679 let mut output = String::with_capacity(raw.len());
1680 let mut index = 0;
1681 let pattern = format!("{key}{separator}");
1682 let lower = raw.to_ascii_lowercase();
1683 while let Some(relative_start) = lower[index..].find(&pattern) {
1684 let start = index + relative_start;
1685 output.push_str(&raw[index..start]);
1686 output.push_str("<redacted>");
1687 let value_start = skip_ascii_whitespace(raw, start + pattern.len());
1688 let value_end = raw[value_start..]
1689 .find(|character| is_sensitive_value_delimiter_for_key(key, character))
1690 .map_or(raw.len(), |relative_end| value_start + relative_end);
1691 index = value_end;
1692 }
1693 output.push_str(&raw[index..]);
1694 output
1695}
1696
1697fn skip_ascii_whitespace(raw: &str, mut index: usize) -> usize {
1698 while let Some(character) = raw[index..].chars().next() {
1699 if !character.is_ascii_whitespace() {
1700 break;
1701 }
1702 index += character.len_utf8();
1703 }
1704 index
1705}
1706
1707fn is_sensitive_value_delimiter_for_key(key: &str, character: char) -> bool {
1708 if key == "authorization" {
1709 matches!(
1710 character,
1711 '&' | '"' | '\'' | '<' | '>' | '(' | ')' | ',' | ';' | '\r' | '\n'
1712 )
1713 } else {
1714 is_sensitive_value_delimiter(character)
1715 }
1716}
1717
1718fn is_sensitive_value_delimiter(character: char) -> bool {
1719 character.is_whitespace()
1720 || matches!(
1721 character,
1722 '&' | '"' | '\'' | '<' | '>' | '(' | ')' | ',' | ';' | '\r' | '\n'
1723 )
1724}
1725
1726fn redact_basic_auth_like_string(raw: &str) -> String {
1727 let Some(scheme_end) = raw.find("//") else {
1728 return raw.to_owned();
1729 };
1730 let after_scheme = &raw[(scheme_end + 2)..];
1731 let Some(userinfo_end) = after_scheme.find('@') else {
1732 return raw.to_owned();
1733 };
1734 format!(
1735 "{}//<redacted>@{}",
1736 &raw[..scheme_end],
1737 &after_scheme[(userinfo_end + 1)..]
1738 )
1739 .trim_end_matches('/')
1740 .to_owned()
1741}
1742
1743fn redact_unparsed_url_for_diagnostics(raw: &str) -> String {
1744 let basic_auth_redacted = redact_basic_auth_like_string(raw);
1745 let Some(scheme_end) = basic_auth_redacted.find("//") else {
1746 return "<invalid-url>".to_owned();
1747 };
1748 let prefix = &basic_auth_redacted[..scheme_end];
1749 let after_scheme = &basic_auth_redacted[(scheme_end + 2)..];
1750 let authority_end = after_scheme
1751 .find(|character: char| character.is_whitespace() || matches!(character, '/' | '?' | '#'))
1752 .unwrap_or(after_scheme.len());
1753 let authority = &after_scheme[..authority_end];
1754 let authority = authority
1755 .rsplit_once('@')
1756 .map_or(authority, |(_, host)| host);
1757 if authority.is_empty() {
1758 "<invalid-url>".to_owned()
1759 } else {
1760 format!("{prefix}//{authority}")
1761 .trim_end_matches('/')
1762 .to_owned()
1763 }
1764}
1765
1766#[derive(Debug, Deserialize)]
1767struct PlayUrlRoot {
1768 code: i64,
1769 #[serde(default)]
1770 message: String,
1771 #[serde(default, deserialize_with = "deserialize_optional_play_payload")]
1772 data: Option<PlayPayload>,
1773 #[serde(default, deserialize_with = "deserialize_optional_play_payload")]
1774 result: Option<PlayPayload>,
1775 #[serde(flatten)]
1776 payload: PlayPayload,
1777}
1778
1779fn deserialize_optional_play_payload<'de, D>(
1780 deserializer: D,
1781) -> std::result::Result<Option<PlayPayload>, D::Error>
1782where
1783 D: serde::Deserializer<'de>,
1784{
1785 let Some(value) = Option::<serde_json::Value>::deserialize(deserializer)? else {
1786 return Ok(None);
1787 };
1788 match value {
1789 serde_json::Value::Object(_) => serde_json::from_value(value)
1790 .map(Some)
1791 .map_err(serde::de::Error::custom),
1792 _ => Ok(None),
1793 }
1794}
1795
1796impl PlayUrlRoot {
1797 fn into_stream_set(self) -> Result<StreamSet> {
1798 if self.code != 0 {
1799 return Err(Error::Api {
1800 code: self.code,
1801 message: self.message,
1802 });
1803 }
1804 let payload = self
1805 .result
1806 .or(self.data)
1807 .or_else(|| self.payload.has_playurl_content().then_some(self.payload))
1808 .ok_or(Error::MissingField("playurl result"))?;
1809 payload.into_stream_set()
1810 }
1811}
1812
1813#[derive(Debug, Deserialize)]
1814struct PlayPayload {
1815 video_info: Option<Box<PlayPayload>>,
1816 playurl: Option<IntlPlayUrlPayload>,
1817 dash: Option<DashPayload>,
1818 stream_list: Option<Vec<IntlStreamItem>>,
1819 dash_audio: Option<Vec<IntlMediaResource>>,
1820 durl: Option<Vec<DurlSegment>>,
1821 #[serde(default)]
1822 accept_quality: Vec<u32>,
1823 #[serde(default)]
1824 accept_description: Vec<String>,
1825 #[serde(default)]
1826 support_formats: Vec<SupportFormat>,
1827 timelength: Option<u64>,
1828}
1829
1830impl PlayPayload {
1831 fn has_playurl_content(&self) -> bool {
1832 self.video_info.is_some()
1833 || self.playurl.is_some()
1834 || self.dash.is_some()
1835 || self.stream_list.is_some()
1836 || self.dash_audio.is_some()
1837 || self.durl.is_some()
1838 }
1839
1840 fn into_stream_set(mut self) -> Result<StreamSet> {
1841 if let Some(video_info) = self.video_info.take() {
1842 return video_info.into_stream_set();
1843 }
1844 if let Some(playurl) = self.playurl.take() {
1845 return playurl.into_stream_set(self.timelength);
1846 }
1847 let dash_duration = self.dash.as_ref().and_then(|dash| dash.duration);
1848 let duration_seconds = dash_duration.or_else(|| {
1849 self.timelength
1850 .and_then(|value| u32::try_from(value / 1000).ok())
1851 });
1852 let mut videos = Vec::new();
1853 let mut audios = Vec::new();
1854 if let Some(dash) = self.dash {
1855 videos.extend(
1856 dash.video
1857 .unwrap_or_default()
1858 .into_iter()
1859 .filter_map(DashTrack::into_media_stream),
1860 );
1861 audios.extend(
1862 dash.audio
1863 .unwrap_or_default()
1864 .into_iter()
1865 .filter_map(DashTrack::into_media_stream),
1866 );
1867 if let Some(dolby) = dash.dolby {
1868 audios.extend(
1869 dolby
1870 .audio
1871 .unwrap_or_default()
1872 .into_iter()
1873 .filter_map(DashTrack::into_media_stream),
1874 );
1875 }
1876 if let Some(flac) = dash.flac
1877 && let Some(audio) = flac.audio
1878 && let Some(stream) = audio.into_media_stream()
1879 {
1880 audios.push(stream);
1881 }
1882 }
1883 videos.extend(
1884 self.stream_list
1885 .unwrap_or_default()
1886 .into_iter()
1887 .filter_map(IntlStreamItem::into_video_stream),
1888 );
1889 audios.extend(
1890 self.dash_audio
1891 .unwrap_or_default()
1892 .into_iter()
1893 .enumerate()
1894 .filter_map(|(index, resource)| resource.into_media_stream(fallback_id(index))),
1895 );
1896 let flv_segments = self
1897 .durl
1898 .unwrap_or_default()
1899 .into_iter()
1900 .enumerate()
1901 .filter_map(|(index, segment)| segment.into_flv_segment(index))
1902 .collect::<Vec<_>>();
1903 if videos.is_empty() && audios.is_empty() && flv_segments.is_empty() {
1904 return Err(Error::MissingField("playurl streams"));
1905 }
1906 Ok(StreamSet {
1907 qualities: stream_qualities(
1908 &self.accept_quality,
1909 &self.accept_description,
1910 &self.support_formats,
1911 &videos,
1912 ),
1913 videos,
1914 audios,
1915 flv_segments,
1916 accept_quality: self.accept_quality,
1917 duration_seconds,
1918 })
1919 }
1920}
1921
1922#[derive(Debug, Deserialize)]
1923struct SupportFormat {
1924 quality: Option<u32>,
1925 new_description: Option<String>,
1926 display_desc: Option<String>,
1927 description: Option<String>,
1928}
1929
1930impl SupportFormat {
1931 fn label(&self) -> Option<String> {
1932 first_non_empty([
1933 self.new_description.clone(),
1934 self.display_desc.clone(),
1935 self.description.clone(),
1936 ])
1937 }
1938}
1939
1940#[derive(Debug, Deserialize)]
1941struct IntlPlayUrlPayload {
1942 video: Option<Vec<IntlStreamItem>>,
1943 audio_resource: Option<Vec<IntlMediaResource>>,
1944 timelength: Option<u64>,
1945}
1946
1947impl IntlPlayUrlPayload {
1948 fn into_stream_set(self, fallback_timelength: Option<u64>) -> Result<StreamSet> {
1949 let videos = self
1950 .video
1951 .unwrap_or_default()
1952 .into_iter()
1953 .filter_map(IntlStreamItem::into_video_stream)
1954 .collect::<Vec<_>>();
1955 let audios = self
1956 .audio_resource
1957 .unwrap_or_default()
1958 .into_iter()
1959 .enumerate()
1960 .filter_map(|(index, resource)| resource.into_media_stream(fallback_id(index)))
1961 .collect::<Vec<_>>();
1962 if videos.is_empty() && audios.is_empty() {
1963 return Err(Error::MissingField("intl playurl streams"));
1964 }
1965 let accept_quality = videos.iter().map(|stream| stream.id).collect::<Vec<_>>();
1966 let duration_seconds = self
1967 .timelength
1968 .or(fallback_timelength)
1969 .and_then(|value| u32::try_from(value / 1000).ok());
1970 Ok(StreamSet {
1971 qualities: stream_qualities(&accept_quality, &[], &[], &videos),
1972 videos,
1973 audios,
1974 flv_segments: Vec::new(),
1975 accept_quality,
1976 duration_seconds,
1977 })
1978 }
1979}
1980
1981#[derive(Debug, Deserialize)]
1982struct IntlStreamItem {
1983 stream_info: Option<IntlStreamInfo>,
1984 video_resource: Option<IntlMediaResource>,
1985 dash_video: Option<IntlMediaResource>,
1986}
1987
1988impl IntlStreamItem {
1989 fn into_video_stream(self) -> Option<MediaStream> {
1990 let id = self.stream_info.and_then(|info| info.quality).or_else(|| {
1991 self.video_resource
1992 .as_ref()
1993 .or(self.dash_video.as_ref())
1994 .and_then(|resource| resource.id)
1995 })?;
1996 self.video_resource
1997 .or(self.dash_video)
1998 .and_then(|resource| resource.into_media_stream(id))
1999 }
2000}
2001
2002#[derive(Debug, Deserialize)]
2003struct IntlStreamInfo {
2004 quality: Option<u32>,
2005}
2006
2007#[derive(Debug, Deserialize)]
2008struct IntlMediaResource {
2009 id: Option<u32>,
2010 url: Option<String>,
2011 base_url: Option<String>,
2012 #[serde(rename = "baseUrl")]
2013 base_url_camel: Option<String>,
2014 backup_url: Option<Vec<String>>,
2015 #[serde(rename = "backupUrl")]
2016 backup_url_camel: Option<Vec<String>>,
2017 codecs: Option<String>,
2018 bandwidth: Option<u64>,
2019 width: Option<u32>,
2020 height: Option<u32>,
2021 size: Option<u64>,
2022}
2023
2024impl IntlMediaResource {
2025 fn into_media_stream(self, fallback_id: u32) -> Option<MediaStream> {
2026 let base_url = first_non_empty([self.url, self.base_url, self.base_url_camel])?;
2027 Some(MediaStream {
2028 id: self.id.unwrap_or(fallback_id),
2029 base_url: normalize_media_url(&base_url),
2030 backup_urls: normalize_media_urls([self.backup_url, self.backup_url_camel]),
2031 codecs: self.codecs,
2032 bandwidth: self.bandwidth,
2033 width: self.width,
2034 height: self.height,
2035 frame_rate: None,
2036 mime_type: None,
2037 size: self.size,
2038 })
2039 }
2040}
2041
2042#[derive(Debug, Deserialize)]
2043struct DashPayload {
2044 duration: Option<u32>,
2045 video: Option<Vec<DashTrack>>,
2046 audio: Option<Vec<DashTrack>>,
2047 dolby: Option<DolbyPayload>,
2048 flac: Option<FlacPayload>,
2049}
2050
2051#[derive(Debug, Deserialize)]
2052struct DolbyPayload {
2053 audio: Option<Vec<DashTrack>>,
2054}
2055
2056#[derive(Debug, Deserialize)]
2057struct FlacPayload {
2058 audio: Option<DashTrack>,
2059}
2060
2061#[derive(Debug, Deserialize)]
2062struct DashTrack {
2063 id: Option<u32>,
2064 base_url: Option<String>,
2065 #[serde(rename = "baseUrl")]
2066 base_url_camel: Option<String>,
2067 backup_url: Option<Vec<String>>,
2068 #[serde(rename = "backupUrl")]
2069 backup_url_camel: Option<Vec<String>>,
2070 codecs: Option<String>,
2071 bandwidth: Option<u64>,
2072 width: Option<u32>,
2073 height: Option<u32>,
2074 frame_rate: Option<String>,
2075 #[serde(rename = "frameRate")]
2076 frame_rate_camel: Option<String>,
2077 mime_type: Option<String>,
2078 #[serde(rename = "mimeType")]
2079 mime_type_camel: Option<String>,
2080 size: Option<u64>,
2081}
2082
2083impl DashTrack {
2084 fn into_media_stream(self) -> Option<MediaStream> {
2085 let base_url = first_non_empty([self.base_url, self.base_url_camel])?;
2086 Some(MediaStream {
2087 id: self.id?,
2088 base_url: normalize_media_url(&base_url),
2089 backup_urls: normalize_media_urls([self.backup_url, self.backup_url_camel]),
2090 codecs: self.codecs,
2091 bandwidth: self.bandwidth,
2092 width: self.width,
2093 height: self.height,
2094 frame_rate: first_non_empty([self.frame_rate, self.frame_rate_camel]),
2095 mime_type: first_non_empty([self.mime_type, self.mime_type_camel]),
2096 size: self.size,
2097 })
2098 }
2099}
2100
2101#[derive(Debug, Deserialize)]
2102struct DurlSegment {
2103 order: Option<u32>,
2104 url: Option<String>,
2105 #[serde(alias = "backupUrl")]
2106 backup_url: Option<Vec<String>>,
2107 size: Option<u64>,
2108 length: Option<u64>,
2109}
2110
2111impl DurlSegment {
2112 fn into_flv_segment(self, index: usize) -> Option<FlvSegment> {
2113 let url = self.url.filter(|value| !value.is_empty())?;
2114 Some(FlvSegment {
2115 order: self
2116 .order
2117 .or_else(|| u32::try_from(index + 1).ok())
2118 .unwrap_or(1),
2119 url: normalize_media_url(&url),
2120 backup_urls: normalize_media_urls([self.backup_url]),
2121 size: self.size,
2122 length_ms: self.length,
2123 })
2124 }
2125}
2126
2127#[derive(Debug, Deserialize)]
2128struct PlayerV2Data {
2129 subtitle: Option<PlayerSubtitle>,
2130}
2131
2132impl PlayerV2Data {
2133 fn into_subtitles(self) -> Vec<SubtitleTrack> {
2134 self.subtitle
2135 .map(PlayerSubtitle::into_subtitles)
2136 .unwrap_or_default()
2137 }
2138}
2139
2140#[derive(Debug, Deserialize)]
2141struct PlayerSubtitle {
2142 #[serde(default)]
2143 subtitles: Vec<SubtitleData>,
2144 #[serde(default)]
2145 list: Vec<SubtitleData>,
2146}
2147
2148impl PlayerSubtitle {
2149 fn into_subtitles(self) -> Vec<SubtitleTrack> {
2150 self.subtitles
2151 .into_iter()
2152 .chain(self.list)
2153 .filter_map(SubtitleData::into_subtitle)
2154 .collect()
2155 }
2156}
2157
2158#[derive(Debug, Deserialize)]
2159struct IntlSubtitleData {
2160 #[serde(default)]
2161 subtitles: Vec<SubtitleData>,
2162}
2163
2164impl IntlSubtitleData {
2165 fn into_subtitles(self) -> Vec<SubtitleTrack> {
2166 self.subtitles
2167 .into_iter()
2168 .filter_map(SubtitleData::into_subtitle)
2169 .collect()
2170 }
2171}
2172
2173#[derive(Debug, Deserialize)]
2174struct SubtitleData {
2175 #[serde(alias = "lang_key", alias = "key")]
2176 lan: Option<String>,
2177 #[serde(alias = "lang_doc")]
2178 lan_doc: Option<String>,
2179 #[serde(alias = "url")]
2180 subtitle_url: Option<String>,
2181}
2182
2183impl SubtitleData {
2184 fn into_subtitle(self) -> Option<SubtitleTrack> {
2185 let url = normalize_media_url(self.subtitle_url?.trim());
2186 if url.is_empty() {
2187 return None;
2188 }
2189 Some(SubtitleTrack {
2190 language: self.lan.unwrap_or_else(|| "und".to_owned()),
2191 language_doc: self.lan_doc,
2192 format: subtitle_format(&url),
2193 url,
2194 })
2195 }
2196}
2197
2198fn normalize_media_url(url: &str) -> String {
2199 if url.starts_with("//") {
2200 format!("https:{url}")
2201 } else {
2202 url.to_owned()
2203 }
2204}
2205
2206fn first_non_empty<const N: usize>(values: [Option<String>; N]) -> Option<String> {
2207 values.into_iter().flatten().find(|value| !value.is_empty())
2208}
2209
2210fn stream_qualities(
2211 accept_quality: &[u32],
2212 accept_description: &[String],
2213 support_formats: &[SupportFormat],
2214 videos: &[MediaStream],
2215) -> Vec<StreamQuality> {
2216 let mut qualities = Vec::new();
2217 for stream in videos {
2218 push_stream_quality(
2219 &mut qualities,
2220 stream.id,
2221 support_format_label(stream.id, support_formats)
2222 .or_else(|| accept_quality_label(stream.id, accept_quality, accept_description)),
2223 );
2224 }
2225 qualities
2226}
2227
2228fn accept_quality_label(
2229 id: u32,
2230 accept_quality: &[u32],
2231 accept_description: &[String],
2232) -> Option<String> {
2233 accept_quality
2234 .iter()
2235 .position(|quality| *quality == id)
2236 .and_then(|index| accept_description.get(index))
2237 .filter(|value| !value.is_empty())
2238 .cloned()
2239}
2240
2241fn support_format_label(id: u32, support_formats: &[SupportFormat]) -> Option<String> {
2242 support_formats
2243 .iter()
2244 .find(|format| format.quality == Some(id))
2245 .and_then(SupportFormat::label)
2246}
2247
2248fn push_stream_quality(qualities: &mut Vec<StreamQuality>, id: u32, description: Option<String>) {
2249 if qualities.iter().any(|quality| quality.id == id) {
2250 return;
2251 }
2252 qualities.push(StreamQuality { id, description });
2253}
2254
2255fn normalize_media_urls<const N: usize>(url_groups: [Option<Vec<String>>; N]) -> Vec<String> {
2256 url_groups
2257 .into_iter()
2258 .flatten()
2259 .flatten()
2260 .filter(|url| !url.is_empty())
2261 .map(|url| normalize_media_url(&url))
2262 .collect()
2263}
2264
2265fn subtitle_format(url: &str) -> SubtitleFormat {
2266 let path = Url::parse(url)
2267 .ok()
2268 .map_or_else(|| url.to_owned(), |url| url.path().to_owned());
2269 match std::path::Path::new(&path)
2270 .extension()
2271 .and_then(|extension| extension.to_str())
2272 {
2273 Some(extension) if extension.eq_ignore_ascii_case("json") => SubtitleFormat::Json,
2274 Some(extension) if extension.eq_ignore_ascii_case("ass") => SubtitleFormat::Ass,
2275 _ => SubtitleFormat::Unknown,
2276 }
2277}
2278
2279fn fallback_id(index: usize) -> u32 {
2280 u32::try_from(index).unwrap_or(u32::MAX)
2281}
2282
2283fn current_unix_timestamp() -> u64 {
2284 SystemTime::now()
2285 .duration_since(UNIX_EPOCH)
2286 .map_or(0, |duration| duration.as_secs())
2287}
2288
2289fn intl_ogv_playurl_params(
2290 epid: u64,
2291 cid: u64,
2292 access_key: Option<&str>,
2293 timestamp: u64,
2294) -> Vec<(&'static str, String)> {
2295 let mut params = Vec::new();
2296 if let Some(access_key) = access_key.filter(|value| !value.is_empty()) {
2297 params.push(("access_key", access_key.to_owned()));
2298 }
2299 params.extend([
2300 ("appkey", "7d089525d3611b1c".to_owned()),
2301 ("area", "th".to_owned()),
2302 ("build", "1001310".to_owned()),
2303 ("cid", cid.to_string()),
2304 ("ep_id", epid.to_string()),
2305 ("fnval", "4048".to_owned()),
2306 ("fnver", "0".to_owned()),
2307 ("force_host", "2".to_owned()),
2308 ("fourk", "1".to_owned()),
2309 ("mobi_app", "bstar_a".to_owned()),
2310 ("platform", "android".to_owned()),
2311 ("qn", "0".to_owned()),
2312 ("s_locale", "zh_SG".to_owned()),
2313 ("ts", timestamp.to_string()),
2314 ]);
2315 let sign = sign_ordered_params(¶ms, "acd495b248ec528c2eed1e862d393126");
2316 params.push(("sign", sign));
2317 params
2318}
2319
2320pub(crate) fn sign_ordered_params(params: &[(&str, String)], secret: &str) -> String {
2321 let mut plaintext = String::new();
2322 for (index, (key, value)) in params.iter().enumerate() {
2323 if index > 0 {
2324 plaintext.push('&');
2325 }
2326 plaintext.push_str(key);
2327 plaintext.push('=');
2328 plaintext.push_str(value);
2329 }
2330 plaintext.push_str(secret);
2331 format!("{:x}", Md5::digest(plaintext.as_bytes()))
2332}
2333
2334#[cfg(test)]
2335mod tests {
2336 use super::{
2337 BiliClient, ClientConfig, EndpointConfig, PlayUrlRoot, RestrictedArea,
2338 RestrictedAreaConfig, RestrictedAreaProxy, intl_ogv_playurl_params,
2339 };
2340 use crate::{
2341 Credentials, Error, Input, ResolvedContent, Selection, StreamSource, SubtitleFormat,
2342 };
2343 use httpmock::MockServer;
2344 use httpmock::prelude::*;
2345 use std::time::{Duration, Instant};
2346
2347 #[tokio::test]
2348 async fn resolves_video_metadata_with_tags() -> anyhow::Result<()> {
2349 let server = MockServer::start();
2350 server.mock(|when, then| {
2351 when.method(GET)
2352 .path("/x/web-interface/view")
2353 .query_param("aid", "170001");
2354 then.status(200).json_body_obj(&serde_json::json!({
2355 "code": 0,
2356 "data": {
2357 "aid": 170_001,
2358 "bvid": "BV1xx411c7mD",
2359 "title": "Example video",
2360 "desc": "Description",
2361 "pic": "https://example.invalid/cover.jpg",
2362 "pubdate": 1_700_000_000,
2363 "owner": {"mid": 42, "name": "Uploader"},
2364 "pages": [{"page": 1, "cid": 9988, "part": "P1", "duration": 123}]
2365 }
2366 }));
2367 });
2368 server.mock(|when, then| {
2369 when.method(GET)
2370 .path("/x/tag/archive/tags")
2371 .query_param("aid", "170001");
2372 then.status(200).json_body_obj(&serde_json::json!({
2373 "code": 0,
2374 "data": [{"tag_id": 1, "tag_name": "anime"}]
2375 }));
2376 });
2377
2378 let client = test_client(&server);
2379 let resolved = client.resolve_input("av170001", None).await?;
2380 match resolved {
2381 ResolvedContent::Video(video) => {
2382 assert_eq!(video.title, "Example video");
2383 assert_eq!(video.tags[0].name, "anime");
2384 assert_eq!(video.pages[0].cid, 9988);
2385 }
2386 ResolvedContent::Season(_) => return Err(anyhow::anyhow!("expected video")),
2387 }
2388 Ok(())
2389 }
2390
2391 #[tokio::test]
2392 async fn video_tag_failure_is_not_silent() -> anyhow::Result<()> {
2393 let server = MockServer::start();
2394 server.mock(|when, then| {
2395 when.method(GET)
2396 .path("/x/web-interface/view")
2397 .query_param("aid", "170001");
2398 then.status(200).json_body_obj(&serde_json::json!({
2399 "code": 0,
2400 "data": {
2401 "aid": 170_001,
2402 "bvid": "BV1xx411c7mD",
2403 "title": "Example video",
2404 "pages": [{"page": 1, "cid": 9988, "part": "P1"}]
2405 }
2406 }));
2407 });
2408 server.mock(|when, then| {
2409 when.method(GET)
2410 .path("/x/tag/archive/tags")
2411 .query_param("aid", "170001");
2412 then.status(200).json_body_obj(&serde_json::json!({
2413 "code": -101,
2414 "message": "login required"
2415 }));
2416 });
2417
2418 let client = test_client(&server);
2419 let Err(error) = client.resolve_input("av170001", None).await else {
2420 return Err(anyhow::anyhow!("tag API failure should propagate"));
2421 };
2422 assert!(matches!(error, Error::Api { code: -101, .. }));
2423 Ok(())
2424 }
2425
2426 #[tokio::test]
2427 async fn plan_video_ignores_tag_failure() -> anyhow::Result<()> {
2428 let server = MockServer::start();
2429 server.mock(|when, then| {
2430 when.method(GET)
2431 .path("/x/web-interface/view")
2432 .query_param("aid", "170001");
2433 then.status(200).json_body_obj(&serde_json::json!({
2434 "code": 0,
2435 "data": {
2436 "aid": 170_001,
2437 "bvid": "BV1xx411c7mD",
2438 "title": "Example video",
2439 "pages": [{"page": 1, "cid": 9988, "part": "P1"}]
2440 }
2441 }));
2442 });
2443 server.mock(|when, then| {
2444 when.method(GET)
2445 .path("/x/tag/archive/tags")
2446 .query_param("aid", "170001");
2447 then.status(200).json_body_obj(&serde_json::json!({
2448 "code": -101,
2449 "message": "login required"
2450 }));
2451 });
2452 server.mock(|when, then| {
2453 when.method(GET)
2454 .path("/x/player/playurl")
2455 .query_param("avid", "170001")
2456 .query_param("cid", "9988")
2457 .query_param("try_look", "1");
2458 then.status(200).json_body_obj(&serde_json::json!({
2459 "code": 0,
2460 "data": {
2461 "durl": [{"url": "https://video.example/segment.flv"}]
2462 }
2463 }));
2464 });
2465
2466 let client = test_client(&server);
2467 let plan = client.plan(Input::Aid(170_001), None).await?;
2468
2469 assert_eq!(plan.title, "Example video");
2470 assert_eq!(
2471 plan.entries[0].streams.flv_segments[0].url,
2472 "https://video.example/segment.flv"
2473 );
2474 Ok(())
2475 }
2476
2477 #[test]
2478 fn playurl_accepts_nullable_dash_lists_with_flv_fallback() -> anyhow::Result<()> {
2479 let response: PlayUrlRoot = serde_json::from_value(serde_json::json!({
2480 "code": 0,
2481 "data": {
2482 "dash": {
2483 "duration": 9,
2484 "video": null,
2485 "audio": null
2486 },
2487 "durl": [{
2488 "url": "https://flv.example/segment.flv",
2489 "backupUrl": null
2490 }]
2491 }
2492 }))?;
2493
2494 let streams = response.into_stream_set()?;
2495
2496 assert!(streams.videos.is_empty());
2497 assert!(streams.audios.is_empty());
2498 assert_eq!(
2499 streams.flv_segments[0].url,
2500 "https://flv.example/segment.flv"
2501 );
2502 Ok(())
2503 }
2504
2505 #[test]
2506 fn playurl_accepts_null_durl_with_dash_streams() -> anyhow::Result<()> {
2507 let response: PlayUrlRoot = serde_json::from_value(serde_json::json!({
2508 "code": 0,
2509 "data": {
2510 "dash": {
2511 "duration": 9,
2512 "video": [{
2513 "id": 80,
2514 "baseUrl": "https://video.example/80.m4s"
2515 }],
2516 "audio": [{
2517 "id": 30280,
2518 "baseUrl": "https://audio.example/30280.m4s"
2519 }]
2520 },
2521 "durl": null
2522 }
2523 }))?;
2524
2525 let streams = response.into_stream_set()?;
2526
2527 assert_eq!(streams.videos[0].id, 80);
2528 assert_eq!(streams.audios[0].id, 30280);
2529 assert!(streams.flv_segments.is_empty());
2530 Ok(())
2531 }
2532
2533 #[test]
2534 fn playurl_accepts_top_level_bpplayurl_flv_shape() -> anyhow::Result<()> {
2535 let response: PlayUrlRoot = serde_json::from_value(serde_json::json!({
2536 "code": 0,
2537 "result": "suee",
2538 "timelength": 42_000,
2539 "durl": [{
2540 "url": "//flv.example/segment.flv",
2541 "backupUrl": ["//flv-backup.example/segment.flv"],
2542 "length": 42_000
2543 }]
2544 }))?;
2545
2546 let streams = response.into_stream_set()?;
2547
2548 assert!(streams.videos.is_empty());
2549 assert!(streams.audios.is_empty());
2550 assert_eq!(streams.duration_seconds, Some(42));
2551 assert_eq!(
2552 streams.flv_segments[0].url,
2553 "https://flv.example/segment.flv"
2554 );
2555 assert_eq!(
2556 streams.flv_segments[0].backup_urls[0],
2557 "https://flv-backup.example/segment.flv"
2558 );
2559 Ok(())
2560 }
2561
2562 #[test]
2563 fn playurl_accepts_top_level_mobile_dash_shape() -> anyhow::Result<()> {
2564 let response: PlayUrlRoot = serde_json::from_value(serde_json::json!({
2565 "code": 0,
2566 "timelength": 123_000,
2567 "accept_quality": [80],
2568 "accept_description": ["1080P"],
2569 "support_formats": [{"quality": 80, "new_description": "1080P 高码率"}],
2570 "dash": {
2571 "duration": 123,
2572 "video": [{
2573 "id": 80,
2574 "baseUrl": "//video.example/80.m4s",
2575 "codecs": "avc1.640028",
2576 "width": 1920,
2577 "height": 1080
2578 }],
2579 "audio": [{
2580 "id": 30280,
2581 "baseUrl": "//audio.example/30280.m4s",
2582 "codecs": "mp4a.40.2"
2583 }]
2584 }
2585 }))?;
2586
2587 let streams = response.into_stream_set()?;
2588
2589 assert_eq!(streams.videos[0].id, 80);
2590 assert_eq!(streams.audios[0].id, 30280);
2591 assert_eq!(streams.accept_quality, vec![80]);
2592 assert_eq!(streams.qualities[0].id, 80);
2593 assert_eq!(
2594 streams.qualities[0].description.as_deref(),
2595 Some("1080P 高码率")
2596 );
2597 assert_eq!(streams.duration_seconds, Some(123));
2598 Ok(())
2599 }
2600
2601 #[test]
2602 fn playurl_uses_non_empty_camel_media_fields_when_snake_fields_are_empty() -> anyhow::Result<()>
2603 {
2604 let response: PlayUrlRoot = serde_json::from_value(serde_json::json!({
2605 "code": 0,
2606 "data": {
2607 "dash": {
2608 "duration": 9,
2609 "video": [{
2610 "id": 80,
2611 "base_url": "",
2612 "baseUrl": "//video.example/80.m4s",
2613 "backup_url": [],
2614 "backupUrl": ["//backup.example/80.m4s"],
2615 "frame_rate": "",
2616 "frameRate": "30",
2617 "mime_type": "",
2618 "mimeType": "video/mp4"
2619 }],
2620 "audio": [{
2621 "id": 30280,
2622 "base_url": "",
2623 "baseUrl": "//audio.example/30280.m4s"
2624 }]
2625 }
2626 }
2627 }))?;
2628
2629 let streams = response.into_stream_set()?;
2630
2631 assert_eq!(streams.videos[0].base_url, "https://video.example/80.m4s");
2632 assert_eq!(
2633 streams.videos[0].backup_urls,
2634 vec!["https://backup.example/80.m4s"]
2635 );
2636 assert_eq!(streams.videos[0].frame_rate.as_deref(), Some("30"));
2637 assert_eq!(streams.videos[0].mime_type.as_deref(), Some("video/mp4"));
2638 Ok(())
2639 }
2640
2641 #[test]
2642 fn playurl_accepts_intl_mobile_video_info_shape() -> anyhow::Result<()> {
2643 let response: PlayUrlRoot = serde_json::from_value(serde_json::json!({
2644 "code": 0,
2645 "data": {
2646 "video_info": {
2647 "timelength": 42_000,
2648 "stream_list": [{
2649 "stream_info": {"quality": 80},
2650 "dash_video": {
2651 "base_url": "https://intl.example/video.m4s",
2652 "bandwidth": 900,
2653 "codecs": "avc1",
2654 "width": 1280,
2655 "height": 720
2656 }
2657 }],
2658 "dash_audio": [{
2659 "id": 30280,
2660 "base_url": "https://intl.example/audio.m4s",
2661 "bandwidth": 128_000,
2662 "codecs": "mp4a.40.2"
2663 }]
2664 }
2665 }
2666 }))?;
2667
2668 let streams = response.into_stream_set()?;
2669
2670 assert_eq!(streams.duration_seconds, Some(42));
2671 assert_eq!(streams.videos[0].id, 80);
2672 assert_eq!(streams.audios[0].id, 30280);
2673 Ok(())
2674 }
2675
2676 #[test]
2677 fn intl_ogv_playurl_params_are_signed_in_helper_order() {
2678 let params = intl_ogv_playurl_params(341_736, 70, Some("intl-token"), 1_234_567_890);
2679
2680 assert_eq!(
2681 params,
2682 vec![
2683 ("access_key", "intl-token".to_owned()),
2684 ("appkey", "7d089525d3611b1c".to_owned()),
2685 ("area", "th".to_owned()),
2686 ("build", "1001310".to_owned()),
2687 ("cid", "70".to_owned()),
2688 ("ep_id", "341736".to_owned()),
2689 ("fnval", "4048".to_owned()),
2690 ("fnver", "0".to_owned()),
2691 ("force_host", "2".to_owned()),
2692 ("fourk", "1".to_owned()),
2693 ("mobi_app", "bstar_a".to_owned()),
2694 ("platform", "android".to_owned()),
2695 ("qn", "0".to_owned()),
2696 ("s_locale", "zh_SG".to_owned()),
2697 ("ts", "1234567890".to_owned()),
2698 ("sign", "8b320aef0eac5b957c3cab1f98bb9b6d".to_owned()),
2699 ]
2700 );
2701 }
2702
2703 #[tokio::test]
2704 #[allow(clippy::too_many_lines)]
2705 async fn plans_video_download_with_streams_subtitles_and_danmaku() -> anyhow::Result<()> {
2706 let server = MockServer::start();
2707 server.mock(|when, then| {
2708 when.method(GET)
2709 .path("/x/web-interface/view")
2710 .query_param("aid", "170001");
2711 then.status(200).json_body_obj(&serde_json::json!({
2712 "code": 0,
2713 "data": {
2714 "aid": 170_001,
2715 "bvid": "BV1xx411c7mD",
2716 "title": "Example video",
2717 "pages": [{"page": 1, "cid": 9988, "part": "P1"}]
2718 }
2719 }));
2720 });
2721 server.mock(|when, then| {
2722 when.method(GET)
2723 .path("/x/tag/archive/tags")
2724 .query_param("aid", "170001");
2725 then.status(200).json_body_obj(&serde_json::json!({
2726 "code": 0,
2727 "data": []
2728 }));
2729 });
2730 server.mock(|when, then| {
2731 when.method(GET)
2732 .path("/x/player/playurl")
2733 .query_param("avid", "170001")
2734 .query_param("cid", "9988")
2735 .query_param("try_look", "1");
2736 then.status(200).json_body_obj(&serde_json::json!({
2737 "code": 0,
2738 "data": {
2739 "timelength": 123_000,
2740 "accept_quality": [80, 64],
2741 "accept_description": ["1080P", "720P"],
2742 "support_formats": [
2743 {"quality": 80, "new_description": "1080P 高码率"},
2744 {"quality": 64, "display_desc": "720P"}
2745 ],
2746 "dash": {
2747 "duration": 123,
2748 "video": [{
2749 "id": 80,
2750 "baseUrl": "//video.example/80.m4s",
2751 "base_url": "//video.example/80.m4s",
2752 "backupUrl": ["//backup.example/80.m4s"],
2753 "backup_url": ["//backup.example/80.m4s"],
2754 "codecs": "avc1.640028",
2755 "bandwidth": 1000,
2756 "width": 1920,
2757 "height": 1080,
2758 "frameRate": "30",
2759 "frame_rate": "30"
2760 }],
2761 "audio": [{
2762 "id": 30280,
2763 "baseUrl": "//audio.example/30280.m4s",
2764 "base_url": "//audio.example/30280.m4s",
2765 "codecs": "mp4a.40.2",
2766 "bandwidth": 128_000
2767 }],
2768 "dolby": {"audio": null}
2769 },
2770 "durl": [{
2771 "url": "//flv.example/segment.flv",
2772 "backup_url": ["//flv-backup.example/segment.flv"],
2773 "length": 123_000
2774 }]
2775 }
2776 }));
2777 });
2778 server.mock(|when, then| {
2779 when.method(GET)
2780 .path("/x/player/v2")
2781 .query_param("aid", "170001")
2782 .query_param("cid", "9988");
2783 then.status(200).json_body_obj(&serde_json::json!({
2784 "code": 0,
2785 "data": {
2786 "subtitle": {
2787 "subtitles": [{
2788 "lan": "zh-CN",
2789 "lan_doc": "中文(简体)",
2790 "subtitle_url": "//subtitle.example/zh.json"
2791 }]
2792 }
2793 }
2794 }));
2795 });
2796
2797 let client = test_client(&server);
2798 let plan = client.plan_download("av170001", None).await?;
2799
2800 assert_eq!(plan.title, "Example video");
2801 assert_eq!(plan.entries.len(), 1);
2802 let entry = &plan.entries[0];
2803 assert_eq!(entry.source, StreamSource::NormalWeb);
2804 assert_eq!(
2805 entry.streams.videos[0].base_url,
2806 "https://video.example/80.m4s"
2807 );
2808 assert_eq!(
2809 entry.streams.videos[0].backup_urls[0],
2810 "https://backup.example/80.m4s"
2811 );
2812 assert_eq!(entry.streams.audios[0].id, 30280);
2813 assert_eq!(
2814 entry.streams.audios[0].base_url,
2815 "https://audio.example/30280.m4s"
2816 );
2817 assert_eq!(entry.streams.flv_segments[0].order, 1);
2818 assert_eq!(
2819 entry.streams.flv_segments[0].url,
2820 "https://flv.example/segment.flv"
2821 );
2822 assert_eq!(
2823 entry.streams.flv_segments[0].backup_urls[0],
2824 "https://flv-backup.example/segment.flv"
2825 );
2826 assert_eq!(entry.streams.duration_seconds, Some(123));
2827 assert_eq!(entry.streams.accept_quality, vec![80, 64]);
2828 assert_eq!(entry.streams.qualities.len(), 1);
2829 assert_eq!(entry.streams.qualities[0].id, 80);
2830 assert_eq!(
2831 entry.streams.qualities[0].description.as_deref(),
2832 Some("1080P 高码率")
2833 );
2834 assert_eq!(entry.subtitles[0].url, "https://subtitle.example/zh.json");
2835 assert_eq!(
2836 entry.danmaku.xml_url,
2837 format!("{}/9988.xml", server.base_url())
2838 );
2839 Ok(())
2840 }
2841
2842 #[tokio::test]
2843 async fn intl_access_key_is_redacted_from_http_errors() -> anyhow::Result<()> {
2844 let server = MockServer::start();
2845 let client = BiliClient::new(ClientConfig {
2846 endpoints: EndpointConfig {
2847 api_base: server.base_url(),
2848 pgc_base: server.base_url(),
2849 intl_base: server.base_url(),
2850 comment_base: server.base_url(),
2851 passport_base: server.base_url(),
2852 tv_passport_base: server.base_url(),
2853 tv_passport_poll_base: server.base_url(),
2854 },
2855 credentials: Credentials {
2856 cookie: None,
2857 access_key: Some("TOKEN_SHOULD_REDACT_12345".to_owned()),
2858 tv_access_key: None,
2859 },
2860 restricted_area: RestrictedAreaConfig::default(),
2861 user_agent: "test".to_owned(),
2862 request_timeout: Duration::from_secs(30),
2863 });
2864
2865 let Err(error) = client
2866 .resolve_input("https://www.bilibili.tv/en/play/34613/341736", None)
2867 .await
2868 else {
2869 return Err(anyhow::anyhow!("HTTP status failure should propagate"));
2870 };
2871 let debug = format!("{error:?}");
2872 assert!(!debug.contains("TOKEN_SHOULD_REDACT_12345"));
2873 assert!(!debug.contains("access_key"));
2874 Ok(())
2875 }
2876
2877 #[tokio::test]
2878 async fn resolves_intl_episode_from_module_episodes() -> anyhow::Result<()> {
2879 let server = MockServer::start();
2880 server.mock(|when, then| {
2881 when.method(GET)
2882 .path("/intl/gateway/v2/ogv/view/app/season")
2883 .query_param("ep_id", "341736");
2884 then.status(200).json_body_obj(&serde_json::json!({
2885 "code": 0,
2886 "result": {
2887 "season_id": 34613,
2888 "title": "Intl Season",
2889 "modules": [{
2890 "data": {
2891 "episodes": [
2892 {"aid": 7, "cid": 70, "episode_id": 341_736, "title": "1", "long_title": "Start"}
2893 ]
2894 }
2895 }, {
2896 "data": {
2897 "episodes": [
2898 {"aid": 8, "cid": 80, "id": 341_737, "title": "2", "long_title": "Wrong module"}
2899 ]
2900 }
2901 }],
2902 "areas": [{"name": "Thailand"}],
2903 "styles": [{"name": "Anime"}]
2904 }
2905 }));
2906 });
2907
2908 let client = test_client(&server);
2909 let resolved = client
2910 .resolve_input(
2911 "https://www.bilibili.tv/en/play/34613/341736",
2912 Some(Selection::Latest),
2913 )
2914 .await?;
2915 match resolved {
2916 ResolvedContent::Season(season) => {
2917 assert_eq!(season.season.title, "Intl Season");
2918 assert_eq!(season.season.episodes.len(), 1);
2919 assert_eq!(season.selected_episodes[0].epid, 341_736);
2920 }
2921 ResolvedContent::Video(_) => return Err(anyhow::anyhow!("expected season")),
2922 }
2923 Ok(())
2924 }
2925
2926 #[tokio::test]
2927 async fn intl_region_limit_returns_access_restricted_error() -> anyhow::Result<()> {
2928 let server = MockServer::start();
2929 server.mock(|when, then| {
2930 when.method(GET)
2931 .path("/intl/gateway/v2/ogv/view/app/season")
2932 .query_param("ep_id", "341736");
2933 then.status(200).json_body_obj(&serde_json::json!({
2934 "code": 0,
2935 "result": {
2936 "season_id": 34613,
2937 "title": "Intl Season",
2938 "status": 13,
2939 "limit": {
2940 "content": "Sorry, this content is not available in your region"
2941 },
2942 "modules": []
2943 }
2944 }));
2945 });
2946
2947 let client = test_client(&server);
2948 let Err(error) = client
2949 .resolve_input("https://www.bilibili.tv/en/play/34613/341736", None)
2950 .await
2951 else {
2952 return Err(anyhow::anyhow!("region-limited intl season should fail"));
2953 };
2954
2955 match error {
2956 Error::AccessRestricted(message) => {
2957 assert!(message.contains("not available in your region"));
2958 }
2959 other => {
2960 return Err(anyhow::anyhow!(
2961 "expected access restriction, got {other:?}"
2962 ));
2963 }
2964 }
2965 Ok(())
2966 }
2967
2968 #[tokio::test]
2969 #[allow(clippy::too_many_lines)]
2970 async fn plans_intl_download_with_access_key_for_subtitles() -> anyhow::Result<()> {
2971 let server = MockServer::start();
2972 server.mock(|when, then| {
2973 when.method(GET)
2974 .path("/intl/gateway/v2/ogv/view/app/season")
2975 .query_param("ep_id", "341736")
2976 .query_param("access_key", "intl-token");
2977 then.status(200).json_body_obj(&serde_json::json!({
2978 "code": 0,
2979 "result": {
2980 "season_id": 34613,
2981 "title": "Intl Season",
2982 "modules": [{
2983 "data": {
2984 "episodes": [
2985 {"aid": 7, "cid": 70, "id": 341_736, "title": "1", "long_title": "Start"}
2986 ]
2987 }
2988 }]
2989 }
2990 }));
2991 });
2992 server.mock(|when, then| {
2993 when.method(GET)
2994 .path("/intl/gateway/v2/ogv/playurl")
2995 .query_param("ep_id", "341736")
2996 .query_param("cid", "70")
2997 .query_param("platform", "android")
2998 .query_param("mobi_app", "bstar_a")
2999 .query_param("area", "th")
3000 .query_param("s_locale", "zh_SG")
3001 .query_param("access_key", "intl-token")
3002 .query_param_exists("sign");
3003 then.status(200).json_body_obj(&serde_json::json!({
3004 "code": 0,
3005 "data": {
3006 "video_info": {
3007 "timelength": 42_000,
3008 "stream_list": [{
3009 "stream_info": {"quality": 80},
3010 "dash_video": {
3011 "base_url": "https://intl.example/video.m4s",
3012 "width": 1280,
3013 "height": 720,
3014 "bandwidth": 900,
3015 "codecs": "avc1"
3016 }
3017 }],
3018 "dash_audio": [{
3019 "id": 30280,
3020 "base_url": "https://intl.example/audio.m4s",
3021 "bandwidth": 128_000,
3022 "codecs": "mp4a.40.2"
3023 }]
3024 }
3025 }
3026 }));
3027 });
3028 server.mock(|when, then| {
3029 when.method(GET)
3030 .path("/intl/gateway/web/v2/subtitle")
3031 .query_param("episode_id", "341736")
3032 .query_param("platform", "web")
3033 .query_param("s_locale", "en_US")
3034 .query_param("access_key", "intl-token");
3035 then.status(200).json_body_obj(&serde_json::json!({
3036 "code": 0,
3037 "data": {
3038 "subtitles": [{
3039 "lang_key": "en",
3040 "lang_doc": "English",
3041 "url": "https://subtitle.example/en.ass"
3042 }]
3043 }
3044 }));
3045 });
3046
3047 let client = BiliClient::new(ClientConfig {
3048 endpoints: EndpointConfig {
3049 api_base: server.base_url(),
3050 pgc_base: server.base_url(),
3051 intl_base: server.base_url(),
3052 comment_base: server.base_url(),
3053 passport_base: server.base_url(),
3054 tv_passport_base: server.base_url(),
3055 tv_passport_poll_base: server.base_url(),
3056 },
3057 credentials: Credentials {
3058 cookie: None,
3059 access_key: Some("intl-token".to_owned()),
3060 tv_access_key: None,
3061 },
3062 restricted_area: RestrictedAreaConfig::default(),
3063 user_agent: "test".to_owned(),
3064 request_timeout: Duration::from_secs(30),
3065 });
3066 let plan = client
3067 .plan_download("https://www.bilibili.tv/en/play/34613/341736", None)
3068 .await?;
3069
3070 assert_eq!(plan.entries[0].source, StreamSource::IntlWeb);
3071 assert_eq!(plan.entries[0].subtitles[0].language, "en");
3072 assert_eq!(plan.entries[0].subtitles[0].format, SubtitleFormat::Ass);
3073 Ok(())
3074 }
3075
3076 #[tokio::test]
3077 async fn plans_pgc_download_from_video_info_payload() -> anyhow::Result<()> {
3078 let server = MockServer::start();
3079 server.mock(|when, then| {
3080 when.method(GET)
3081 .path("/pgc/view/web/season")
3082 .query_param("ep_id", "1000");
3083 then.status(200).json_body_obj(&serde_json::json!({
3084 "code": 0,
3085 "result": {
3086 "season_id": 123,
3087 "title": "A Season",
3088 "episodes": [
3089 {"aid": 10, "bvid": "BV1aa", "cid": 100, "id": 1000, "ep_id": 1000, "title": "1", "long_title": "Start"}
3090 ]
3091 }
3092 }));
3093 });
3094 server.mock(|when, then| {
3095 when.method(GET)
3096 .path("/pgc/player/web/v2/playurl")
3097 .query_param("avid", "10")
3098 .query_param("cid", "100")
3099 .query_param("ep_id", "1000")
3100 .query_param("module", "bangumi");
3101 then.status(200).json_body_obj(&serde_json::json!({
3102 "code": 0,
3103 "result": {
3104 "video_info": {
3105 "dash": {
3106 "duration": 456,
3107 "video": [{
3108 "id": 64,
3109 "baseUrl": "https://video.example/64.m4s",
3110 "base_url": "https://video.example/64.m4s",
3111 "backupUrl": [],
3112 "backup_url": [],
3113 "codecs": "hev1",
3114 "bandwidth": 900,
3115 "mimeType": "video/mp4",
3116 "mime_type": "video/mp4"
3117 }],
3118 "audio": []
3119 }
3120 }
3121 }
3122 }));
3123 });
3124
3125 let client = test_client(&server);
3126 let plan = client.plan_download("ep1000", None).await?;
3127
3128 assert_eq!(plan.entries.len(), 1);
3129 let entry = &plan.entries[0];
3130 assert_eq!(entry.source, StreamSource::PgcWeb);
3131 assert_eq!(entry.epid, Some(1000));
3132 assert_eq!(entry.title, "1 Start");
3133 assert_eq!(entry.streams.videos[0].id, 64);
3134 assert_eq!(
3135 entry.streams.videos[0].mime_type.as_deref(),
3136 Some("video/mp4")
3137 );
3138 Ok(())
3139 }
3140
3141 #[test]
3142 fn endpoint_client_and_restricted_area_builders_configure_embedding_inputs() {
3143 let endpoints = EndpointConfig::default()
3144 .with_api_base("https://api.test")
3145 .with_pgc_base("https://pgc.test")
3146 .with_intl_base("https://intl.test")
3147 .with_comment_base("https://comment.test")
3148 .with_passport_base("https://passport.test")
3149 .with_tv_passport_base("https://tv-passport.test")
3150 .with_tv_passport_poll_base("https://tv-poll.test");
3151 let restricted_area = RestrictedAreaConfig::default()
3152 .with_area_hint(RestrictedArea::Tw)
3153 .with_proxy(RestrictedAreaProxy::playurl(
3154 "https://generic.example/playurl",
3155 None,
3156 ))
3157 .with_proxies([RestrictedAreaProxy::bilibili_api(
3158 "https://tw.example/api",
3159 Some(RestrictedArea::Tw),
3160 )]);
3161 let credentials = Credentials::default()
3162 .with_cookie("SESSDATA=redacted")
3163 .with_access_key("access-key");
3164
3165 let config = ClientConfig::default()
3166 .with_endpoints(endpoints)
3167 .with_credentials(credentials)
3168 .with_restricted_area(restricted_area)
3169 .with_user_agent("embedding-test/1.0")
3170 .with_request_timeout(Duration::from_secs(7));
3171
3172 assert_eq!(config.endpoints.api_base, "https://api.test");
3173 assert_eq!(
3174 config.endpoints.tv_passport_poll_base,
3175 "https://tv-poll.test"
3176 );
3177 assert_eq!(config.credentials.access_key.as_deref(), Some("access-key"));
3178 assert_eq!(config.restricted_area.area_hint, Some(RestrictedArea::Tw));
3179 assert_eq!(config.restricted_area.proxies.len(), 2);
3180 assert_eq!(config.user_agent, "embedding-test/1.0");
3181 assert_eq!(config.request_timeout, Duration::from_secs(7));
3182 }
3183
3184 #[test]
3185 fn restricted_area_proxies_are_ordered_by_hint_then_generic_then_area() {
3186 let config = RestrictedAreaConfig::new(
3187 Some(RestrictedArea::Hk),
3188 [
3189 RestrictedAreaProxy::playurl("https://generic.example/playurl", None),
3190 RestrictedAreaProxy::bilibili_api(
3191 "https://tw.example/api",
3192 Some(RestrictedArea::Tw),
3193 ),
3194 RestrictedAreaProxy::bilibili_api(
3195 "https://hk.example/api",
3196 Some(RestrictedArea::Hk),
3197 ),
3198 ],
3199 );
3200
3201 let ordered = config.ordered_proxies();
3202 assert_eq!(ordered[0].area, Some(RestrictedArea::Hk));
3203 assert_eq!(ordered[1].area, None);
3204 assert_eq!(ordered[2].area, Some(RestrictedArea::Tw));
3205 }
3206
3207 #[test]
3208 fn restricted_area_message_accepts_common_chinese_unavailable_phrasing() {
3209 for message in [
3210 "您所在地区不可观看",
3211 "所在地区无法观看",
3212 "您所在地區不可觀看",
3213 "您所在的地区无法观看",
3214 "您所在的地區無法觀看",
3215 ] {
3216 assert!(super::is_restricted_area_message(message));
3217 }
3218 }
3219
3220 #[tokio::test]
3221 async fn pgc_streams_fall_back_to_restricted_area_proxy() -> anyhow::Result<()> {
3222 let server = MockServer::start();
3223 server.mock(|when, then| {
3224 when.method(GET)
3225 .path("/pgc/view/web/season")
3226 .query_param("ep_id", "1000");
3227 then.status(200).json_body_obj(&serde_json::json!({
3228 "code": 0,
3229 "result": {
3230 "season_id": 123,
3231 "title": "A Season",
3232 "episodes": [
3233 {"aid": 10, "bvid": "BV1aa", "cid": 100, "id": 1000, "ep_id": 1000, "title": "1", "long_title": "Start"}
3234 ]
3235 }
3236 }));
3237 });
3238 server.mock(|when, then| {
3239 when.method(GET)
3240 .path("/pgc/player/web/v2/playurl")
3241 .query_param("ep_id", "1000");
3242 then.status(200).json_body_obj(&serde_json::json!({
3243 "code": -40301,
3244 "message": "您所在地区不可观看"
3245 }));
3246 });
3247 server.mock(|when, then| {
3248 when.method(GET)
3249 .path("/t/PATH_SECRET/proxy-playurl")
3250 .query_param("proxy_token", "PROXY_SECRET")
3251 .query_param("ep_id", "1000")
3252 .query_param("area", "hk")
3253 .query_param("access_key", "ACCESS_SECRET")
3254 .header_missing("cookie");
3255 then.status(200).json_body_obj(&serde_json::json!({
3256 "code": 0,
3257 "result": {
3258 "video_info": {
3259 "dash": {
3260 "duration": 456,
3261 "video": [{
3262 "id": 64,
3263 "baseUrl": "https://proxy.example/64.m4s",
3264 "base_url": "https://proxy.example/64.m4s"
3265 }],
3266 "audio": []
3267 }
3268 }
3269 }
3270 }));
3271 });
3272
3273 let client = BiliClient::new(ClientConfig {
3274 endpoints: EndpointConfig {
3275 api_base: server.base_url(),
3276 pgc_base: server.base_url(),
3277 intl_base: server.base_url(),
3278 comment_base: server.base_url(),
3279 passport_base: server.base_url(),
3280 tv_passport_base: server.base_url(),
3281 tv_passport_poll_base: server.base_url(),
3282 },
3283 credentials: Credentials {
3284 cookie: Some("SESSDATA=COOKIE_SECRET".to_owned()),
3285 access_key: Some("ACCESS_SECRET".to_owned()),
3286 tv_access_key: None,
3287 },
3288 restricted_area: RestrictedAreaConfig {
3289 area_hint: Some(RestrictedArea::Hk),
3290 proxies: vec![RestrictedAreaProxy::playurl(
3291 format!(
3292 "{}/t/PATH_SECRET/proxy-playurl?proxy_token=PROXY_SECRET",
3293 server.base_url()
3294 ),
3295 Some(RestrictedArea::Hk),
3296 )],
3297 },
3298 user_agent: "test".to_owned(),
3299 request_timeout: Duration::from_secs(30),
3300 });
3301 let plan = client.plan_download("ep1000", None).await?;
3302
3303 let entry = &plan.entries[0];
3304 assert_eq!(entry.source, StreamSource::PgcProxy);
3305 assert_eq!(
3306 entry.streams.videos[0].base_url,
3307 "https://proxy.example/64.m4s"
3308 );
3309 assert_eq!(entry.diagnostics.attempts.len(), 2);
3310 assert_eq!(entry.diagnostics.attempts[0].source, StreamSource::PgcWeb);
3311 assert_eq!(entry.diagnostics.attempts[1].area.as_deref(), Some("hk"));
3312 let diagnostics = serde_json::to_string(&entry.diagnostics)?;
3313 assert!(!diagnostics.contains("ACCESS_SECRET"));
3314 assert!(!diagnostics.contains("COOKIE_SECRET"));
3315 assert!(!diagnostics.contains("PATH_SECRET"));
3316 assert!(!diagnostics.contains("PROXY_SECRET"));
3317 assert!(!diagnostics.contains("access_key"));
3318 Ok(())
3319 }
3320
3321 #[tokio::test]
3322 async fn pgc_proxy_invalid_candidate_diagnostics_redact_endpoint() -> anyhow::Result<()> {
3323 let server = MockServer::start();
3324 server.mock(|when, then| {
3325 when.method(GET)
3326 .path("/pgc/view/web/season")
3327 .query_param("ep_id", "1000");
3328 then.status(200).json_body_obj(&serde_json::json!({
3329 "code": 0,
3330 "result": {
3331 "season_id": 123,
3332 "title": "A Season",
3333 "episodes": [
3334 {"aid": 10, "bvid": "BV1aa", "cid": 100, "id": 1000, "ep_id": 1000, "title": "1"}
3335 ]
3336 }
3337 }));
3338 });
3339 server.mock(|when, then| {
3340 when.method(GET)
3341 .path("/pgc/player/web/v2/playurl")
3342 .query_param("ep_id", "1000");
3343 then.status(200).json_body_obj(&serde_json::json!({
3344 "code": -40301,
3345 "message": "area restricted"
3346 }));
3347 });
3348 server.mock(|when, then| {
3349 when.method(GET)
3350 .path("/proxy-playurl")
3351 .query_param("ep_id", "1000")
3352 .header_missing("cookie");
3353 then.status(200).json_body_obj(&serde_json::json!({
3354 "code": 0,
3355 "result": {
3356 "video_info": {
3357 "dash": {
3358 "video": [{
3359 "id": 64,
3360 "baseUrl": "https://proxy.example/64.m4s",
3361 "base_url": "https://proxy.example/64.m4s"
3362 }],
3363 "audio": []
3364 }
3365 }
3366 }
3367 }));
3368 });
3369
3370 let client = BiliClient::new(ClientConfig {
3371 endpoints: EndpointConfig {
3372 api_base: server.base_url(),
3373 pgc_base: server.base_url(),
3374 intl_base: server.base_url(),
3375 comment_base: server.base_url(),
3376 passport_base: server.base_url(),
3377 tv_passport_base: server.base_url(),
3378 tv_passport_poll_base: server.base_url(),
3379 },
3380 credentials: Credentials::default(),
3381 restricted_area: RestrictedAreaConfig {
3382 area_hint: Some(RestrictedArea::Hk),
3383 proxies: vec![
3384 RestrictedAreaProxy::playurl(
3385 "https://user:pass@proxy.example:bad/t/PATH_SECRET?proxy_token=PROXY_SECRET",
3386 Some(RestrictedArea::Hk),
3387 ),
3388 RestrictedAreaProxy::playurl(
3389 format!("{}/proxy-playurl", server.base_url()),
3390 Some(RestrictedArea::Hk),
3391 ),
3392 ],
3393 },
3394 user_agent: "test".to_owned(),
3395 request_timeout: Duration::from_secs(30),
3396 });
3397
3398 let plan = client.plan_download("ep1000", None).await?;
3399 let diagnostics = serde_json::to_string(&plan.entries[0].diagnostics)?;
3400
3401 assert!(diagnostics.contains("https://proxy.example:bad"));
3402 for sensitive in ["user:pass", "PATH_SECRET", "PROXY_SECRET", "proxy_token"] {
3403 assert!(
3404 !diagnostics.contains(sensitive),
3405 "diagnostics leaked {sensitive}: {diagnostics}"
3406 );
3407 }
3408 Ok(())
3409 }
3410
3411 #[tokio::test]
3412 async fn pgc_proxy_fallback_requires_area_restriction() -> anyhow::Result<()> {
3413 for code in [403_i64, -40301_i64] {
3414 let server = MockServer::start();
3415 server.mock(|when, then| {
3416 when.method(GET)
3417 .path("/pgc/view/web/season")
3418 .query_param("ep_id", "1000");
3419 then.status(200).json_body_obj(&serde_json::json!({
3420 "code": 0,
3421 "result": {
3422 "season_id": 123,
3423 "title": "A Season",
3424 "episodes": [
3425 {"aid": 10, "bvid": "BV1aa", "cid": 100, "id": 1000, "ep_id": 1000, "title": "1"}
3426 ]
3427 }
3428 }));
3429 });
3430 server.mock(|when, then| {
3431 when.method(GET)
3432 .path("/pgc/player/web/v2/playurl")
3433 .query_param("ep_id", "1000");
3434 then.status(200).json_body_obj(&serde_json::json!({
3435 "code": code,
3436 "message": "vip required"
3437 }));
3438 });
3439 server.mock(|when, then| {
3440 when.method(GET).path("/proxy-playurl");
3441 then.status(200).json_body_obj(&serde_json::json!({
3442 "code": 0,
3443 "result": {
3444 "video_info": {
3445 "dash": {
3446 "video": [{
3447 "id": 64,
3448 "baseUrl": "https://proxy.example/64.m4s",
3449 "base_url": "https://proxy.example/64.m4s"
3450 }],
3451 "audio": []
3452 }
3453 }
3454 }
3455 }));
3456 });
3457
3458 let client = BiliClient::new(ClientConfig {
3459 endpoints: EndpointConfig {
3460 api_base: server.base_url(),
3461 pgc_base: server.base_url(),
3462 intl_base: server.base_url(),
3463 comment_base: server.base_url(),
3464 passport_base: server.base_url(),
3465 tv_passport_base: server.base_url(),
3466 tv_passport_poll_base: server.base_url(),
3467 },
3468 credentials: Credentials {
3469 cookie: None,
3470 access_key: Some("ACCESS_SECRET".to_owned()),
3471 tv_access_key: None,
3472 },
3473 restricted_area: RestrictedAreaConfig {
3474 area_hint: Some(RestrictedArea::Hk),
3475 proxies: vec![RestrictedAreaProxy::playurl(
3476 format!("{}/proxy-playurl", server.base_url()),
3477 Some(RestrictedArea::Hk),
3478 )],
3479 },
3480 user_agent: "test".to_owned(),
3481 request_timeout: Duration::from_secs(30),
3482 });
3483
3484 let Err(error) = client.plan_download("ep1000", None).await else {
3485 return Err(anyhow::anyhow!("non-area PGC error should not use proxy"));
3486 };
3487 assert!(matches!(error, Error::Api { code: error_code, .. } if error_code == code));
3488 }
3489 Ok(())
3490 }
3491
3492 #[tokio::test]
3493 async fn pgc_restricted_area_failure_redacts_sensitive_messages() -> anyhow::Result<()> {
3494 let server = MockServer::start();
3495 server.mock(|when, then| {
3496 when.method(GET)
3497 .path("/pgc/view/web/season")
3498 .query_param("ep_id", "1000");
3499 then.status(200).json_body_obj(&serde_json::json!({
3500 "code": 0,
3501 "result": {
3502 "season_id": 123,
3503 "title": "A Season",
3504 "episodes": [
3505 {"aid": 10, "bvid": "BV1aa", "cid": 100, "id": 1000, "ep_id": 1000, "title": "1"}
3506 ]
3507 }
3508 }));
3509 });
3510 server.mock(|when, then| {
3511 when.method(GET)
3512 .path("/pgc/player/web/v2/playurl")
3513 .query_param("ep_id", "1000");
3514 then.status(200).json_body_obj(&serde_json::json!({
3515 "code": -40301,
3516 "message": "area restricted access_key=OFFICIAL_SECRET"
3517 }));
3518 });
3519 server.mock(|when, then| {
3520 when.method(GET)
3521 .path("/proxy-playurl")
3522 .query_param("ep_id", "1000")
3523 .query_param("access_key", "ACCESS_SECRET")
3524 .header_missing("cookie");
3525 then.status(200).json_body_obj(&serde_json::json!({
3526 "code": -40301,
3527 "message": "proxy rejected https://user:pass@proxy.example/api?proxy_token=PROXY_SECRET&access_key=ACCESS_SECRET cookie=SESSDATA=COOKIE_SECRET"
3528 }));
3529 });
3530
3531 let client = BiliClient::new(ClientConfig {
3532 endpoints: EndpointConfig {
3533 api_base: server.base_url(),
3534 pgc_base: server.base_url(),
3535 intl_base: server.base_url(),
3536 comment_base: server.base_url(),
3537 passport_base: server.base_url(),
3538 tv_passport_base: server.base_url(),
3539 tv_passport_poll_base: server.base_url(),
3540 },
3541 credentials: Credentials {
3542 cookie: Some("SESSDATA=COOKIE_SECRET".to_owned()),
3543 access_key: Some("ACCESS_SECRET".to_owned()),
3544 tv_access_key: None,
3545 },
3546 restricted_area: RestrictedAreaConfig {
3547 area_hint: Some(RestrictedArea::Hk),
3548 proxies: vec![RestrictedAreaProxy::playurl(
3549 format!("{}/proxy-playurl", server.base_url()),
3550 None,
3551 )],
3552 },
3553 user_agent: "test".to_owned(),
3554 request_timeout: Duration::from_secs(30),
3555 });
3556
3557 let Err(error) = client.plan_download("ep1000", None).await else {
3558 return Err(anyhow::anyhow!("all resolver candidates should fail"));
3559 };
3560 let message = error.to_string();
3561 assert!(message.contains("restricted-area resolver failed"));
3562 for sensitive in [
3563 "OFFICIAL_SECRET",
3564 "ACCESS_SECRET",
3565 "PROXY_SECRET",
3566 "COOKIE_SECRET",
3567 "access_key",
3568 "proxy_token",
3569 "cookie",
3570 "user:pass",
3571 ] {
3572 assert!(
3573 !message.contains(sensitive),
3574 "message leaked {sensitive}: {message}"
3575 );
3576 }
3577 Ok(())
3578 }
3579
3580 #[test]
3581 fn pgc_bilibili_api_proxy_preserves_base_query_for_web_and_v2_paths() -> anyhow::Result<()> {
3582 let client = BiliClient::new(ClientConfig {
3583 credentials: Credentials {
3584 cookie: None,
3585 access_key: Some("ACCESS_SECRET".to_owned()),
3586 tv_access_key: None,
3587 },
3588 ..ClientConfig::default()
3589 });
3590 let proxy = RestrictedAreaProxy::bilibili_api(
3591 "https://proxy.example/base?proxy_token=a%3Db",
3592 Some(RestrictedArea::Hk),
3593 );
3594
3595 let urls = client.pgc_proxy_playurl_urls(&proxy, 10, 100, 1000)?;
3596 let actual = urls.iter().map(url::Url::as_str).collect::<Vec<_>>();
3597 assert_eq!(
3598 actual,
3599 vec![
3600 "https://proxy.example/base/pgc/player/web/playurl?proxy_token=a%3Db&avid=10&cid=100&ep_id=1000&module=bangumi&qn=0&fnval=4048&fnver=0&fourk=1&otype=json&area=hk&access_key=ACCESS_SECRET",
3601 "https://proxy.example/base/pgc/player/web/v2/playurl?proxy_token=a%3Db&avid=10&cid=100&ep_id=1000&module=bangumi&qn=0&fnval=4048&fnver=0&fourk=1&otype=json&area=hk&access_key=ACCESS_SECRET"
3602 ]
3603 );
3604 Ok(())
3605 }
3606
3607 #[tokio::test]
3608 async fn pgc_bilibili_api_proxy_falls_back_to_v2_path() -> anyhow::Result<()> {
3609 let server = MockServer::start();
3610 server.mock(|when, then| {
3611 when.method(GET)
3612 .path("/pgc/view/web/season")
3613 .query_param("ep_id", "1000");
3614 then.status(200).json_body_obj(&serde_json::json!({
3615 "code": 0,
3616 "result": {
3617 "season_id": 123,
3618 "title": "A Season",
3619 "episodes": [
3620 {"aid": 10, "bvid": "BV1aa", "cid": 100, "id": 1000, "ep_id": 1000, "title": "1"}
3621 ]
3622 }
3623 }));
3624 });
3625 server.mock(|when, then| {
3626 when.method(GET)
3627 .path("/pgc/player/web/v2/playurl")
3628 .query_param("ep_id", "1000")
3629 .query_param("module", "bangumi");
3630 then.status(200).json_body_obj(&serde_json::json!({
3631 "code": -40301,
3632 "message": "area restricted"
3633 }));
3634 });
3635 let web_proxy = server.mock(|when, then| {
3636 when.method(GET)
3637 .path("/proxy/pgc/player/web/playurl")
3638 .query_param("ep_id", "1000")
3639 .query_param("area", "hk");
3640 then.status(404);
3641 });
3642 let v2_proxy = server.mock(|when, then| {
3643 when.method(GET)
3644 .path("/proxy/pgc/player/web/v2/playurl")
3645 .query_param("ep_id", "1000")
3646 .query_param("area", "hk");
3647 then.status(200).json_body_obj(&serde_json::json!({
3648 "code": 0,
3649 "result": {
3650 "video_info": {
3651 "dash": {
3652 "duration": 3,
3653 "video": [{
3654 "id": 80,
3655 "baseUrl": "https://proxy.example/video.m4s",
3656 "base_url": "https://proxy.example/video.m4s"
3657 }],
3658 "audio": []
3659 }
3660 }
3661 }
3662 }));
3663 });
3664 server.mock(|when, then| {
3665 when.method(GET)
3666 .path("/x/player/v2")
3667 .query_param("aid", "10")
3668 .query_param("cid", "100");
3669 then.status(200).json_body_obj(&serde_json::json!({
3670 "code": 0,
3671 "data": {"subtitle": {"subtitles": []}}
3672 }));
3673 });
3674
3675 let client = BiliClient::new(ClientConfig {
3676 endpoints: EndpointConfig {
3677 api_base: server.base_url(),
3678 pgc_base: server.base_url(),
3679 intl_base: server.base_url(),
3680 comment_base: server.base_url(),
3681 passport_base: server.base_url(),
3682 tv_passport_base: server.base_url(),
3683 tv_passport_poll_base: server.base_url(),
3684 },
3685 restricted_area: RestrictedAreaConfig {
3686 area_hint: Some(RestrictedArea::Hk),
3687 proxies: vec![RestrictedAreaProxy::bilibili_api(
3688 format!("{}/proxy", server.base_url()),
3689 Some(RestrictedArea::Hk),
3690 )],
3691 },
3692 user_agent: "test".to_owned(),
3693 request_timeout: Duration::from_secs(30),
3694 ..ClientConfig::default()
3695 });
3696 let plan = client.plan_download("ep1000", None).await?;
3697
3698 web_proxy.assert();
3699 v2_proxy.assert();
3700 assert_eq!(plan.entries[0].source, StreamSource::PgcProxy);
3701 assert_eq!(plan.entries[0].streams.videos[0].id, 80);
3702 assert_eq!(plan.entries[0].diagnostics.attempts.len(), 3);
3703 Ok(())
3704 }
3705
3706 #[test]
3707 fn resolver_error_message_redacts_sensitive_values() {
3708 let message = super::resolver_error_message(&Error::Api {
3709 code: -40301,
3710 message: "proxy rejected https://user:pass@proxy.example/api?proxy_token=PROXY_SECRET&access_key=ACCESS_SECRET cookie=SESSDATA=COOKIE_SECRET token=TOKEN_SECRET jwt=JWT_SECRET x-api-key: API_KEY_SECRET authorization: Bearer AUTH_SECRET".to_owned(),
3711 });
3712
3713 assert!(message.starts_with("API code -40301:"));
3714 for sensitive in [
3715 "ACCESS_SECRET",
3716 "PROXY_SECRET",
3717 "COOKIE_SECRET",
3718 "TOKEN_SECRET",
3719 "JWT_SECRET",
3720 "API_KEY_SECRET",
3721 "AUTH_SECRET",
3722 "proxy_token",
3723 "access_key",
3724 "x-api-key",
3725 "authorization",
3726 "cookie",
3727 "user:pass",
3728 ] {
3729 assert!(
3730 !message.contains(sensitive),
3731 "message leaked {sensitive}: {message}"
3732 );
3733 }
3734 }
3735
3736 #[test]
3737 fn resolver_error_message_redacts_mixed_case_urls() {
3738 let message = super::resolver_error_message(&Error::Api {
3739 code: -40301,
3740 message: "proxy rejected HtTpS://user:pass@proxy.example/t/PATH_SECRET?x=1".to_owned(),
3741 });
3742
3743 assert!(message.contains("https://proxy.example"));
3744 for sensitive in ["user:pass", "PATH_SECRET", "?x=1"] {
3745 assert!(
3746 !message.contains(sensitive),
3747 "message leaked {sensitive}: {message}"
3748 );
3749 }
3750 }
3751
3752 #[tokio::test]
3753 async fn request_timeout_bounds_hung_endpoint() -> anyhow::Result<()> {
3754 use std::net::TcpListener;
3755
3756 let listener = TcpListener::bind("127.0.0.1:0")?;
3757 let address = listener.local_addr()?;
3758 let handle = std::thread::spawn(move || {
3759 if let Ok((_stream, _address)) = listener.accept() {
3760 std::thread::sleep(Duration::from_millis(200));
3761 }
3762 });
3763
3764 let client = BiliClient::new(ClientConfig {
3765 endpoints: EndpointConfig {
3766 api_base: format!("http://{address}"),
3767 pgc_base: "http://127.0.0.1:1".to_owned(),
3768 intl_base: "http://127.0.0.1:1".to_owned(),
3769 comment_base: "http://127.0.0.1:1".to_owned(),
3770 passport_base: "http://127.0.0.1:1".to_owned(),
3771 tv_passport_base: "http://127.0.0.1:1".to_owned(),
3772 tv_passport_poll_base: "http://127.0.0.1:1".to_owned(),
3773 },
3774 credentials: Credentials::default(),
3775 restricted_area: RestrictedAreaConfig::default(),
3776 user_agent: "test".to_owned(),
3777 request_timeout: Duration::from_millis(30),
3778 });
3779
3780 let started = Instant::now();
3781 let Err(error) = client.resolve_input("av170001", None).await else {
3782 return Err(anyhow::anyhow!("hung endpoint should time out"));
3783 };
3784 let elapsed = started.elapsed();
3785 handle
3786 .join()
3787 .map_err(|_| anyhow::anyhow!("timeout test server panicked"))?;
3788
3789 assert!(matches!(error, Error::Http(_)));
3790 assert!(elapsed < Duration::from_secs(1));
3791 Ok(())
3792 }
3793
3794 #[tokio::test]
3795 async fn season_links_require_selection() -> anyhow::Result<()> {
3796 let server = MockServer::start();
3797 let client = test_client(&server);
3798 let error = client.resolve_input("ss123", None).await.err();
3799 assert!(matches!(
3800 error,
3801 Some(Error::SelectionRequired {
3802 input_kind: "season"
3803 })
3804 ));
3805 Ok(())
3806 }
3807
3808 #[tokio::test]
3809 async fn resolves_season_latest() -> anyhow::Result<()> {
3810 let server = MockServer::start();
3811 server.mock(|when, then| {
3812 when.method(GET)
3813 .path("/pgc/view/web/season")
3814 .query_param("season_id", "123");
3815 then.status(200).json_body_obj(&serde_json::json!({
3816 "code": 0,
3817 "result": {
3818 "season_id": 123,
3819 "media_id": 456,
3820 "title": "A Season",
3821 "evaluate": "Season desc",
3822 "episodes": [
3823 {"aid": 10, "bvid": "BV1aa", "cid": 100, "id": 9000, "ep_id": 1000, "title": "1", "long_title": "Start"},
3824 {"aid": 11, "bvid": "BV1bb", "cid": 101, "id": 9001, "ep_id": 1001, "title": "2", "long_title": "Next"}
3825 ],
3826 "areas": [{"name": "Japan"}],
3827 "styles": ["Anime", "Action"]
3828 }
3829 }));
3830 });
3831
3832 let client = test_client(&server);
3833 let resolved = client
3834 .resolve_input("ss123", Some(Selection::Latest))
3835 .await?;
3836 match resolved {
3837 ResolvedContent::Season(season) => {
3838 assert_eq!(season.season.title, "A Season");
3839 assert_eq!(season.season.tags, ["Anime", "Action"]);
3840 assert_eq!(season.selected_episodes.len(), 1);
3841 assert_eq!(season.selected_episodes[0].epid, 1001);
3842 }
3843 ResolvedContent::Video(_) => return Err(anyhow::anyhow!("expected season")),
3844 }
3845 Ok(())
3846 }
3847
3848 #[tokio::test]
3849 async fn resolves_episode_from_section() -> anyhow::Result<()> {
3850 let server = MockServer::start();
3851 server.mock(|when, then| {
3852 when.method(GET)
3853 .path("/pgc/view/web/season")
3854 .query_param("ep_id", "2000");
3855 then.status(200).json_body_obj(&serde_json::json!({
3856 "code": 0,
3857 "result": {
3858 "season_id": 123,
3859 "title": "A Season",
3860 "evaluate": "Season desc",
3861 "episodes": [
3862 {"aid": 10, "bvid": "BV1aa", "cid": 100, "id": 1000, "title": "1", "long_title": "Start"}
3863 ],
3864 "section": [{
3865 "title": "Extras",
3866 "episodes": [
3867 {"aid": 12, "bvid": "BV1cc", "cid": 102, "episode_id": 2000, "title": "SP", "long_title": "Special"}
3868 ]
3869 }]
3870 }
3871 }));
3872 });
3873
3874 let client = test_client(&server);
3875 let resolved = client.resolve_input("ep2000", None).await?;
3876 match resolved {
3877 ResolvedContent::Season(season) => {
3878 assert_eq!(season.season.episodes.len(), 2);
3879 assert_eq!(season.selected_episodes[0].epid, 2000);
3880 }
3881 ResolvedContent::Video(_) => return Err(anyhow::anyhow!("expected season")),
3882 }
3883 Ok(())
3884 }
3885
3886 #[tokio::test]
3887 async fn latest_ignores_section_extras() -> anyhow::Result<()> {
3888 let server = MockServer::start();
3889 server.mock(|when, then| {
3890 when.method(GET)
3891 .path("/pgc/view/web/season")
3892 .query_param("season_id", "123");
3893 then.status(200).json_body_obj(&serde_json::json!({
3894 "code": 0,
3895 "result": {
3896 "season_id": 123,
3897 "title": "A Season",
3898 "episodes": [
3899 {"aid": 10, "bvid": "BV1aa", "cid": 100, "id": 1000, "title": "1"},
3900 {"aid": 11, "bvid": "BV1bb", "cid": 101, "id": 1001, "title": "2"}
3901 ],
3902 "section": [{
3903 "title": "Extras",
3904 "episodes": [
3905 {"aid": 12, "bvid": "BV1cc", "cid": 102, "id": 2000, "title": "PV"}
3906 ]
3907 }]
3908 }
3909 }));
3910 });
3911
3912 let client = test_client(&server);
3913 let resolved = client
3914 .resolve_input("ss123", Some(Selection::Latest))
3915 .await?;
3916 match resolved {
3917 ResolvedContent::Season(season) => {
3918 assert_eq!(season.season.episodes.len(), 3);
3919 assert_eq!(season.season.main_episode_count, 2);
3920 assert_eq!(season.selected_episodes[0].epid, 1001);
3921 }
3922 ResolvedContent::Video(_) => return Err(anyhow::anyhow!("expected season")),
3923 }
3924 Ok(())
3925 }
3926
3927 #[tokio::test]
3928 async fn latest_uses_filtered_main_episode_count() -> anyhow::Result<()> {
3929 let server = MockServer::start();
3930 server.mock(|when, then| {
3931 when.method(GET)
3932 .path("/pgc/view/web/season")
3933 .query_param("season_id", "123");
3934 then.status(200).json_body_obj(&serde_json::json!({
3935 "code": 0,
3936 "result": {
3937 "season_id": 123,
3938 "title": "A Season",
3939 "episodes": [
3940 {"bvid": "BVbad", "cid": 99, "id": 999, "title": "invalid"},
3941 {"aid": 11, "bvid": "BV1bb", "cid": 101, "id": 1001, "title": "2"}
3942 ],
3943 "section": [{
3944 "title": "Extras",
3945 "episodes": [
3946 {"aid": 12, "bvid": "BV1cc", "cid": 102, "id": 2000, "title": "PV"}
3947 ]
3948 }]
3949 }
3950 }));
3951 });
3952
3953 let client = test_client(&server);
3954 let resolved = client
3955 .resolve_input("ss123", Some(Selection::Latest))
3956 .await?;
3957 match resolved {
3958 ResolvedContent::Season(season) => {
3959 assert_eq!(season.season.main_episode_count, 1);
3960 assert_eq!(season.selected_episodes[0].epid, 1001);
3961 }
3962 ResolvedContent::Video(_) => return Err(anyhow::anyhow!("expected season")),
3963 }
3964 Ok(())
3965 }
3966
3967 #[test]
3968 fn endpoint_url_preserves_path_prefix() -> anyhow::Result<()> {
3969 let url =
3970 BiliClient::endpoint_url("http://proxy.example/bili/api", "/x/web-interface/view")?;
3971 assert_eq!(
3972 url.as_str(),
3973 "http://proxy.example/bili/api/x/web-interface/view"
3974 );
3975 Ok(())
3976 }
3977
3978 fn test_client(server: &MockServer) -> BiliClient {
3979 BiliClient::new(ClientConfig {
3980 endpoints: EndpointConfig {
3981 api_base: server.base_url(),
3982 pgc_base: server.base_url(),
3983 intl_base: server.base_url(),
3984 comment_base: server.base_url(),
3985 passport_base: server.base_url(),
3986 tv_passport_base: server.base_url(),
3987 tv_passport_poll_base: server.base_url(),
3988 },
3989 credentials: Credentials::default(),
3990 restricted_area: RestrictedAreaConfig::default(),
3991 user_agent: "test".to_owned(),
3992 request_timeout: Duration::from_secs(30),
3993 })
3994 }
3995}