Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

handle audio upload quotas correctly #139

Merged
merged 2 commits into from
Mar 13, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 44 additions & 35 deletions src/lib/roblox_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -330,6 +322,27 @@ pub struct GetSocialLinkResponse {
pub link_type: SocialLinkType,
}

#[derive(Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
struct CreateAssetQuotasResponse {
quotas: Vec<CreateAssetQuota>,
}

#[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<String>,
}

#[derive(Serialize, Deserialize, Clone)]
pub enum ExperienceGenre {
All,
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -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<AssetId>,
) -> Result<GetCreateAudioAssetPriceResponse, String> {
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<CreateAssetQuota, String> {
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::<CreateAssetQuotasResponse>(req).await?)
.quotas
.first()
.cloned()
.ok_or(format!(
"No create quotas found for asset type {}",
asset_type
))
}

pub async fn create_audio_asset(
Expand Down
150 changes: 102 additions & 48 deletions src/lib/roblox_resource_manager.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand All @@ -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,
};
Expand Down Expand Up @@ -314,41 +317,24 @@ impl ResourceManager<RobloxInputs, RobloxOutputs> 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),
}
Expand Down Expand Up @@ -593,16 +579,54 @@ impl ResourceManager<RobloxInputs, RobloxOutputs> 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);
Expand All @@ -628,16 +652,11 @@ impl ResourceManager<RobloxInputs, RobloxOutputs> for RobloxResourceManager {

async fn get_update_price(
&self,
inputs: RobloxInputs,
outputs: RobloxOutputs,
dependency_outputs: Vec<RobloxOutputs>,
_inputs: RobloxInputs,
_outputs: RobloxOutputs,
_dependency_outputs: Vec<RobloxOutputs>,
) -> Result<Option<u32>, 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`.
Expand Down Expand Up @@ -927,3 +946,38 @@ impl ResourceManager<RobloxInputs, RobloxOutputs> for RobloxResourceManager {
Ok(())
}
}

fn format_quota_reset(reset: DateTime<Utc>) -> String {
let now = Utc::now();
let duration = reset.signed_duration_since(now);

let mut parts = Vec::<String>::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(" ")
}