ddp2ass 0.4.2

Dandanplay 的 json 转成 ass 文件
Documentation
use anyhow::{Context, Result};
use md5;
use promkit::{
    crossterm::style::Stylize,
    preset::{query_selector::QuerySelector, readline::Readline},
    suggest::Suggest,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{
    fmt,
    fs::File,
    io::{BufReader, Read},
    path::PathBuf,
};

use crate::InputFile;

pub struct MatchParams {
    pub match_name: String,
    pub json: MatchParamsJson,
}

#[derive(Serialize, Deserialize, Clone)]
pub enum MatchMode {
    #[serde(rename = "hashAndFileName")]
    HashAndFileName,
    #[serde(rename = "fileNameOnly")]
    FileNameOnly,
    #[serde(rename = "hashOnly")]
    HashOnly,
}

#[derive(Serialize, Deserialize, Clone)]
pub struct MatchParamsJson {
    #[serde(rename = "fileName")]
    pub file_name: String,
    #[serde(rename = "fileHash")]
    pub file_hash: String,
    #[serde(rename = "fileSize")]
    pub file_size: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(rename = "matchMode")]
    pub match_mode: Option<MatchMode>,
}

#[derive(Serialize, Deserialize, Clone)]
pub struct MatchAcceptParamsJson {
    pub hash: Option<String>,
    #[serde(rename = "fileName")]
    pub file_name: Option<String>,
}

#[derive(Serialize, Deserialize, Clone)]
pub struct MatchItem {
    #[serde(rename = "episodeId")]
    pub episode_id: i64,
    #[serde(rename = "animeId")]
    pub anime_id: i64,
    #[serde(rename = "animeTitle")]
    pub anime_title: String,
    #[serde(rename = "episodeTitle")]
    pub episode_title: String,
    #[serde(rename = "type")]
    pub match_type: String,
    #[serde(rename = "typeDescription")]
    pub type_description: String,
    pub shift: f64,
}

#[derive(Serialize, Deserialize)]
pub struct MatchesJson {
    #[serde(rename = "isMatched")]
    pub is_matched: bool,
    pub matches: Vec<MatchItem>,
    #[serde(rename = "errorCode")]
    pub error_code: i64,
    pub success: bool,
    #[serde(rename = "errorMessage")]
    pub error_message: String,
}

#[derive(Serialize, Deserialize)]
pub struct SearchJson {
    #[serde(rename = "hasMore")]
    pub has_more: bool,
    #[serde(rename = "errorCode")]
    pub error_code: i64,
    pub success: bool,
    #[serde(rename = "errorMessage")]
    pub error_message: String,
    pub animes: Vec<SearchAnimeJson>,
}

#[derive(Serialize, Deserialize)]
pub struct SearchAnimeJson {
    #[serde(rename = "animeId")]
    pub anime_id: i64,
    #[serde(rename = "animeTitle")]
    pub anime_title: String,
    #[serde(rename = "type")]
    pub type_: String,
    #[serde(rename = "typeDescription")]
    pub type_description: String,
    pub episodes: Vec<SearchAnimeEpisodeJson>,
}

#[derive(Serialize, Deserialize)]
pub struct SearchAnimeEpisodeJson {
    #[serde(rename = "episodeId")]
    episode_id: i64,
    #[serde(rename = "episodeTitle")]
    episode_title: String,
}

#[derive(Clone)]
pub enum SearchOption {
    SearchEditInput(SearchEditInput),
    SearchAnimeOption(AnimeEpisodeItem),
}

#[derive(Clone)]
pub struct AnimeEpisodeItem {
    pub anime_id: i64,
    pub anime_title: String,
    pub episode_id: i64,
    pub episode_title: String,
}

#[derive(Clone)]
pub struct SearchEditInput {}

impl fmt::Display for SearchOption {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            SearchOption::SearchEditInput(_) => write!(f, "Edit Search name"),
            SearchOption::SearchAnimeOption(o) => {
                write!(f, "{} {}", o.anime_title, o.episode_title)
            }
        }
    }
}

pub struct DandanMatch {}

impl DandanMatch {
    pub fn get_file_hash(path: &PathBuf) -> Result<String> {
        let file = File::open(path)?;
        let mut reader = BufReader::new(file);
        let bytes_to_read: usize = 16 * 1024 * 1024;
        let mut buf = vec![0u8; bytes_to_read];
        reader.read(&mut buf)?;
        let hash = format!("{:x}", md5::compute(buf));
        Ok(hash)
    }

    pub fn get_match_params(input_file: &InputFile) -> Result<MatchParams> {
        let hash = Self::get_file_hash(&input_file.path)?;
        let folder_name = match input_file.path.parent() {
            Some(p) => match p.file_name() {
                Some(p) => p.to_string_lossy().to_string(),
                None => "".to_string(),
            },
            None => "".to_string(),
        };
        let filename = match input_file.path.with_extension("").file_name() {
            Some(p) => p.to_string_lossy().to_string(),
            None => "".to_string(),
        };
        let match_name = format!("{} {}", folder_name, filename);
        let file_size = input_file.path.metadata()?.len();

        Ok(MatchParams {
            match_name,
            json: MatchParamsJson {
                file_name: filename,
                file_hash: hash,
                file_size: file_size.to_string(),
                match_mode: None,
            },
        })
    }

