bpi-rs 0.2.0

Bilibili API client library for Rust
Documentation
use crate::ids::{Cid, EpisodeId, MediaId, SeasonId};
use crate::models::{Fnval, VideoQuality};
use crate::{BpiError, BpiResult};

use super::timeline::BangumiTimelineType;

/// Parameters for `/pgc/review/user`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BangumiInfoParams {
    media_id: MediaId,
}

impl BangumiInfoParams {
    pub fn new(media_id: MediaId) -> Self {
        Self { media_id }
    }

    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
        vec![("media_id", self.media_id.to_string())]
    }
}

/// Identifies a bangumi detail request by season ID or episode ID.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BangumiDetailId {
    Season(SeasonId),
    Episode(EpisodeId),
}

/// Parameters for `/pgc/view/web/season`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BangumiDetailParams {
    id: BangumiDetailId,
}

impl BangumiDetailParams {
    pub fn from_season_id(season_id: SeasonId) -> Self {
        Self {
            id: BangumiDetailId::Season(season_id),
        }
    }

    pub fn from_episode_id(episode_id: EpisodeId) -> Self {
        Self {
            id: BangumiDetailId::Episode(episode_id),
        }
    }

    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
        match self.id {
            BangumiDetailId::Season(season_id) => {
                vec![("season_id", season_id.to_string())]
            }
            BangumiDetailId::Episode(episode_id) => {
                vec![("ep_id", episode_id.to_string())]
            }
        }
    }
}

/// Parameters for `/pgc/web/season/section`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BangumiSectionsParams {
    season_id: SeasonId,
}

impl BangumiSectionsParams {
    pub fn new(season_id: SeasonId) -> Self {
        Self { season_id }
    }

    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
        vec![("season_id", self.season_id.to_string())]
    }
}

/// Identifies a bangumi play URL request by episode ID or content ID.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BangumiVideoStreamId {
    Episode(EpisodeId),
    Content(Cid),
}

/// Parameters for `/pgc/player/web/playurl`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BangumiVideoStreamParams {
    id: BangumiVideoStreamId,
    quality: Option<VideoQuality>,
    fnval: Option<Fnval>,
}

impl BangumiVideoStreamParams {
    pub fn from_episode_id(episode_id: EpisodeId) -> Self {
        Self {
            id: BangumiVideoStreamId::Episode(episode_id),
            quality: None,
            fnval: None,
        }
    }

    pub fn from_cid(cid: Cid) -> Self {
        Self {
            id: BangumiVideoStreamId::Content(cid),
            quality: None,
            fnval: None,
        }
    }

    pub fn with_quality(mut self, quality: VideoQuality) -> Self {
        self.quality = Some(quality);
        self
    }

    pub fn with_fnval(mut self, fnval: Fnval) -> Self {
        self.fnval = Some(fnval);
        self
    }

    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
        let mut params = vec![("fnver", "0".to_string())];

        if self.fnval.is_some_and(|fnval| fnval.is_fourk()) {
            params.push(("fourk", "1".to_string()));
        }

        match self.id {
            BangumiVideoStreamId::Episode(episode_id) => {
                params.push(("ep_id", episode_id.to_string()));
            }
            BangumiVideoStreamId::Content(cid) => {
                params.push(("cid", cid.to_string()));
            }
        }

        if let Some(quality) = self.quality {
            params.push(("qn", quality.as_u32().to_string()));
        }

        if let Some(fnval) = self.fnval {
            params.push(("fnval", fnval.bits().to_string()));
        }

        params
    }
}

/// Parameters for `/pgc/web/timeline`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BangumiTimelineParams {
    timeline_type: BangumiTimelineType,
    before: i32,
    after: i32,
}

impl BangumiTimelineParams {
    pub fn new(timeline_type: BangumiTimelineType, before: i32, after: i32) -> BpiResult<Self> {
        Ok(Self {
            timeline_type,
            before: validate_timeline_day_offset("before", before)?,
            after: validate_timeline_day_offset("after", after)?,
        })
    }

    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
        vec![
            ("types", self.timeline_type.as_i32().to_string()),
            ("before", self.before.to_string()),
            ("after", self.after.to_string()),
        ]
    }
}

fn validate_timeline_day_offset(field: &'static str, value: i32) -> BpiResult<i32> {
    if !(0..=7).contains(&value) {
        return Err(BpiError::invalid_parameter(
            field,
            "value must be between 0 and 7",
        ));
    }

    Ok(value)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn bangumi_info_params_serializes_media_id() -> BpiResult<()> {
        let params = BangumiInfoParams::new(MediaId::new(28_220_978)?);

        assert_eq!(
            params.query_pairs(),
            vec![("media_id", "28220978".to_string())]
        );
        Ok(())
    }

    #[test]
    fn bangumi_detail_params_serializes_season_id() -> BpiResult<()> {
        let params = BangumiDetailParams::from_season_id(SeasonId::new(1172)?);

        assert_eq!(
            params.query_pairs(),
            vec![("season_id", "1172".to_string())]
        );
        Ok(())
    }

    #[test]
    fn bangumi_detail_params_serializes_episode_id() -> BpiResult<()> {
        let params = BangumiDetailParams::from_episode_id(EpisodeId::new(21265)?);

        assert_eq!(params.query_pairs(), vec![("ep_id", "21265".to_string())]);
        Ok(())
    }

    #[test]
    fn bangumi_sections_params_serializes_season_id() -> BpiResult<()> {
        let params = BangumiSectionsParams::new(SeasonId::new(1172)?);

        assert_eq!(
            params.query_pairs(),
            vec![("season_id", "1172".to_string())]
        );
        Ok(())
    }

    #[test]
    fn bangumi_video_stream_params_serializes_episode_id() -> BpiResult<()> {
        let params = BangumiVideoStreamParams::from_episode_id(EpisodeId::new(21_265)?)
            .with_quality(VideoQuality::P480)
            .with_fnval(Fnval::DASH);

        assert_eq!(
            params.query_pairs(),
            vec![
                ("fnver", "0".to_string()),
                ("ep_id", "21265".to_string()),
                ("qn", "32".to_string()),
                ("fnval", "16".to_string()),
            ]
        );
        Ok(())
    }

    #[test]
    fn bangumi_video_stream_params_serializes_cid_and_fourk_flag() -> BpiResult<()> {
        let params = BangumiVideoStreamParams::from_cid(Cid::new(91_549_662)?)
            .with_quality(VideoQuality::P4K)
            .with_fnval(Fnval::DASH | Fnval::FOURK);

        assert_eq!(
            params.query_pairs(),
            vec![
                ("fnver", "0".to_string()),
                ("fourk", "1".to_string()),
                ("cid", "91549662".to_string()),
                ("qn", "120".to_string()),
                ("fnval", "144".to_string()),
            ]
        );
        Ok(())
    }

    #[test]
    fn bangumi_timeline_params_serializes_query() -> BpiResult<()> {
        let params = BangumiTimelineParams::new(BangumiTimelineType::Anime, 3, 7)?;

        assert_eq!(
            params.query_pairs(),
            vec![
                ("types", "1".to_string()),
                ("before", "3".to_string()),
                ("after", "7".to_string()),
            ]
        );
        Ok(())
    }

    #[test]
    fn bangumi_timeline_params_rejects_large_before() {
        let err = BangumiTimelineParams::new(BangumiTimelineType::Anime, 8, 7).unwrap_err();

        assert!(matches!(
            err,
            BpiError::InvalidParameter {
                field: "before",
                ..
            }
        ));
    }
}