piston-mc 0.1.2-beta

A library for interacting with mojangs piston-mc api
Documentation
#![doc = include_str!("../.wiki/Assets.md")]

use crate::assets::AssetError::{AssetFailedToValidate, AssetNotFound};
use simple_download_utility::{FileDownloadArguments, MultiDownloadProgress, download_multiple_files};
use crate::sha_validation::validate_file;
use anyhow::{Result, anyhow};
use futures_util::stream::{self, StreamExt};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::io::AsyncWriteExt;

const MINECRAFT_RESOURCE_CDN: &str = "https://resources.download.minecraft.net";

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Assets {
    pub url: String,
    pub asset_id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub path: Option<PathBuf>,
    pub objects: HashMap<String, AssetItem>,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct AssetItem {
    pub hash: String,
    pub size: u64,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct AssetValidationResult {
    pub asset_id: String,
    pub succeeded: Vec<String>,
    pub failed: Vec<AssetValidationFailureResult>,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct AssetValidationFailureResult {
    pub hash: String,
    pub reason: AssetValidationFailureReason,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum AssetValidationFailureReason {
    FileNotFound,
    HashNotMatching,
}

#[derive(thiserror::Error, Debug)]
pub enum AssetError {
    #[error("Asset '{name}' not found in path '{path}'")]
    AssetNotFound { name: String, path: PathBuf },
    #[error("Asset '{name}' is not valid in path '{path}'")]
    AssetFailedToValidate { name: String, path: PathBuf },
}

impl Assets {
    pub async fn from_url(url: impl AsRef<str>) -> Result<Self> {
        let url = url.as_ref();
        debug!("Fetching versions manifest");

        let manifest = reqwest::get(url).await?.json::<serde_json::Value>().await?;
        let objects = manifest.get("objects").ok_or_else(|| anyhow!("missing `objects`"))?;
        let objects: HashMap<String, AssetItem> = serde_json::from_value(objects.clone())?;
        debug!("Found {} versions in manifest", objects.len());

        let id = url.split("/").last().ok_or_else(|| anyhow!("invalid url"))?.trim_end_matches(".json");

        Ok(Assets { asset_id: id.to_string(), path: None, url: url.to_string(), objects })
    }

    pub async fn from_path(path: impl AsRef<Path>) -> Result<Self> {
        let path = path.as_ref();
        let content = tokio::fs::read_to_string(path).await?;
        let mut assets = serde_json::from_str::<Self>(&content)?;
        assets.path = Some(path.to_path_buf());
        Ok(assets)
    }

    pub async fn download(
        &mut self,
        directory: impl AsRef<Path>,
        parallel: u16,
        sender: Option<tokio::sync::mpsc::Sender<MultiDownloadProgress>>,
    ) -> Result<()> {
        let directory = directory.as_ref();
        if !directory.exists() {
            tokio::fs::create_dir_all(&directory).await?;
        }
        self.path = Some(directory.to_path_buf());
        let mut file = tokio::fs::File::create(directory.join(format!("{}.json", self.asset_id))).await?;
        file.write_all(serde_json::to_string(&self)?.as_bytes()).await?;
        let download_items: Vec<FileDownloadArguments> = self
            .objects
            .values()
            .map(|item| FileDownloadArguments {
                url: item.get_download_url(),
                sha1: Some(item.hash.clone()),
                sender: None,
                path: item.get_download_path(directory).to_string_lossy().into_owned(),
            })
            .collect();

        download_multiple_files(download_items, parallel, sender).await?;

        Ok(())
    }

    pub async fn validate(&self, parallel: u16) -> Result<AssetValidationResult> {
        let path = self.path.as_ref().ok_or_else(|| anyhow!("Asset path was not set"))?;

        let items: Vec<_> = self.objects.iter().map(|(name, item)| (name.clone(), item.clone())).collect();

        let results: Vec<_> = stream::iter(items)
            .map(|(name, item)| {
                let path = path.clone();
                tokio::task::spawn_blocking(move || (name, item.validate(&path)))
            })
            .buffer_unordered(parallel as usize)
            .collect()
            .await;

        let mut result = AssetValidationResult { asset_id: self.asset_id.clone(), succeeded: vec![], failed: vec![] };

        for join_result in results {
            let (name, validation) = join_result?;
            match validation {
                Ok(_) => result.succeeded.push(name),
                Err(err) => result.failed.push(AssetValidationFailureResult {
                    hash: name,
                    reason: match err {
                        AssetNotFound { .. } => AssetValidationFailureReason::FileNotFound,
                        AssetFailedToValidate { .. } => AssetValidationFailureReason::HashNotMatching,
                    },
                }),
            }
        }

        Ok(result)
    }
}

impl AssetItem {
    pub fn get_download_url(&self) -> String {
        let hash = self.hash.clone();
        let dir = hash.chars().take(2).collect::<String>();
        format!("{}/{}/{}", MINECRAFT_RESOURCE_CDN, dir, hash)
    }
    pub fn get_download_path(&self, asset_dir: impl AsRef<Path>) -> PathBuf {
        let asset_dir = asset_dir.as_ref();
        let hash = self.hash.clone();
        let dir = hash.chars().take(2).collect::<String>();
        asset_dir.join(dir).join(&hash)
    }

    pub fn validate(&self, asset_dir: impl AsRef<Path>) -> Result<(), AssetError> {
        let file_path = self.get_download_path(&asset_dir);
        if !file_path.exists() {
            return Err(AssetNotFound { name: self.hash.clone(), path: file_path });
        }

        if !validate_file(&file_path, &self.hash) {
            return Err(AssetFailedToValidate { name: self.hash.clone(), path: file_path });
        }

        Ok(())
    }
}