holodex 0.3.1

A Rust wrapper of the Holodex v2 API.
Documentation
//! Various types wrapping different IDs used in the API.
#![allow(clippy::module_name_repetitions)]

use std::{convert::TryFrom, fmt::Display, ops::Deref, str::FromStr};

use regex::Regex;
use serde::{self, Deserialize, Serialize};

use crate::{
    errors::Error,
    model::{
        Channel, ChannelVideoFilter, ChannelVideoType, Language, PaginatedResult, Video, VideoFull,
    },
    Client,
};

#[cfg(feature = "streams")]
use futures_core::Stream;

#[cfg(not(feature = "sso"))]
#[derive(Debug, Clone, PartialEq, Eq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
/// The ID of a video.
pub struct VideoId(pub(crate) String);

#[cfg(feature = "sso")]
#[derive(Debug, Clone, PartialEq, Eq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
/// The ID of a video.
pub struct VideoId(pub(crate) smartstring::alias::String);

impl VideoId {
    /// Get all the metadata associated with this channel.
    ///
    /// # Examples
    ///
    /// Get all songs sung in the Lazu Light karaoke (2021-10-05).
    /// ```rust
    /// use holodex::model::{id::VideoId, Language};
    ///
    /// # if std::env::var_os("HOLODEX_API_TOKEN").is_none() {
    /// #   std::env::set_var("HOLODEX_API_TOKEN", "my-api-token");
    /// # }
    /// let token = std::env::var("HOLODEX_API_TOKEN").unwrap();
    /// let client = holodex::Client::new(&token)?;
    ///
    /// let video_id: VideoId = "https://www.youtube.com/watch?v=V2SBDtZ4khY".parse()?;
    /// let video = video_id.metadata(&client)?;
    ///
    /// for song in video.songs {
    ///     println!("{}", song);
    /// }
    /// # Ok::<(), holodex::errors::Error>(())
    /// ```
    ///
    /// # Errors
    /// Will return [`Error::ApiRequestFailed`] if sending the API request fails.
    ///
    /// Will return [`Error::InvalidResponse`] if the API returned a faulty response or server error.
    pub fn metadata(&self, client: &Client) -> Result<VideoFull, Error> {
        client.video(self)
    }

    /// Get all indexed comments containing timestamps for this video.
    ///
    /// # Examples
    ///
    /// Print all timestamped comments from Elira's birthday stream (2021).
    /// ```rust
    /// use holodex::model::id::VideoId;
    ///
    /// # if std::env::var_os("HOLODEX_API_TOKEN").is_none() {
    /// #   std::env::set_var("HOLODEX_API_TOKEN", "my-api-token");
    /// # }
    /// let token = std::env::var("HOLODEX_API_TOKEN").unwrap();
    /// let client = holodex::Client::new(&token)?;
    ///
    /// let video: VideoId = "https://www.youtube.com/watch?v=tDXvkK_MLl0".parse()?;
    /// let timestamps = video.timestamps(&client)?;
    ///
    /// for timestamp in timestamps {
    ///     println!("{}", timestamp);
    /// }
    /// # Ok::<(), holodex::errors::Error>(())
    /// ```
    ///
    /// # Errors
    /// Will return [`Error::ApiRequestFailed`] if sending the API request fails.
    ///
    /// Will return [`Error::InvalidResponse`] if the API returned a faulty response or server error.
    pub fn timestamps(&self, client: &Client) -> Result<impl Iterator<Item = String> + '_, Error> {
        let metadata = client.video_with_timestamps(self)?;

        Ok(metadata.comments.into_iter().map(|c| c.message))
    }

    /// Get all videos related to this video that are in the given languages.
    ///
    /// # Examples
    ///
    /// Get Japanese clips related to Calli's birthday stream (2021).
    /// ```rust
    /// use holodex::model::{id::VideoId, Language};
    ///
    /// # if std::env::var_os("HOLODEX_API_TOKEN").is_none() {
    /// #   std::env::set_var("HOLODEX_API_TOKEN", "my-api-token");
    /// # }
    /// let token = std::env::var("HOLODEX_API_TOKEN").unwrap();
    /// let client = holodex::Client::new(&token)?;
    ///
    /// let video: VideoId = "https://www.youtube.com/watch?v=NiziRRHFZGA".parse()?;
    /// let clips = video.related(&client, &[Language::Japanese])?;
    ///
    /// for clip in clips {
    ///     println!("{}", clip.title);
    /// }
    /// # Ok::<(), holodex::errors::Error>(())
    /// ```
    ///
    /// # Errors
    /// Will return [`Error::ApiRequestFailed`] if sending the API request fails.
    ///
    /// Will return [`Error::InvalidResponse`] if the API returned a faulty response or server error.
    pub fn related(
        &self,
        client: &Client,
        languages: &[Language],
    ) -> Result<impl Iterator<Item = Video> + '_, Error> {
        let metadata = client.video_with_related(self, languages)?;

        Ok(metadata.related.into_iter())
    }
}

