mod cache_manager;
use std::{future::Future, sync::Arc};
use anyhow::Result;
use console::truncate_str;
use dynasty_api::{
chapter::Chapter, directory_list::DirectoryList, DirectoryListConfig, DynastyApi,
};
use indicatif::{ProgressBar, ProgressStyle};
use serde::{de::DeserializeOwned, Serialize};
use tokio::sync::Semaphore;
use crate::{
downloader::{Downloadable, DownloadableChapter},
parser::{ParsedArgumentValue, TagValue},
runner::{DefaultRetryConfig, RunnerConfig},
styles::{
DOTS_SPINNER_CHARS, DOTS_SPINNER_INTERVAL, MESSAGE_COLOR, PROGRESS_COLOR, SPINNER_COLOR,
},
Cli,
};
use self::cache_manager::CacheManager;
static RESOLVER_MAX_MESSAGE_LENGTH: usize = 55;
#[derive(Clone, Debug)]
struct Resolver {
dynasty: DynastyApi,
cache_manager: Option<CacheManager>,
semaphore: Arc<Semaphore>,
progress_bar: ProgressBar,
retry_config: DefaultRetryConfig,
}
impl Resolver {
fn new(
dynasty: DynastyApi,
cache_manager: Option<CacheManager>,
config: &RunnerConfig,
) -> Self {
let style = ProgressStyle::with_template(&format!(
"{} {} {}",
SPINNER_COLOR.apply_to("{spinner}"),
MESSAGE_COLOR.apply_to("{msg:^55}"),
PROGRESS_COLOR.apply_to("{percent:>3}%")
))
.unwrap()
.tick_chars(DOTS_SPINNER_CHARS);
let progress_bar = ProgressBar::new(1).with_style(style);
progress_bar.enable_steady_tick(DOTS_SPINNER_INTERVAL);
Resolver {
dynasty,
progress_bar,
cache_manager,
semaphore: config.semaphore.clone(),
retry_config: config.retry_config,
}
}
async fn resolve_chapters(
&self,
values: Vec<String>,
title: Option<String>,
) -> Result<Downloadable> {
let length = values.len();
self.progress_bar.set_length(length as u64);
let mut handles = Vec::with_capacity(length);
for value in values {
let resolver = self.clone();
let permit = resolver.semaphore.clone().acquire_owned().await?;
handles.push(tokio::spawn(async move {
let chapter = resolver
.with_cache(&value, async {
tryhard::retry_fn(|| resolver.dynasty.chapter(value.clone().into()))
.with_config(resolver.retry_config)
.await
.map(|Chapter { title, pages, .. }| {
let pages = pages.into_iter().map(|page| page.url).collect();
DownloadableChapter { title, pages }
})
})
.await?;
resolver.progress_bar.inc(1);
drop(permit);
anyhow::Ok(chapter)
}))
}
let mut chapters = Vec::with_capacity(length);
for handle in handles {
chapters.push(handle.await??)
}
Ok(Downloadable { title, chapters })
}
#[cfg(feature = "search")]
async fn resolve_search(
&self,
value: crate::parser::SearchValue,
) -> Result<ParsedArgumentValue> {
use dynasty::utils::truncate_dynasty_chapter_title;
use dynasty_api::{
directory::DirectoryKind,
search::{Search, SearchCategory},
SearchConfig,
};
use terminal_size::terminal_size;
use crate::parser::SearchValue;
let SearchValue {
query,
sort,
categories,
} = value;
let identifier = format!(
"search/{}/{}/{}",
&query,
sort.unwrap_or_default(),
categories
.iter()
.map(ToString::to_string)
.collect::<String>()
);
let mut current_page = 1;
loop {
let (max_page, mut items) = self
.with_cache(&format!("{}/{}", &identifier, current_page), async {
tryhard::retry_fn(|| {
self.dynasty.search(SearchConfig {
query: query.clone(),
page_number: current_page,
categories: categories.clone(),
sort,
..Default::default()
})
})
.with_config(self.retry_config)
.await
.map(
|Search {
items,
max_page_number,
page_number,
}| {
current_page = page_number;
let items = items
.into_iter()
.map(|item| {
let prefix = if matches!(
item.kind,
SearchCategory::Directory(DirectoryKind::Tag)
) {
"Tag".to_string()
} else {
item.kind.to_string()
};
let kind = if let SearchCategory::Directory(kind) = item.kind {
Some(kind)
} else {
None
};
(
format!("{prefix}: {title}", title = item.title),
item.permalink,
kind,
)
})
.collect::<Vec<_>>();
(max_page_number, items)
},
)
})
.await?;
let fmt_pagination = |index: u64| format!("--page {}", index);
let previous_item = (current_page > 1).then(|| fmt_pagination(current_page - 1));
let next_item = (current_page < max_page).then(|| fmt_pagination(current_page + 1));
let choices_iter = items.iter().map(|(title, ..)| Some(title.as_str()));
let terminal_width = terminal_size().map(|(width, ..)| (width.0.max(8) as usize) - 7);
let choices = [previous_item.as_deref()]
.into_iter()
.chain(choices_iter)
.chain([next_item.as_deref()])
.flatten()
.map(|s| {
if let Some(width) = terminal_width {
truncate_dynasty_chapter_title(s, width)
} else {
s.to_string()
}
})
.collect::<Vec<_>>();
let default_selection = if previous_item.is_some() { 1 } else { 0 };
let selection_index = self.progress_bar.suspend(|| {
dialoguer::Select::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(format!(
"query: `{}` page: {} max-page: {}",
&query, current_page, max_page
))
.items(&choices)
.default(default_selection)
.report(false)
.interact()
})?;
let selected_text = choices[selection_index].as_str();
if Some(selected_text) == previous_item.as_deref() {
current_page -= 1
} else if Some(selected_text) == next_item.as_deref() {
current_page += 1
} else {
let actual_index = if previous_item.is_some() {
selection_index - 1
} else {
selection_index
};
let (_, permalink, directory_kind) = items.swap_remove(actual_index);
break Ok(if let Some(kind) = directory_kind {
ParsedArgumentValue::Tag(TagValue { kind, permalink })
} else {
ParsedArgumentValue::Chapter(permalink)
});
}
}
}
async fn resolve_tag(&self, value: TagValue) -> Result<Downloadable> {
let (title, chapters) = self
.with_cache(&value.to_string(), async {
tryhard::retry_fn(|| {
self.dynasty.directory_list(DirectoryListConfig {
kind: value.kind,
name: value.permalink.clone(),
page_number: 1,
view_kind: None,
})
})
.with_config(self.retry_config)
.await
.map(
|DirectoryList {
name,
chapter_items,
..
}| {
let chapters = chapter_items
.into_iter()
.map(|item| item.permalink)
.collect();
(name, chapters)
},
)
})
.await?;
self.resolve_chapters(chapters, Some(title)).await
}
async fn with_cache<T: DeserializeOwned + Serialize>(
&self,
cache_identifier: &str,
future: impl Future<Output = Result<T>>,
) -> Result<T> {
if let Some(cache_manager) = &self.cache_manager {
cache_manager.get_or_init(cache_identifier, future).await
} else {
future.await
}
}
}
pub async fn resolve(
value: ParsedArgumentValue,
cli: &Cli,
config: &RunnerConfig,
) -> Result<(Downloadable, reqwest::Client)> {
let dynasty = DynastyApi::new();
let cache_manager = (!cli.no_cache).then_some(CacheManager::new()?);
let resolver = Resolver::new(dynasty.clone(), cache_manager, config);
#[cfg(feature = "search")]
let value = if let ParsedArgumentValue::Search(search_value) = &value {
resolver.progress_bar.set_message(
truncate_str(
&format!("searching `{}`", search_value.query),
RESOLVER_MAX_MESSAGE_LENGTH,
"..`",
)
.to_string(),
);
resolver.resolve_search(search_value.clone()).await?
} else {
value
};
resolver.progress_bar.set_message(
truncate_str(
&format!("resolving `{}`", value),
RESOLVER_MAX_MESSAGE_LENGTH,
"..`",
)
.to_string(),
);
let downloadable = match value {
ParsedArgumentValue::Chapter(value) => resolver.resolve_chapters(vec![value], None).await?,
ParsedArgumentValue::Tag(value) => resolver.resolve_tag(value).await?,
#[cfg(feature = "search")]
ParsedArgumentValue::Search(_) => unreachable!(),
};
resolver.progress_bar.finish_and_clear();
Ok((downloadable, dynasty.clone_reqwest_client()))
}