From 1592c83d564b84247394b370889d4631e0b6713d Mon Sep 17 00:00:00 2001 From: Blake Mealey Date: Sat, 12 Mar 2022 22:58:28 -0600 Subject: [PATCH] handle audio upload quotas correctly (#139) * handle audio upload quotas correctly * fix linter errors --- src/lib/roblox_api.rs | 79 ++++++++------- src/lib/roblox_resource_manager.rs | 150 ++++++++++++++++++++--------- 2 files changed, 146 insertions(+), 83 deletions(-) diff --git a/src/lib/roblox_api.rs b/src/lib/roblox_api.rs index a767792..bf7e9b5 100644 --- a/src/lib/roblox_api.rs +++ b/src/lib/roblox_api.rs @@ -258,14 +258,6 @@ pub struct CreateImageAssetResponse { pub backing_asset_id: AssetId, } -#[derive(Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GetCreateAudioAssetPriceResponse { - pub price: u32, - pub balance: u32, - pub can_afford: bool, -} - #[derive(Deserialize, Clone)] #[serde(rename_all = "PascalCase")] pub struct CreateAudioAssetResponse { @@ -330,6 +322,27 @@ pub struct GetSocialLinkResponse { pub link_type: SocialLinkType, } +#[derive(Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct CreateAssetQuotasResponse { + quotas: Vec, +} + +#[derive(Deserialize, Clone)] +#[serde(rename_all = "PascalCase")] +pub enum QuotaDuration { + Month, +} + +#[derive(Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct CreateAssetQuota { + pub duration: QuotaDuration, + pub usage: u32, + pub capacity: u32, + pub expiration_time: Option, +} + #[derive(Serialize, Deserialize, Clone)] pub enum ExperienceGenre { All, @@ -449,6 +462,11 @@ pub enum AssetTypeId { RightShoeAccessory = 71, DressSkirtAccessory = 72, } +impl fmt::Display for AssetTypeId { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", serde_json::to_string(&self).unwrap(),) + } +} #[derive(Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] @@ -1773,37 +1791,28 @@ impl RobloxApi { Self::handle_as_json_with_status(req).await } - pub async fn get_create_audio_asset_price( + pub async fn get_create_asset_quota( &self, - file_path: PathBuf, - group_id: Option, - ) -> Result { - let data = fs::read(&file_path).map_err(|e| { - format!( - "Unable to read audio asset file: {}\n\t{}", - file_path.display(), - e - ) - })?; - - let file_name = format!( - "Audio/{}", - file_path.file_stem().and_then(OsStr::to_str).unwrap() - ); - + asset_type: AssetTypeId, + ) -> Result { let req = self .client - .post("https://publish.roblox.com/v1/audio/verify") - .query(&[("name", &file_name)]) - .header(reqwest::header::CONTENT_TYPE, "*/*") - .json(&json!({ - "name": file_name, - "fileSize": data.len(), - "file": base64::encode(data), - "groupId": group_id, - })); + .get("https://publish.roblox.com/v1/asset-quotas") + .query(&[ + // TODO: Understand what this parameter does + ("resourceType", "1"), + ("assetType", &asset_type.to_string()), + ]); - Self::handle_as_json(req).await + // TODO: Understand how to interpret multiple quota objects (rather than just using the first one) + (Self::handle_as_json::(req).await?) + .quotas + .first() + .cloned() + .ok_or(format!( + "No create quotas found for asset type {}", + asset_type + )) } pub async fn create_audio_asset( diff --git a/src/lib/roblox_resource_manager.rs b/src/lib/roblox_resource_manager.rs index 4b0362b..1ab4a18 100644 --- a/src/lib/roblox_resource_manager.rs +++ b/src/lib/roblox_resource_manager.rs @@ -1,11 +1,14 @@ use std::path::{Path, PathBuf}; use async_trait::async_trait; -use chrono::Utc; +use chrono::{DateTime, Duration, Utc}; use serde::{Deserialize, Serialize}; use yansi::Paint; -use crate::logger; +use crate::{ + logger, + roblox_api::{AssetTypeId, CreateAssetQuota, QuotaDuration}, +}; use super::{ resource_graph::{ @@ -15,8 +18,8 @@ use super::{ CreateAudioAssetResponse, CreateBadgeResponse, CreateDeveloperProductResponse, CreateExperienceResponse, CreateGamePassResponse, CreateImageAssetResponse, CreateSocialLinkResponse, CreatorType, ExperienceConfigurationModel, - GetCreateAudioAssetPriceResponse, GetDeveloperProductResponse, GetPlaceResponse, - PlaceConfigurationModel, RobloxApi, SocialLinkType, UploadImageResponse, + GetDeveloperProductResponse, GetPlaceResponse, PlaceConfigurationModel, RobloxApi, + SocialLinkType, UploadImageResponse, }, roblox_auth::RobloxAuth, }; @@ -314,41 +317,24 @@ impl ResourceManager for RobloxResourceManager { .roblox_api .get_create_badge_free_quota(experience.asset_id) .await?; + + let quota_reset = + format_quota_reset((Utc::today() + Duration::days(1)).and_hms(0, 0, 0)); + if free_quota > 0 { - let utc_now = Utc::now(); - let utc_reset = (utc_now + chrono::Duration::days(1)) - .date() - .and_hms(0, 0, 0); - let duration = utc_reset.signed_duration_since(utc_now); - let duration_str = format!( - "{:02}:{:02}:{:02}", - duration.num_hours(), - duration.num_minutes() - duration.num_hours() * 60, - duration.num_seconds() - duration.num_minutes() * 60 - ); logger::log(""); logger::log(Paint::yellow( - format!("You will have {} free badge(s) remaining in the current period after creation. Your quota will reset in {}.", free_quota - 1, duration_str), + format!("You will have {} free badge(s) remaining in the current period after creation. Your quota will reset in {}.", free_quota - 1, quota_reset), )); Ok(None) } else { - Ok(Some(100)) - } - } - RobloxInputs::AudioAsset(inputs) => { - let GetCreateAudioAssetPriceResponse { - price, can_afford, .. - } = self - .roblox_api - .get_create_audio_asset_price(self.get_path(inputs.file_path), inputs.group_id) - .await?; + logger::log(""); + logger::log(Paint::yellow( + format!("You have no free badges remaining in the current period. Your quota will reset in {}.", quota_reset), + )); - // TODO: Add support for failing early like this for all other resource types (e.g. return the price and current balance from this function) - if !can_afford { - return Err(format!("You do not have enough Robux to create an audio asset with the price of {}", price)); + Ok(Some(100)) } - - Ok(Some(price)) } _ => Ok(None), } @@ -593,16 +579,54 @@ impl ResourceManager for RobloxResourceManager { })) } RobloxInputs::AudioAsset(inputs) => { - let CreateAudioAssetResponse { id } = self + let CreateAssetQuota { + usage, + capacity, + expiration_time, + duration, + } = self .roblox_api - .create_audio_asset( - self.get_path(inputs.file_path), - inputs.group_id, - self.payment_source.clone(), - ) - .await?; + .get_create_asset_quota(AssetTypeId::Audio) + .await?; + + let quota_reset = format_quota_reset(match expiration_time { + Some(ref x) => DateTime::parse_from_rfc3339(x) + .map_err(|e| format!("Unable to parse expiration_time: {}", e))? + .with_timezone(&Utc), + None => { + Utc::now() + + match duration { + // TODO: Learn how Roblox computes a "Month" to ensure this is an accurate estimate + QuotaDuration::Month => Duration::days(30), + } + } + }); + + if usage < capacity { + logger::log(""); + logger::log(Paint::yellow( + format!( + "You will have {} audio upload(s) remaining in the current period after creation. Your quota will reset in {}.", + capacity - usage - 1, + quota_reset + ))); + + let CreateAudioAssetResponse { id } = self + .roblox_api + .create_audio_asset( + self.get_path(inputs.file_path), + inputs.group_id, + self.payment_source.clone(), + ) + .await?; - Ok(RobloxOutputs::AudioAsset(AssetOutputs { asset_id: id })) + Ok(RobloxOutputs::AudioAsset(AssetOutputs { asset_id: id })) + } else { + Err(format!( + "You have reached your audio upload quota. Your quota will reset in {}.", + quota_reset + )) + } } RobloxInputs::AssetAlias(inputs) => { let experience = single_output!(dependency_outputs, RobloxOutputs::Experience); @@ -628,16 +652,11 @@ impl ResourceManager for RobloxResourceManager { async fn get_update_price( &self, - inputs: RobloxInputs, - outputs: RobloxOutputs, - dependency_outputs: Vec, + _inputs: RobloxInputs, + _outputs: RobloxOutputs, + _dependency_outputs: Vec, ) -> Result, String> { - match (inputs.clone(), outputs) { - (RobloxInputs::AudioAsset(_), RobloxOutputs::AudioAsset(_)) => { - self.get_create_price(inputs, dependency_outputs).await - } - _ => Ok(None), - } + Ok(None) } // TODO: Consider moving `outputs` into `dependency_outputs`. @@ -927,3 +946,38 @@ impl ResourceManager for RobloxResourceManager { Ok(()) } } + +fn format_quota_reset(reset: DateTime) -> String { + let now = Utc::now(); + let duration = reset.signed_duration_since(now); + + let mut parts = Vec::::new(); + if duration.num_days() > 0 { + parts.push(format!("{}d", duration.num_days())); + } + if duration.num_hours() > 0 { + parts.push(format!( + "{}h", + duration.num_hours() - duration.num_days() * 24 + )); + } + if duration.num_minutes() > 0 { + parts.push(format!( + "{}m", + duration.num_minutes() - duration.num_hours() * 60 + )); + } + if duration.num_seconds() > 0 { + parts.push(format!( + "{}s", + duration.num_seconds() - duration.num_minutes() * 60 + )); + } else { + parts.push(format!( + "{}ms", + duration.num_milliseconds() - duration.num_seconds() * 1000 + )); + } + + parts.join(" ") +}