dynasty 1.4.1

Dynasty Reader's CLI downloader
Documentation
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()));

            // we put previous_item before the choices and next_item after the choices
            // but we also flatten the iterator here, so they won't be showed at all
            // if their condition is not met
            // we also truncate the title because the select prompt will looks weird
            // if there is an overflowed choice
            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 {
                // substract by one if previous item is added to choices
                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()))
}