    pub async fn get_matches_json(match_params: &MatchParams) -> Result<MatchesJson> {
        let match_json = json!(match_params.json);
        let matches_json = reqwest::Client::new()
            .post("https://api.dandanplay.net/api/v2/match")
            .json(&match_json)
            .header("Accept", "application/json")
            .header("User-Agent", "curl")
            .send()
            .await?
            .json::<MatchesJson>()
            .await?;
        Ok(matches_json)
    }

    pub async fn search_anime(
        match_params: &MatchParams,
        anime_name: &str,
        episode: Option<&str>,
    ) -> Result<AnimeEpisodeItem> {
        let mut query = vec![("anime", anime_name)];
        if let Some(episode) = episode {
            query.push(("episode", episode));
        }
        let search_json = reqwest::Client::new()
            .get("https://api.dandanplay.net/api/v2/search/episodes")
            .query(&query)
            .header("Accept", "application/json")
            .header("User-Agent", "curl")
            .send()
            .await?
            .json::<SearchJson>()
            .await?;
        if search_json.animes.is_empty() {
            println!(
                "搜索 {} 结果为空",
                match_params.match_name.clone().underlined()
            );
            let new_anime_name = Self::input_search_params(anime_name)?;
            return Box::pin(Self::search_anime(match_params, &new_anime_name, None)).await;
        }
        let mut options: Vec<SearchOption> = vec![];
        for anime in search_json.animes {
            for episode in anime.episodes {
                options.push(SearchOption::SearchAnimeOption(AnimeEpisodeItem {
                    anime_id: anime.anime_id,
                    anime_title: anime.anime_title.clone(),
                    episode_id: episode.episode_id,
                    episode_title: episode.episode_title.clone(),
                }));
            }
        }
        options.push(SearchOption::SearchEditInput(SearchEditInput {}));
        let mut select_prompt = QuerySelector::new(&options, |text, items| {
            items.iter().filter(|i| i.contains(text)).cloned().collect()
        })
        .title("请选择匹配的动画:")
        .prompt()?;
        let selected = select_prompt.run()?;
        let option = options
            .iter()
            .find(|o| o.to_string() == selected)
            .context("Select anime not found")?;
        match option {
            SearchOption::SearchAnimeOption(o) => {
                Self::accept_match(
                    o.episode_id,
                    &MatchAcceptParamsJson {
                        hash: None,
                        file_name: None,
                    },
                )
                .await?;
                Ok(o.to_owned())
            }
            _ => {
                let new_anime_name = Self::input_search_params(anime_name)?;
                Box::pin(Self::search_anime(match_params, &new_anime_name, None)).await
            }
        }
    }

    pub async fn accept_match(
        episode_id: i64,
        match_accept_params: &MatchAcceptParamsJson,
    ) -> Result<()> {
        let match_json = json!(match_accept_params);
        reqwest::Client::new()
            .post(format!(
                "https://api.dandanplay.net/api/v2/match/{}",
                episode_id
            ))
            .json(&match_json)
            .header("Accept", "application/json")
            .header("User-Agent", "curl")
            .send()
            .await?;
        Ok(())
    }

    pub fn input_search_params(match_name: &str) -> Result<String> {
        let mut match_name_prompt = Readline::default()
            .title("输入要搜索的名字(用 tab 补全):")
            .enable_suggest(Suggest::from_iter([&match_name]))
            .enable_history()
            .prompt()?;
        match_name_prompt.run()
    }

    pub async fn get_anime_episode_item(
        input_file: &InputFile,
        change_match: bool,
    ) -> Result<AnimeEpisodeItem> {
        let match_params = Self::get_match_params(input_file)?;
        let matches_json = Self::get_matches_json(&match_params).await?;

        Ok(if change_match {
            Self::search_anime(&match_params, &match_params.match_name, None).await?
        } else if matches_json.is_matched {
            let match_item = &matches_json.matches[0];
            info!(
                "{}, {}, 话数 {}",
                input_file.log("匹配弹幕"),
                match_item.anime_title.clone().underlined().to_string(),
                match_item.episode_title.clone().underlined().to_string()
            );
            AnimeEpisodeItem {
                anime_id: match_item.anime_id,
                anime_title: match_item.anime_title.clone(),
                episode_id: match_item.episode_id,
                episode_title: match_item.episode_title.clone(),
            }
        } else {
            println!("无法精确匹配 {}", match_params.match_name);
            Self::search_anime(&match_params, &match_params.match_name, None).await?
        })
    }
}