Skip to main content

bbdown_core/
client.rs

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(&params, "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}