impl Display for VideoId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl Deref for VideoId {
    type Target = str;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

impl TryFrom<String> for VideoId {
    type Error = Error;

    fn try_from(s: String) -> Result<Self, Self::Error> {
        s.parse()
    }
}

impl FromStr for VideoId {
    type Err = Error;

    #[allow(clippy::unwrap_in_result)]
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        #[allow(clippy::expect_used)]
        let regex =
            Regex::new(r"[0-9A-Za-z_-]{10}[048AEIMQUYcgkosw]").expect("Video ID regex broke.");

        Ok(Self(
            regex
                .find(s)
                .ok_or_else(|| Error::InvalidVideoId(s.to_owned()))?
                .as_str()
                .into(),
        ))
    }
}

#[cfg(not(feature = "sso"))]
#[derive(Debug, Clone, PartialEq, Eq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
/// The ID of a channel.
pub struct ChannelId(pub(crate) String);

#[cfg(feature = "sso")]
#[derive(Debug, Clone, PartialEq, Eq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
/// The ID of a channel.
pub struct ChannelId(pub(crate) smartstring::alias::String);

impl ChannelId {
    /// Get all the metadata associated with this channel.
    ///
    /// # Examples
    ///
    /// Show the top topics associated with Aruran's channel.
    /// ```rust
    /// use holodex::model::id::ChannelId;
    ///
    /// # if std::env::var_os("HOLODEX_API_TOKEN").is_none() {
    /// #   std::env::set_var("HOLODEX_API_TOKEN", "my-api-token");
    /// # }
    /// let token = std::env::var("HOLODEX_API_TOKEN").unwrap();
    /// let client = holodex::Client::new(&token)?;
    ///
    /// let channel_id: ChannelId = "UCKeAhJvy8zgXWbh9duVjIaQ".parse()?;
    /// let channel = channel_id.metadata(&client)?;
    ///
    /// for topic in channel.top_topics {
    ///     println!("{}", topic);
    /// }
    /// # Ok::<(), holodex::errors::Error>(())
    /// ```
    ///
    /// # Errors
    /// Will return [`Error::ApiRequestFailed`] if sending the API request fails.
    ///
    /// Will return [`Error::InvalidResponse`] if the API returned a faulty response or server error.
    pub fn metadata(&self, client: &Client) -> Result<Channel, Error> {
        client.channel(self)
    }

    /// Get videos that this channel has uploaded.
    ///
    /// # Examples
    ///
    /// Print some videos uploaded by Kiara.
    /// ```rust
    /// use holodex::model::id::ChannelId;
    ///
    /// # if std::env::var_os("HOLODEX_API_TOKEN").is_none() {
    /// #   std::env::set_var("HOLODEX_API_TOKEN", "my-api-token");
    /// # }
    /// let token = std::env::var("HOLODEX_API_TOKEN").unwrap();
    /// let client = holodex::Client::new(&token)?;
    ///
    /// let channel_id: ChannelId = "UCHsx4Hqa-1ORjQTh9TYDhww".parse()?;
    /// let videos = channel_id.videos(&client)?;
    ///
    /// for video in videos {
    ///     println!("{}", video.title);
    /// }
    /// # Ok::<(), holodex::errors::Error>(())
    /// ```
    ///
    /// # Errors
    /// Will return [`Error::ApiRequestFailed`] if sending the API request fails.
    ///
    /// Will return [`Error::InvalidResponse`] if the API returned a faulty response or server error.
    pub fn videos(&self, client: &Client) -> Result<PaginatedResult<Video>, Error> {
        client.videos_from_channel(
            self,
            ChannelVideoType::Videos,
            &ChannelVideoFilter {
                paginated: false,
                ..ChannelVideoFilter::default()
            },
        )
    }

