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}