bbdown-core 0.1.0

Rust library for resolving Bilibili metadata, download plans, media, subtitles, and danmaku.
Documentation
use crate::bv;
use crate::{Error, Result};
use url::Url;

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Input {
    Aid(u64),
    Bvid(String),
    Episode(u64),
    Season(u64),
    Media(u64),
    IntlEpisode(u64),
}

impl Input {
    pub fn parse(raw: &str) -> Result<Self> {
        let trimmed = raw.trim();
        if trimmed.is_empty() {
            return Err(Error::InvalidInput("empty input".to_owned()));
        }

        if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
            return parse_url(trimmed);
        }

        let lower = trimmed.to_ascii_lowercase();
        if lower.starts_with("bv") {
            return parse_bvid(trimmed);
        }
        if let Some(id) = lower.strip_prefix("av") {
            return parse_number(id, "av").map(Self::Aid);
        }
        if let Some(id) = lower.strip_prefix("ep") {
            return parse_number(id, "ep").map(Self::Episode);
        }
        if let Some(id) = lower.strip_prefix("ss") {
            return parse_number(id, "ss").map(Self::Season);
        }
        if let Some(id) = lower.strip_prefix("md") {
            return parse_number(id, "md").map(Self::Media);
        }
        if trimmed.chars().all(|ch| ch.is_ascii_digit()) {
            return parse_number(trimmed, "aid").map(Self::Aid);
        }

        Err(Error::InvalidInput(trimmed.to_owned()))
    }

    pub fn aid_hint(&self) -> Result<Option<u64>> {
        match self {
            Self::Aid(aid) => Ok(Some(*aid)),
            Self::Bvid(bvid) => bv::decode(bvid).map(Some),
            Self::Episode(_) | Self::Season(_) | Self::Media(_) | Self::IntlEpisode(_) => Ok(None),
        }
    }
}

fn parse_url(raw: &str) -> Result<Input> {
    let url = Url::parse(raw)?;
    let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
    let path = url.path();

    if host == "b23.tv" {
        return Err(Error::Unsupported(
            "short-link redirect resolution is not in the first PR slice".to_owned(),
        ));
    }

    if host.ends_with("bilibili.tv") {
        let segments: Vec<_> = path
            .split('/')
            .filter(|segment| !segment.is_empty())
            .collect();
        if segments.len() >= 4 && segments[1] == "play" {
            return parse_number(segments[3], "intl episode").map(Input::IntlEpisode);
        }
    }

    if let Some(value) = query_number(&url, "ep_id")? {
        return Ok(Input::Episode(value));
    }
    if let Some(value) = query_number(&url, "season_id")? {
        return Ok(Input::Season(value));
    }

    for segment in path.split('/').filter(|segment| !segment.is_empty()) {
        let lower = segment.to_ascii_lowercase();
        if let Some(id) = lower.strip_prefix("av") {
            return parse_number(id, "av").map(Input::Aid);
        }
        if lower.starts_with("bv") {
            return parse_bvid(segment);
        }
        if let Some(id) = lower.strip_prefix("ep") {
            return parse_number(id, "ep").map(Input::Episode);
        }
        if let Some(id) = lower.strip_prefix("ss") {
            return parse_number(id, "ss").map(Input::Season);
        }
        if let Some(id) = lower.strip_prefix("md") {
            return parse_number(id, "md").map(Input::Media);
        }
    }

    Err(Error::InvalidInput(raw.to_owned()))
}

fn parse_bvid(text: &str) -> Result<Input> {
    bv::decode(text)?;
    Ok(Input::Bvid(text.to_owned()))
}

fn query_number(url: &Url, key: &str) -> Result<Option<u64>> {
    url.query_pairs()
        .find(|(name, _)| name == key)
        .map(|(_, value)| parse_number(value.as_ref(), key))
        .transpose()
}

fn parse_number(text: &str, label: &str) -> Result<u64> {
    text.parse::<u64>()
        .map_err(|_| Error::InvalidInput(format!("invalid {label} id `{text}`")))
}

#[cfg(test)]
mod tests {
    use crate::Input;

    #[test]
    fn parses_common_inputs() -> anyhow::Result<()> {
        assert_eq!(Input::parse("av170001")?, Input::Aid(170_001));
        assert_eq!(
            Input::parse("BV1qt4y1X7TW")?,
            Input::Bvid("BV1qt4y1X7TW".to_owned())
        );
        assert_eq!(
            Input::parse("https://www.bilibili.com/bangumi/play/ep359333")?,
            Input::Episode(359_333)
        );
        assert_eq!(
            Input::parse("https://www.bilibili.com/bangumi/play/ss28276")?,
            Input::Season(28_276)
        );
        assert_eq!(
            Input::parse("https://www.bilibili.com/bangumi/media/md28230188/")?,
            Input::Media(28_230_188)
        );
        assert_eq!(
            Input::parse("https://www.bilibili.tv/en/play/34613/341736")?,
            Input::IntlEpisode(341_736)
        );
        assert!(Input::parse("bvideo").is_err());
        assert!(Input::parse("bv1qt4y1X7TW").is_err());
        Ok(())
    }
}