Skip to main content

bbdown_core/
input.rs

1use crate::bv;
2use crate::{Error, Result};
3use url::Url;
4
5#[derive(Clone, Debug, Eq, PartialEq)]
6pub enum Input {
7    Aid(u64),
8    Bvid(String),
9    Episode(u64),
10    Season(u64),
11    Media(u64),
12    IntlEpisode(u64),
13}
14
15impl Input {
16    pub fn parse(raw: &str) -> Result<Self> {
17        let trimmed = raw.trim();
18        if trimmed.is_empty() {
19            return Err(Error::InvalidInput("empty input".to_owned()));
20        }
21
22        if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
23            return parse_url(trimmed);
24        }
25
26        let lower = trimmed.to_ascii_lowercase();
27        if lower.starts_with("bv") {
28            return parse_bvid(trimmed);
29        }
30        if let Some(id) = lower.strip_prefix("av") {
31            return parse_number(id, "av").map(Self::Aid);
32        }
33        if let Some(id) = lower.strip_prefix("ep") {
34            return parse_number(id, "ep").map(Self::Episode);
35        }
36        if let Some(id) = lower.strip_prefix("ss") {
37            return parse_number(id, "ss").map(Self::Season);
38        }
39        if let Some(id) = lower.strip_prefix("md") {
40            return parse_number(id, "md").map(Self::Media);
41        }
42        if trimmed.chars().all(|ch| ch.is_ascii_digit()) {
43            return parse_number(trimmed, "aid").map(Self::Aid);
44        }
45
46        Err(Error::InvalidInput(trimmed.to_owned()))
47    }
48
49    pub fn aid_hint(&self) -> Result<Option<u64>> {
50        match self {
51            Self::Aid(aid) => Ok(Some(*aid)),
52            Self::Bvid(bvid) => bv::decode(bvid).map(Some),
53            Self::Episode(_) | Self::Season(_) | Self::Media(_) | Self::IntlEpisode(_) => Ok(None),
54        }
55    }
56}
57
58fn parse_url(raw: &str) -> Result<Input> {
59    let url = Url::parse(raw)?;
60    let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
61    let path = url.path();
62
63    if host == "b23.tv" {
64        return Err(Error::Unsupported(
65            "short-link redirect resolution is not in the first PR slice".to_owned(),
66        ));
67    }
68
69    if host.ends_with("bilibili.tv") {
70        let segments: Vec<_> = path
71            .split('/')
72            .filter(|segment| !segment.is_empty())
73            .collect();
74        if segments.len() >= 4 && segments[1] == "play" {
75            return parse_number(segments[3], "intl episode").map(Input::IntlEpisode);
76        }
77    }
78
79    if let Some(value) = query_number(&url, "ep_id")? {
80        return Ok(Input::Episode(value));
81    }
82    if let Some(value) = query_number(&url, "season_id")? {
83        return Ok(Input::Season(value));
84    }
85
86    for segment in path.split('/').filter(|segment| !segment.is_empty()) {
87        let lower = segment.to_ascii_lowercase();
88        if let Some(id) = lower.strip_prefix("av") {
89            return parse_number(id, "av").map(Input::Aid);
90        }
91        if lower.starts_with("bv") {
92            return parse_bvid(segment);
93        }
94        if let Some(id) = lower.strip_prefix("ep") {
95            return parse_number(id, "ep").map(Input::Episode);
96        }
97        if let Some(id) = lower.strip_prefix("ss") {
98            return parse_number(id, "ss").map(Input::Season);
99        }
100        if let Some(id) = lower.strip_prefix("md") {
101            return parse_number(id, "md").map(Input::Media);
102        }
103    }
104
105    Err(Error::InvalidInput(raw.to_owned()))
106}
107
108fn parse_bvid(text: &str) -> Result<Input> {
109    bv::decode(text)?;
110    Ok(Input::Bvid(text.to_owned()))
111}
112
113fn query_number(url: &Url, key: &str) -> Result<Option<u64>> {
114    url.query_pairs()
115        .find(|(name, _)| name == key)
116        .map(|(_, value)| parse_number(value.as_ref(), key))
117        .transpose()
118}
119
120fn parse_number(text: &str, label: &str) -> Result<u64> {
121    text.parse::<u64>()
122        .map_err(|_| Error::InvalidInput(format!("invalid {label} id `{text}`")))
123}
124
125#[cfg(test)]
126mod tests {
127    use crate::Input;
128
129    #[test]
130    fn parses_common_inputs() -> anyhow::Result<()> {
131        assert_eq!(Input::parse("av170001")?, Input::Aid(170_001));
132        assert_eq!(
133            Input::parse("BV1qt4y1X7TW")?,
134            Input::Bvid("BV1qt4y1X7TW".to_owned())
135        );
136        assert_eq!(
137            Input::parse("https://www.bilibili.com/bangumi/play/ep359333")?,
138            Input::Episode(359_333)
139        );
140        assert_eq!(
141            Input::parse("https://www.bilibili.com/bangumi/play/ss28276")?,
142            Input::Season(28_276)
143        );
144        assert_eq!(
145            Input::parse("https://www.bilibili.com/bangumi/media/md28230188/")?,
146            Input::Media(28_230_188)
147        );
148        assert_eq!(
149            Input::parse("https://www.bilibili.tv/en/play/34613/341736")?,
150            Input::IntlEpisode(341_736)
151        );
152        assert!(Input::parse("bvideo").is_err());
153        assert!(Input::parse("bv1qt4y1X7TW").is_err());
154        Ok(())
155    }
156}