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#[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 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#[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#[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#[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#[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#[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}