dynasty-api 1.1.0

Dynasty Reader's wrappers
Documentation
/// Dynasty Reader's search suggestion
pub mod suggestion;

mod parser;

use serde::{Deserialize, Serialize};
use tl::ParserOptions;

use crate::{directory::DirectoryKind, DynastyReaderRoute, TagItem, DYNASTY_READER_BASE};

use self::suggestion::SearchSuggestion;

/// A configuration to get a [Search]
#[allow(missing_docs)]
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct SearchConfig {
    pub query: String,
    pub page_number: u64,
    pub sort: Option<SearchSort>,
    pub categories: Vec<SearchCategory>,
    pub with_tags: Vec<SearchTag>,
    pub without_tags: Vec<SearchTag>,
}

impl From<String> for SearchConfig {
    fn from(s: String) -> Self {
        SearchConfig {
            query: s,
            page_number: 1,
            sort: None,
            categories: vec![],
            with_tags: vec![],
            without_tags: vec![],
        }
    }
}

impl DynastyReaderRoute for SearchConfig {
    fn request_builder(
        &self,
        client: &reqwest::Client,
        url: reqwest::Url,
    ) -> reqwest::RequestBuilder {
        let mut queries = Vec::with_capacity(
            // 3 is for search query, sort, and page number
            3 + self.categories.len() + self.with_tags.len() + self.without_tags.len(),
        );

        queries.extend([
            // search query
            ("q", self.query.clone()),
            // search sort
            ("sort", self.sort.unwrap_or_default().to_string()),
            // page number
            ("page", self.page_number.to_string()),
        ]);

        // search categories
        queries.extend(
            self.categories
                .iter()
                .map(|kind| ("classes[]", kind.to_string())),
        );

        // search tags filters
        queries.extend(
            self.with_tags
                .iter()
                .map(|tag| ("with[]", tag.0.to_string())),
        );
        queries.extend(
            self.without_tags
                .iter()
                .map(|tag| ("without[]", tag.0.to_string())),
        );

        client.get(url).query(&queries)
    }

    fn request_url(&self) -> reqwest::Url {
        DYNASTY_READER_BASE.join("search").unwrap()
    }
}

/// A Dynasty Reader's search categories
#[allow(missing_docs)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
pub enum SearchCategory {
    Chapter,
    Directory(DirectoryKind),
}

impl std::fmt::Display for SearchCategory {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let s = match self {
            SearchCategory::Chapter => "Chapter",
            SearchCategory::Directory(kind) => {
                use DirectoryKind::*;

                match kind {
                    Anthology => "Anthology",
                    Doujin => "Doujin",
                    Issue => "Issue",
                    Series => "Series",
                    Author => "Author",
                    Scanlator => "Scanlator",
                    Tag => "General",
                    Pairing => "Pairing",
                }
            }
        };

        write!(f, "{s}")
    }
}

/// A Dynasty Reader's search sort methods
#[allow(missing_docs)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchSort {
    Alphabetical,
    BestMatch,
    DateAdded,
    ReleaseDate,
}

impl std::default::Default for SearchSort {
    fn default() -> Self {
        SearchSort::BestMatch
    }
}

impl std::fmt::Display for SearchSort {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let s = {
            use SearchSort::*;

            match self {
                Alphabetical => "name",
                BestMatch => "",
                DateAdded => "created_at",
                ReleaseDate => "released_on",
            }
        };

        write!(f, "{s}")
    }
}

/// A Dynasty Reader's tag ID used for search filtering
///
/// As far as I know, the only way to retrieve this is through [SearchSuggestion]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SearchTag(u64);

impl From<SearchSuggestion> for SearchTag {
    fn from(item: SearchSuggestion) -> Self {
        SearchTag(item.id)
    }
}

/// A wrapper around Dynasty Reader's search results
///
/// <https://dynasty-scans.com/search>
#[allow(missing_docs)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct Search {
    pub items: Vec<SearchItem>,
    pub page_number: u64,
    pub max_page_number: u64,
}

