dynasty_api/search/
mod.rs

1/// Dynasty Reader's search suggestion
2pub mod suggestion;
3
4mod parser;
5
6use serde::{Deserialize, Serialize};
7use tl::ParserOptions;
8
9use crate::{directory::DirectoryKind, DynastyReaderRoute, TagItem, DYNASTY_READER_BASE};
10
11use self::suggestion::SearchSuggestion;
12
13/// A configuration to get a [Search]
14#[allow(missing_docs)]
15#[derive(Debug, Default, Clone, PartialEq, Eq)]
16pub struct SearchConfig {
17    pub query: String,
18    pub page_number: u64,
19    pub sort: Option<SearchSort>,
20    pub categories: Vec<SearchCategory>,
21    pub with_tags: Vec<SearchTag>,
22    pub without_tags: Vec<SearchTag>,
23}
24
25impl From<String> for SearchConfig {
26    fn from(s: String) -> Self {
27        SearchConfig {
28            query: s,
29            page_number: 1,
30            sort: None,
31            categories: vec![],
32            with_tags: vec![],
33            without_tags: vec![],
34        }
35    }
36}
37
38impl DynastyReaderRoute for SearchConfig {
39    fn request_builder(
40        &self,
41        client: &reqwest::Client,
42        url: reqwest::Url,
43    ) -> reqwest::RequestBuilder {
44        let mut queries = Vec::with_capacity(
45            // 3 is for search query, sort, and page number
46            3 + self.categories.len() + self.with_tags.len() + self.without_tags.len(),
47        );
48
49        queries.extend([
50            // search query
51            ("q", self.query.clone()),
52            // search sort
53            ("sort", self.sort.unwrap_or_default().to_string()),
54            // page number
55            ("page", self.page_number.to_string()),
56        ]);
57
58        // search categories
59        queries.extend(
60            self.categories
61                .iter()
62                .map(|kind| ("classes[]", kind.to_string())),
63        );
64
65        // search tags filters
66        queries.extend(
67            self.with_tags
68                .iter()
69                .map(|tag| ("with[]", tag.0.to_string())),
70        );
71        queries.extend(
72            self.without_tags
73                .iter()
74                .map(|tag| ("without[]", tag.0.to_string())),
75        );
76
77        client.get(url).query(&queries)
78    }
79
80    fn request_url(&self) -> reqwest::Url {
81        DYNASTY_READER_BASE.join("search").unwrap()
82    }
83}
84
85/// A Dynasty Reader's search categories
86#[allow(missing_docs)]
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
88pub enum SearchCategory {
89    Chapter,
90    Directory(DirectoryKind),
91}
92
93impl std::fmt::Display for SearchCategory {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        let s = match self {
96            SearchCategory::Chapter => "Chapter",
97            SearchCategory::Directory(kind) => {
98                use DirectoryKind::*;
99
100                match kind {
101                    Anthology => "Anthology",
102                    Doujin => "Doujin",
103                    Issue => "Issue",
104                    Series => "Series",
105                    Author => "Author",
106                    Scanlator => "Scanlator",
107                    Tag => "General",
108                    Pairing => "Pairing",
109                }
110            }
111        };
112
113        write!(f, "{s}")
114    }
115}
116
117/// A Dynasty Reader's search sort methods
118#[allow(missing_docs)]
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum SearchSort {
121    Alphabetical,
122    BestMatch,
123    DateAdded,
124    ReleaseDate,
125}
126
127impl std::default::Default for SearchSort {
128    fn default() -> Self {
129        SearchSort::BestMatch
130    }
131}
132
133impl std::fmt::Display for SearchSort {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        let s = {
136            use SearchSort::*;
137
138            match self {
139                Alphabetical => "name",
140                BestMatch => "",
141                DateAdded => "created_at",
142                ReleaseDate => "released_on",
143            }
144        };
145
146        write!(f, "{s}")
147    }
148}
149
150/// A Dynasty Reader's tag ID used for search filtering
151///
152/// As far as I know, the only way to retrieve this is through [SearchSuggestion]
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub struct SearchTag(u64);
155
156impl From<SearchSuggestion> for SearchTag {
157    fn from(item: SearchSuggestion) -> Self {
158        SearchTag(item.id)
159    }
160}
161
162/// A wrapper around Dynasty Reader's search results
163///
164/// <https://dynasty-scans.com/search>
165#[allow(missing_docs)]
166#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
167pub struct Search {
168    pub items: Vec<SearchItem>,
169    pub page_number: u64,
170    pub max_page_number: u64,
171}
172
173impl std::str::FromStr for Search {
174    type Err = anyhow::Error;
175
176    fn from_str(s: &str) -> Result<Self, Self::Err> {
177        let dom = tl::parse(s, ParserOptions::new().track_classes())?;
178        let parser = dom.parser();
179
180        let items = parser::parse_items(&dom, parser)?;
181        let (page_number, max_page_number) = parser::parse_page_numbers(&dom, parser)?;
182
183        Ok(Search {
184            items,
185            page_number,
186            max_page_number,
187        })
188    }
189}
190
191/// A Dynasty Reader's search results item
192#[allow(missing_docs)]
193#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
194pub struct SearchItem {
195    pub title: String,
196    pub kind: SearchCategory,
197    pub permalink: String,
198    pub tags: Vec<TagItem>,
199}
200
201#[cfg(test)]
202mod tests {
203    use anyhow::Result;
204
205    use crate::{test_utils::tryhard_configs, DynastyApi};
206
207    use super::*;
208
209    #[tokio::test]
210    #[ignore = "requires internet"]
211    async fn response_structure() -> Result<()> {
212        let configs = {
213            use SearchSort::*;
214
215            [Alphabetical, BestMatch, DateAdded, ReleaseDate].map(|sort| SearchConfig {
216                query: "a".to_string(),
217                page_number: 1,
218                sort: Some(sort),
219                ..Default::default()
220            })
221        };
222
223        tryhard_configs(configs, |client, config| client.search(config)).await?;
224
225        Ok(())
226    }
227
228    async fn check_response(
229        client: &DynastyApi,
230        (config, check): (SearchConfig, impl Fn(SearchItem)),
231    ) -> Result<()> {
232        client
233            .search(config)
234            .await
235            .map(|Search { items, .. }| items.into_iter().for_each(check))
236    }
237
238    #[tokio::test]
239    #[ignore = "requires internet"]
240    async fn filtered_response_structure() -> Result<()> {
241        let categories = {
242            use DirectoryKind::*;
243            use SearchCategory::*;
244
245            [
246                Chapter,
247                Directory(Anthology),
248                Directory(Doujin),
249                Directory(Issue),
250                Directory(Series),
251                Directory(Author),
252                Directory(Scanlator),
253                Directory(Tag),
254                Directory(Pairing),
255            ]
256            .map(|category| {
257                (
258                    SearchConfig {
259                        page_number: 1,
260                        categories: vec![category],
261                        ..Default::default()
262                    },
263                    move |item: SearchItem| {
264                        assert_eq!(
265                            item.kind, category,
266                            "category: {category} result should not contains other category"
267                        )
268                    },
269                )
270            })
271        };
272        tryhard_configs(categories, check_response).await?;
273
274        let with_tags = [
275            (5175, DirectoryKind::Tag, "aaaaaangst"),
276            (18109, DirectoryKind::Author, "manio"),
277            (16084, DirectoryKind::Doujin, "bloom_into_you"),
278        ]
279        .map(|(t, kind, permalink)| {
280            (
281                SearchConfig {
282                    page_number: 1,
283                    with_tags: vec![SearchTag(t)],
284                    ..Default::default()
285                },
286                move |item: SearchItem| {
287                    assert!(
288                        item.tags
289                            .iter()
290                            .any(|tag| kind == tag.kind && permalink == tag.permalink),
291                        "with_tags: {t} should contains {kind} {permalink}"
292                    )
293                },
294            )
295        });
296        tryhard_configs(with_tags, check_response).await?;
297
298        let without_tags = [
299            (5182, DirectoryKind::Tag, "love_triangle"),
300            (9811, DirectoryKind::Tag, "guro"),
301            (5367, DirectoryKind::Tag, "vampire"),
302        ]
303        .map(|(t, kind, permalink)| {
304            (
305                SearchConfig {
306                    page_number: 1,
307                    without_tags: vec![SearchTag(t)],
308                    ..Default::default()
309                },
310                move |item: SearchItem| {
311                    assert!(
312                        !item
313                            .tags
314                            .iter()
315                            .any(|tag| kind == tag.kind && permalink == tag.permalink),
316                        "without_tags: {t} should not contains {kind} {permalink}"
317                    )
318                },
319            )
320        });
321        tryhard_configs(without_tags, check_response).await?;
322
323        Ok(())
324    }
325}