dynasty_api/
directory_list.rs

1use serde::{Deserialize, Serialize};
2
3use crate::{
4    directory::{DirectoryItem, DirectoryKind},
5    tag::TagItem,
6    utils::name_to_permalink,
7    DynastyReaderRoute, DYNASTY_READER_BASE,
8};
9
10/// A configuration to get a [DirectoryList]
11#[allow(missing_docs)]
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct DirectoryListConfig {
14    pub name: String,
15    pub kind: DirectoryKind,
16    pub view_kind: Option<DirectoryListViewKind>,
17    pub page_number: u64,
18}
19
20impl DirectoryListConfig {
21    /// Replaces this [DirectoryListConfig] `view_kind`
22    pub fn with_view_kind(self, view_kind: Option<DirectoryListViewKind>) -> DirectoryListConfig {
23        DirectoryListConfig {
24            name: self.name,
25            kind: self.kind,
26            view_kind,
27            page_number: self.page_number,
28        }
29    }
30}
31
32impl From<DirectoryItem> for DirectoryListConfig {
33    fn from(item: DirectoryItem) -> Self {
34        DirectoryListConfig {
35            name: item.name,
36            kind: item.kind,
37            view_kind: None,
38            page_number: 1,
39        }
40    }
41}
42
43impl From<TagItem> for DirectoryListConfig {
44    fn from(item: TagItem) -> Self {
45        DirectoryListConfig {
46            name: item.name,
47            kind: item.kind,
48            view_kind: None,
49            page_number: 1,
50        }
51    }
52}
53
54#[cfg(feature = "search")]
55impl TryFrom<crate::search::SearchItem> for DirectoryListConfig {
56    type Error = anyhow::Error;
57
58    fn try_from(value: crate::search::SearchItem) -> Result<Self, Self::Error> {
59        if let crate::search::SearchCategory::Directory(kind) = value.kind {
60            Ok(DirectoryListConfig {
61                kind,
62                name: value.title,
63                page_number: 1,
64                view_kind: None,
65            })
66        } else {
67            Err(anyhow::anyhow!("this search item is a chapter"))
68        }
69    }
70}
71
72impl DynastyReaderRoute for DirectoryListConfig {
73    fn request_builder(
74        &self,
75        client: &reqwest::Client,
76        url: reqwest::Url,
77    ) -> reqwest::RequestBuilder {
78        let builder = client.get(url).query(&[("page", self.page_number)]);
79
80        if let Some(view_kind) = self.view_kind {
81            builder.query(&[("view", view_kind.to_string())])
82        } else {
83            builder
84        }
85    }
86
87    fn request_url(&self) -> reqwest::Url {
88        let permalink = name_to_permalink(&self.name);
89
90        DYNASTY_READER_BASE
91            .join(&format!("{}/{}.json", self.kind, permalink))
92            .unwrap()
93    }
94}
95
96/// A Dynasty Reader directory list view mode, see view tabs at <https://dynasty-scans.com/doujins/a_certain_scientific_railgun> for references
97#[allow(missing_docs)]
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum DirectoryListViewKind {
100    Chapters,
101    Groupings,
102    OneShots,
103    Pairings,
104}
105
106impl std::fmt::Display for DirectoryListViewKind {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        let s = {
109            use DirectoryListViewKind::*;
110
111            match self {
112                Chapters => "chapters",
113                Groupings => "groupings",
114                OneShots => "one_shots",
115                Pairings => "pairings",
116            }
117        };
118
119        write!(f, "{s}")
120    }
121}
122
123/// A wrapper around Dynasty Reader's directory list
124///
125/// # Example urls
126///
127/// - <https://dynasty-scans.com/doujins/a_certain_scientific_railgun>
128/// - <https://dynasty-scans.com/tags/aaaaaangst>
129#[allow(missing_docs)]
130#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
131pub struct DirectoryList {
132    pub name: String,
133    pub kind: DirectoryKind,
134    pub permalink: String,
135    pub tags: Vec<TagItem>,
136    pub status: Option<DirectoryListStatus>,
137    pub cover: Option<String>,
138    pub link: Option<String>,
139    pub description: Option<String>,
140    pub aliases: Vec<String>,
141    pub items: Vec<DirectoryListItem>,
142    pub chapter_items: Vec<DirectoryListChapterItem>,
143    pub page_number: u64,
144    pub max_page_number: u64,
145}
146
147impl<'de> Deserialize<'de> for DirectoryList {
148    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
149    where
150        D: serde::Deserializer<'de>,
151    {
152        #[derive(Deserialize)]
153        #[serde(untagged)]
154        enum DirectoryListChapterItemWrapper {
155            Chapter {
156                title: String,
157                permalink: String,
158                released_on: String,
159                tags: Vec<TagItem>,
160            },
161            Header {
162                header: Option<String>,
163            },
164        }
165
166        #[derive(Deserialize)]
167        #[serde(untagged)]
168        enum TagWrapper {
169            Default(TagItem),
170            Status(DirectoryListStatus),
171        }
172
173        #[derive(Deserialize)]
174        struct DirectoryListWrapper {
175            name: String,
176            #[serde(alias = "type")]
177            kind: DirectoryKind,
178            permalink: String,
179            tags: Vec<TagWrapper>,
180            #[serde(deserialize_with = "crate::utils::join_path_with_dynasty_reader_base")]
181            cover: String,
182            link: Option<String>,
183            description: Option<String>,
184            aliases: Vec<String>,
185            #[serde(default)]
186            taggables: Vec<DirectoryListItem>,
187            #[serde(default)]
188            taggings: Vec<DirectoryListChapterItemWrapper>,
189            current_page: Option<u64>,
190            total_pages: Option<u64>,
191        }
192
193        let wrapper: DirectoryListWrapper = Deserialize::deserialize(deserializer)?;
194
195        let DirectoryListWrapper {
196            name,
197            kind,
198            permalink,
199            tags,
200            cover,
201            link,
202            description,
203            aliases,
204            taggables: items,
205            taggings: chapter_items,
206            current_page: page_number,
207            total_pages: max_page_number,
208        } = wrapper;
209
210        let page_number = page_number.unwrap_or(1);
211        let max_page_number = max_page_number.unwrap_or(1);
212        let cover = if cover.is_empty() { None } else { Some(cover) };
213        let status = tags.iter().find_map(|tag| match tag {
214            TagWrapper::Status(s) => Some(s.clone()),
215            _ => None,
216        });
217        let tags = tags
218            .iter()
219            .filter_map(|tag| match tag {
220                TagWrapper::Default(s) => Some(s.clone()),
221                _ => None,
222            })
223            .collect();
224        let chapter_items = {
225            let mut current_header: Option<String> = None;
226            chapter_items.into_iter().fold(vec![], |mut current, item| {
227                use DirectoryListChapterItemWrapper::*;
228
229                match item {
230                    Chapter {
231                        title,
232                        permalink,
233                        released_on,
234                        tags,
235                    } => current.push(DirectoryListChapterItem {
236                        header: current_header.clone(),
237                        permalink,
238                        released_on,
239                        title,
240                        tags,
241                    }),
242                    Header { header } => current_header = header,
243                }
244
245                current
246            })
247        };
248
249        Ok(DirectoryList {
250            name,
251            kind,
252            permalink,
253            tags,
254            status,
255            cover,
256            link,
257            description,
258            aliases,
259            items,
260            chapter_items,
261            page_number,
262            max_page_number,
263        })
264    }
265}
266
267/// A Dynasty Reader's directory list status
268#[allow(missing_docs)]
269#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
270pub enum DirectoryListStatus {
271    Abandoned,
272    Cancelled,
273    Completed,
274    Licensed,
275    Ongoing,
276    Unknown,
277}
278
279impl<'de> Deserialize<'de> for DirectoryListStatus {
280    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
281    where
282        D: serde::Deserializer<'de>,
283    {
284        #[derive(Deserialize)]
285        struct DirectoryListStatusWrapper<'a> {
286            name: &'a str,
287        }
288
289        let wrapper: DirectoryListStatusWrapper = Deserialize::deserialize(deserializer)?;
290
291        Ok({
292            use DirectoryListStatus::*;
293
294            match wrapper.name {
295                "Abandoned" => Abandoned,
296                "Cancelled" => Cancelled,
297                "Completed" => Completed,
298                "Licensed" => Licensed,
299                "Ongoing" => Ongoing,
300                _ => Unknown,
301            }
302        })
303    }
304}
305
306/// A Dynasty Reader's directory list item
307#[allow(missing_docs)]
308#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
309pub struct DirectoryListItem {
310    pub name: String,
311    pub kind: DirectoryKind,
312    pub permalink: String,
313    pub cover: Option<String>,
314}
315
316impl<'de> Deserialize<'de> for DirectoryListItem {
317    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
318    where
319        D: serde::Deserializer<'de>,
320    {
321        #[derive(Deserialize)]
322        struct DirectoryListItemWrapper {
323            name: String,
324            #[serde(alias = "type")]
325            kind: DirectoryKind,
326            permalink: String,
327            #[serde(deserialize_with = "crate::utils::join_path_with_dynasty_reader_base")]
328            cover: String,
329        }
330
331        let wrapper: DirectoryListItemWrapper = Deserialize::deserialize(deserializer)?;
332
333        let DirectoryListItemWrapper {
334            name,
335            kind,
336            permalink,
337            cover,
338        } = wrapper;
339
340        let cover = if cover.is_empty() { None } else { Some(cover) };
341
342        Ok(DirectoryListItem {
343            name,
344            kind,
345            permalink,
346            cover,
347        })
348    }
349}
350
351/// A Dynasty Reader's directory list chapter item
352#[allow(missing_docs)]
353#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
354pub struct DirectoryListChapterItem {
355    pub title: String,
356    pub header: Option<String>,
357    pub permalink: String,
358    pub released_on: String,
359    pub tags: Vec<TagItem>,
360}
361
362#[cfg(test)]
363mod tests {
364    use anyhow::Result;
365
366    use crate::test_utils::tryhard_configs;
367
368    use super::*;
369
370    fn create_config(
371        c: (
372            DirectoryKind,
373            &str,
374            impl Into<Option<DirectoryListViewKind>>,
375        ),
376    ) -> DirectoryListConfig {
377        DirectoryListConfig {
378            name: c.1.to_string(),
379            kind: c.0,
380            page_number: 1,
381            view_kind: c.2.into(),
382        }
383    }
384
385    fn create_config_with_superset_view_kind(c: (DirectoryKind, &str)) -> Vec<DirectoryListConfig> {
386        [
387            DirectoryListViewKind::Chapters,
388            DirectoryListViewKind::Groupings,
389            DirectoryListViewKind::OneShots,
390            DirectoryListViewKind::Pairings,
391        ]
392        .map(|view_kind| create_config((c.0, c.1, view_kind)))
393        .to_vec()
394    }
395
396    #[tokio::test]
397    #[ignore = "requires internet"]
398    async fn response_structure() -> Result<()> {
399        let configs = [
400            (DirectoryKind::Doujin, "Bloom Into You"),
401            (DirectoryKind::Pairing, "Homura x Madoka"),
402            (DirectoryKind::Tag, "Aaaaaangst"),
403        ]
404        .map(create_config_with_superset_view_kind)
405        .into_iter()
406        .flatten()
407        .collect::<Vec<_>>();
408
409        tryhard_configs(configs, |client, config| client.directory_list(config)).await?;
410
411        Ok(())
412    }
413
414    #[tokio::test]
415    #[ignore = "requires internet"]
416    async fn viewless_response_structure() -> Result<()> {
417        let configs = [
418            (DirectoryKind::Anthology, "And Then, To You", None),
419            (DirectoryKind::Author, "Nakatani Nio", None),
420            (DirectoryKind::Issue, "Aya Yuri Vol 11", None),
421            (DirectoryKind::Scanlator, "/u/ Scanlations", None),
422            (
423                DirectoryKind::Series,
424                "Arknights Official Comic Anthology",
425                None,
426            ),
427        ]
428        .map(create_config);
429
430        tryhard_configs(configs, |client, config| client.directory_list(config)).await?;
431
432        Ok(())
433    }
434}