impl std::str::FromStr for Search {
    type Err = anyhow::Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let dom = tl::parse(s, ParserOptions::new().track_classes())?;
        let parser = dom.parser();

        let items = parser::parse_items(&dom, parser)?;
        let (page_number, max_page_number) = parser::parse_page_numbers(&dom, parser)?;

        Ok(Search {
            items,
            page_number,
            max_page_number,
        })
    }
}

/// A Dynasty Reader's search results item
#[allow(missing_docs)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct SearchItem {
    pub title: String,
    pub kind: SearchCategory,
    pub permalink: String,
    pub tags: Vec<TagItem>,
}

#[cfg(test)]
mod tests {
    use anyhow::Result;

    use crate::{test_utils::tryhard_configs, DynastyApi};

    use super::*;

    #[tokio::test]
    #[ignore = "requires internet"]
    async fn response_structure() -> Result<()> {
        let configs = {
            use SearchSort::*;

            [Alphabetical, BestMatch, DateAdded, ReleaseDate].map(|sort| SearchConfig {
                query: "a".to_string(),
                page_number: 1,
                sort: Some(sort),
                ..Default::default()
            })
        };

        tryhard_configs(configs, |client, config| client.search(config)).await?;

        Ok(())
    }

    async fn check_response(
        client: &DynastyApi,
        (config, check): (SearchConfig, impl Fn(SearchItem)),
    ) -> Result<()> {
        client
            .search(config)
            .await
            .map(|Search { items, .. }| items.into_iter().for_each(check))
    }

    #[tokio::test]
    #[ignore = "requires internet"]
    async fn filtered_response_structure() -> Result<()> {
        let categories = {
            use DirectoryKind::*;
            use SearchCategory::*;

            [
                Chapter,
                Directory(Anthology),
                Directory(Doujin),
                Directory(Issue),
                Directory(Series),
                Directory(Author),
                Directory(Scanlator),
                Directory(Tag),
                Directory(Pairing),
            ]
            .map(|category| {
                (
                    SearchConfig {
                        page_number: 1,
                        categories: vec![category],
                        ..Default::default()
                    },
                    move |item: SearchItem| {
                        assert_eq!(
                            item.kind, category,
                            "category: {category} result should not contains other category"
                        )
                    },
                )
            })
        };
        tryhard_configs(categories, check_response).await?;

        let with_tags = [
            (5175, DirectoryKind::Tag, "aaaaaangst"),
            (18109, DirectoryKind::Author, "manio"),
            (16084, DirectoryKind::Doujin, "bloom_into_you"),
        ]
        .map(|(t, kind, permalink)| {
            (
                SearchConfig {
                    page_number: 1,
                    with_tags: vec![SearchTag(t)],
                    ..Default::default()
                },
                move |item: SearchItem| {
                    assert!(
                        item.tags
                            .iter()
                            .any(|tag| kind == tag.kind && permalink == tag.permalink),
                        "with_tags: {t} should contains {kind} {permalink}"
                    )
                },
            )
        });
        tryhard_configs(with_tags, check_response).await?;

        let without_tags = [
            (5182, DirectoryKind::Tag, "love_triangle"),
            (9811, DirectoryKind::Tag, "guro"),
            (5367, DirectoryKind::Tag, "vampire"),
        ]
        .map(|(t, kind, permalink)| {
            (
                SearchConfig {
                    page_number: 1,
                    without_tags: vec![SearchTag(t)],
                    ..Default::default()
                },
                move |item: SearchItem| {
                    assert!(
                        !item
                            .tags
                            .iter()
                            .any(|tag| kind == tag.kind && permalink == tag.permalink),
                        "without_tags: {t} should not contains {kind} {permalink}"
                    )
                },
            )
        });
        tryhard_configs(without_tags, check_response).await?;

        Ok(())
    }
}