    #[cfg(feature = "streams")]
    /// Returns a stream of all videos that this channel has uploaded.
    ///
    /// /// Print the latest 200 videos uploaded by Kiara.
    /// ```rust
    /// # fn main() -> Result<(), holodex::errors::Error> {
    /// # tokio_test::block_on(async {
    /// use holodex::model::id::ChannelId;
    /// use futures::{self, pin_mut, StreamExt, TryStreamExt};
    ///
    /// # if std::env::var_os("HOLODEX_API_TOKEN").is_none() {
    /// #   std::env::set_var("HOLODEX_API_TOKEN", "my-api-token");
    /// # }
    /// let token = std::env::var("HOLODEX_API_TOKEN").unwrap();
    /// let client = holodex::Client::new(&token)?;
    ///
    /// let channel_id: ChannelId = "UCHsx4Hqa-1ORjQTh9TYDhww".parse()?;
    ///
    /// let stream = channel_id.video_stream(&client).take(200);
    /// pin_mut!(stream);
    ///
    /// while let Some(video) = stream.try_next().await? {
    ///     println!("{}", video.title);
    /// }
    /// # Ok(())
    /// # })
    /// # }
    pub fn video_stream(self, client: &Client) -> impl Stream<Item = Result<Video, Error>> + '_ {
        Self::stream_channel_video_type(client, self, ChannelVideoType::Videos)
    }

    /// Get clips related to this channel.
    ///
    /// # Examples
    ///
    /// Show some clips related to Uto.
    /// ```rust
    /// use holodex::model::id::ChannelId;
    ///
    /// # if std::env::var_os("HOLODEX_API_TOKEN").is_none() {
    /// #   std::env::set_var("HOLODEX_API_TOKEN", "my-api-token");
    /// # }
    /// let token = std::env::var("HOLODEX_API_TOKEN").unwrap();
    /// let client = holodex::Client::new(&token)?;
    ///
    /// let channel_id: ChannelId = "UCdYR5Oyz8Q4g0ZmB4PkTD7g".parse()?;
    /// let clips = channel_id.clips(&client)?;
    ///
    /// for clip in clips {
    ///     println!("{}", clip.title);
    /// }
    /// # Ok::<(), holodex::errors::Error>(())
    /// ```
    ///
    /// # Errors
    /// Will return [`Error::ApiRequestFailed`] if sending the API request fails.
    ///
    /// Will return [`Error::InvalidResponse`] if the API returned a faulty response or server error.
    pub fn clips(&self, client: &Client) -> Result<PaginatedResult<Video>, Error> {
        client.videos_from_channel(
            self,
            ChannelVideoType::Clips,
            &ChannelVideoFilter {
                paginated: false,
                ..ChannelVideoFilter::default()
            },
        )
    }

    #[cfg(feature = "streams")]
    /// Returns a stream of all videos that this channel has uploaded.
    ///
    /// /// Print the latest 200 clips made about Kiara.
    /// ```rust
    /// # fn main() -> Result<(), holodex::errors::Error> {
    /// # tokio_test::block_on(async {
    /// use holodex::model::id::ChannelId;
    /// use futures::{self, pin_mut, StreamExt, TryStreamExt};
    ///
    /// # if std::env::var_os("HOLODEX_API_TOKEN").is_none() {
    /// #   std::env::set_var("HOLODEX_API_TOKEN", "my-api-token");
    /// # }
    /// let token = std::env::var("HOLODEX_API_TOKEN").unwrap();
    /// let client = holodex::Client::new(&token)?;
    ///
    /// let channel_id: ChannelId = "UCHsx4Hqa-1ORjQTh9TYDhww".parse()?;
    ///
    /// let stream = channel_id.clip_stream(&client).take(200);
    /// pin_mut!(stream);
    ///
    /// while let Some(clip) = stream.try_next().await? {
    ///     println!("{}", clip.title);
    /// }
    /// # Ok(())
    /// # })
    /// # }
    pub fn clip_stream(self, client: &Client) -> impl Stream<Item = Result<Video, Error>> + '_ {
        Self::stream_channel_video_type(client, self, ChannelVideoType::Clips)
    }

    /// Get collabs from other videos that mention this channel.
    ///
    /// # Examples
    ///
    /// Show some collabs with Korone.
    /// ```rust
    /// use holodex::model::id::ChannelId;
    ///
    /// # if std::env::var_os("HOLODEX_API_TOKEN").is_none() {
    /// #   std::env::set_var("HOLODEX_API_TOKEN", "my-api-token");
    /// # }
    /// let token = std::env::var("HOLODEX_API_TOKEN").unwrap();
    /// let client = holodex::Client::new(&token)?;
    ///
    /// let channel_id: ChannelId = "UChAnqc_AY5_I3Px5dig3X1Q".parse()?;
    /// let collabs = channel_id.collabs(&client)?;
    ///
    /// for collab in collabs {
    ///     println!("{}", collab.title);
    /// }
    /// # Ok::<(), holodex::errors::Error>(())
    /// ```
    ///
    /// # Errors
    /// Will return [`Error::ApiRequestFailed`] if sending the API request fails.
    ///
    /// Will return [`Error::InvalidResponse`] if the API returned a faulty response or server error.
    pub fn collabs(&self, client: &Client) -> Result<PaginatedResult<Video>, Error> {
        client.videos_from_channel(
            self,
            ChannelVideoType::Clips,
            &ChannelVideoFilter {
                paginated: false,
                ..ChannelVideoFilter::default()
            },
        )
    }

    #[cfg(feature = "streams")]
    /// Returns a stream of all collabs from other videos that have mentioned this channel.
    ///
    /// /// Print the latest 50 collabs with Subaru.
    /// ```rust
    /// # fn main() -> Result<(), holodex::errors::Error> {
    /// # tokio_test::block_on(async {
    /// use holodex::model::id::ChannelId;
    /// use futures::{self, pin_mut, StreamExt, TryStreamExt};
    ///
    /// # if std::env::var_os("HOLODEX_API_TOKEN").is_none() {
    /// #   std::env::set_var("HOLODEX_API_TOKEN", "my-api-token");
    /// # }
    /// let token = std::env::var("HOLODEX_API_TOKEN").unwrap();
    /// let client = holodex::Client::new(&token)?;
    ///
    /// let channel_id: ChannelId = "UCvzGlP9oQwU--Y0r9id_jnA".parse()?;
    ///
    /// let stream = channel_id.collab_stream(&client).take(50);
    /// pin_mut!(stream);
    ///
    /// while let Some(collab) = stream.try_next().await? {
    ///     println!("{}", collab.title);
    /// }
    /// # Ok(())
    /// # })
    /// # }
    pub fn collab_stream(self, client: &Client) -> impl Stream<Item = Result<Video, Error>> + '_ {
        Self::stream_channel_video_type(client, self, ChannelVideoType::Collabs)
    }

    #[cfg(feature = "streams")]
    #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
    fn stream_channel_video_type(
        client: &Client,
        channel_id: ChannelId,
        video_type: ChannelVideoType,
    ) -> impl Stream<Item = Result<Video, Error>> + '_ {
        let (mut async_sender, async_receiver) = async_stream::yielder::pair();

        async_stream::AsyncStream::new(async_receiver, async move {
            const CHUNK_SIZE: u32 = 50;

            let mut filter = ChannelVideoFilter {
                paginated: true,
                limit: CHUNK_SIZE,
                ..ChannelVideoFilter::default()
            };
            let mut counter = 0_u32;

            while let PaginatedResult::Page { total, items } =
                match client.videos_from_channel(&channel_id, video_type, &filter) {
                    Ok(v) => v,
                    Err(e) => {
                        async_sender.send(Err(e)).await;
                        return;
                    }
                }
            {
                counter += items.len() as u32;
                let total: u32 = total.into();

                for video in items {
                    async_sender.send(Ok(video)).await;
                }

                if counter >= total {
                    break;
                }

                filter.offset += CHUNK_SIZE as i32;
            }
        })
    }
}

impl Display for ChannelId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl Deref for ChannelId {
    type Target = str;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

impl TryFrom<String> for ChannelId {
    type Error = Error;

    fn try_from(s: String) -> Result<Self, Self::Error> {
        s.parse()
    }
}

impl FromStr for ChannelId {
    type Err = Error;

    #[allow(clippy::unwrap_in_result)]
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        #[allow(clippy::expect_used)]
        let regex = Regex::new(r"UC[0-9a-zA-Z_-]{21}[AQgw]").expect("Channel ID regex broke.");

        Ok(Self(
            regex
                .find(s)
                .ok_or_else(|| Error::InvalidChannelId(s.to_owned()))?
                .as_str()
                .into(),
        ))
    }
}