Create multiple assets concurrently #5

Merged
Quaternions merged 16 commits from staging into master 2024-08-17 18:00:28 +00:00
Showing only changes of commit 57a163dfb1 - Show all commits

View File

@ -26,6 +26,7 @@ enum Commands{
DownloadGroupInventoryJson(DownloadGroupInventoryJsonSubcommand), DownloadGroupInventoryJson(DownloadGroupInventoryJsonSubcommand),
CreateAsset(CreateAssetSubcommand), CreateAsset(CreateAssetSubcommand),
CreateAssetMedia(CreateAssetMediaSubcommand), CreateAssetMedia(CreateAssetMediaSubcommand),
CreateAssetMedias(CreateAssetMediasSubcommand),
UploadAsset(UpdateAssetSubcommand), UploadAsset(UpdateAssetSubcommand),
UploadAssetMedia(UpdateAssetMediaSubcommand), UploadAssetMedia(UpdateAssetMediaSubcommand),
UploadPlace(UpdatePlaceSubcommand), UploadPlace(UpdatePlaceSubcommand),
@ -128,6 +129,32 @@ struct CreateAssetMediaSubcommand{
expected_price:Option<u64>, expected_price:Option<u64>,
} }
#[derive(Args)] #[derive(Args)]
/// Automatically detect the media type from file extension and generate asset name and description
struct CreateAssetMediasSubcommand{
#[arg(long,group="api_key",required=true)]
api_key_literal:Option<String>,
#[arg(long,group="api_key",required=true)]
api_key_envvar:Option<String>,
#[arg(long,group="api_key",required=true)]
api_key_file:Option<PathBuf>,
#[arg(long,group="cookie",required=true)]
cookie_literal:Option<String>,
#[arg(long,group="cookie",required=true)]
cookie_envvar:Option<String>,
#[arg(long,group="cookie",required=true)]
cookie_file:Option<PathBuf>,
#[arg(long)]
description:Option<String>,
#[arg(long,group="creator",required=true)]
creator_user_id:Option<u64>,
#[arg(long,group="creator",required=true)]
creator_group_id:Option<u64>,
/// Expected price limits how much robux can be spent to create the asset (defaults to 0)
#[arg(long)]
expected_price:Option<u64>,
input_files:Vec<PathBuf>,
}
#[derive(Args)]
struct UpdateAssetSubcommand{ struct UpdateAssetSubcommand{
#[arg(long)] #[arg(long)]
asset_id:AssetID, asset_id:AssetID,
@ -424,6 +451,26 @@ async fn main()->AResult<()>{
description:subcommand.description.unwrap_or_else(||String::with_capacity(0)), description:subcommand.description.unwrap_or_else(||String::with_capacity(0)),
expected_price:subcommand.expected_price, expected_price:subcommand.expected_price,
}).await, }).await,
Commands::CreateAssetMedias(subcommand)=>create_asset_medias(CreateAssetMediasConfig{
api_key:api_key_from_args(
subcommand.api_key_literal,
subcommand.api_key_envvar,
subcommand.api_key_file,
).await?,
cookie:cookie_from_args(
subcommand.cookie_literal,
subcommand.cookie_envvar,
subcommand.cookie_file,
).await?,
creator:match (subcommand.creator_user_id,subcommand.creator_group_id){
(Some(user_id),None)=>rbx_asset::cloud::Creator::userId(user_id.to_string()),
(None,Some(group_id))=>rbx_asset::cloud::Creator::groupId(group_id.to_string()),
other=>Err(anyhow!("Invalid creator {other:?}"))?,
},
description:subcommand.description.unwrap_or_else(||String::with_capacity(0)),
input_files:subcommand.input_files,
expected_price:subcommand.expected_price,
}).await,
Commands::UploadAsset(subcommand)=>upload_asset(UploadAssetConfig{ Commands::UploadAsset(subcommand)=>upload_asset(UploadAssetConfig{
cookie:cookie_from_args( cookie:cookie_from_args(
subcommand.cookie_literal, subcommand.cookie_literal,
@ -614,6 +661,133 @@ async fn create_asset_media(config:CreateAssetMediaConfig)->AResult<()>{
Ok(()) Ok(())
} }
// complex operation requires both api key and cookie! how horrible! roblox please fix!
struct CreateAssetMediasConfig{
api_key:ApiKey,
cookie:Cookie,
description:String,
input_files:Vec<PathBuf>,
creator:rbx_asset::cloud::Creator,
expected_price:Option<u64>,
}
#[derive(Debug)]
enum CreateAssetMediasError{
NoFileStem(PathBuf),
UnknownFourCC(Option<Vec<u8>>),
}
impl std::fmt::Display for CreateAssetMediasError{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>)->std::fmt::Result{
write!(f,"{self:?}")
}
}
impl std::error::Error for CreateAssetMediasError{}
#[derive(Debug)]
enum DownloadDecalError{
ParseInt(std::num::ParseIntError),
Get(rbx_asset::cookie::GetError),
LoadDom(LoadDomError),
NoFirstInstance,
NoTextureProperty,
TexturePropertyInvalid,
}
impl std::fmt::Display for DownloadDecalError{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>)->std::fmt::Result{
write!(f,"{self:?}")
}
}
impl std::error::Error for DownloadDecalError{}
async fn create_asset_medias(config:CreateAssetMediasConfig)->AResult<()>{
let context=CloudContext::new(config.api_key);
let cookie_context=CookieContext::new(config.cookie);
let expected_price=Some(config.expected_price.unwrap_or(0));
let asset_id_list=futures::stream::iter(config.input_files.into_iter()
//step 1: read file, make create request
.map(|path|{
let description=&config.description;
let creator=&config.creator;
let context=&context;
async move{
let model_name=path.file_stem()
.and_then(std::ffi::OsStr::to_str)
.ok_or(CreateAssetMediasError::NoFileStem(path.clone()))?
.to_owned();
let file=tokio::fs::read(path).await?;
let asset_type=match file.get(0..4){
//png
Some(b"\x89PNG")=>rbx_asset::cloud::AssetType::Decal,
//jpeg
Some(b"\xFF\xD8\xFF\xE0")=>rbx_asset::cloud::AssetType::Decal,
//Some("fbx")=>rbx_asset::cloud::AssetType::Model,
//Some("ogg")=>rbx_asset::cloud::AssetType::Audio,
fourcc=>Err(CreateAssetMediasError::UnknownFourCC(fourcc.map(<[u8]>::to_owned)))?,
};
Ok(context.create_asset(rbx_asset::cloud::CreateAssetRequest{
assetType:asset_type,
displayName:model_name,
description:description.clone(),
creationContext:rbx_asset::cloud::CreationContext{
creator:creator.clone(),
expectedPrice:expected_price,
}
},file).await?)
}
}))
//parallel requests
.buffer_unordered(CONCURRENT_REQUESTS)
//step 2: poll operation until it completes
.filter_map(|create_result:AResult<_>|{
let context=&context;
async{
match create_result{
Ok(create_asset_response)=>match get_asset_exp_backoff(context,&create_asset_response).await{
Ok(asset_response)=>Some(asset_response),
Err(e)=>{
eprintln!("operation error: {}",e);
None
},
},
Err(e)=>{
eprintln!("create_asset error: {}",e);
None
},
}
}
})
//step 3: read decal id from operation and download it, decode it as a roblox file and extract the texture content url
.filter_map(|asset_response|{
let parse_result=asset_response.assetId.parse();
async{
match async{
let file=cookie_context.get_asset(rbx_asset::cookie::GetAssetRequest{
asset_id:parse_result.map_err(DownloadDecalError::ParseInt)?,
version:None,
}).await.map_err(DownloadDecalError::Get)?;
let dom=load_dom(std::io::Cursor::new(file)).map_err(DownloadDecalError::LoadDom)?;
let instance=dom.get_by_ref(
*dom.root().children().first().ok_or(DownloadDecalError::NoFirstInstance)?
).ok_or(DownloadDecalError::NoFirstInstance)?;
match instance.properties.get("Texture").ok_or(DownloadDecalError::NoTextureProperty)?{
rbx_dom_weak::types::Variant::Content(url)=>Ok(url.clone().into_string()),
_=>Err(DownloadDecalError::TexturePropertyInvalid),
}
}.await{
Ok(asset_url)=>Some((asset_response.displayName,asset_url)),
Err(e)=>{
eprintln!("get_asset error: {}",e);
None
},
}
}
}).collect::<Vec<(String,String)>>().await;
for (file_name,asset_url) in asset_id_list{
println!("{}={}",file_name,asset_url);
}
Ok(())
}
struct UploadAssetConfig{ struct UploadAssetConfig{
cookie:Cookie, cookie:Cookie,
asset_id:AssetID, asset_id:AssetID,
@ -859,18 +1033,34 @@ async fn download_history(mut config:DownloadHistoryConfig)->AResult<()>{
Ok(()) Ok(())
} }
fn load_dom<R:Read>(input:R)->AResult<rbx_dom_weak::WeakDom>{ #[derive(Debug)]
#[allow(dead_code)]
enum LoadDomError{
IO(std::io::Error),
RbxBinary(rbx_binary::DecodeError),
RbxXml(rbx_xml::DecodeError),
UnknownRobloxFile([u8;4]),
UnsupportedFile,
}
impl std::fmt::Display for LoadDomError{
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
write!(f,"{self:?}")
}
}
impl std::error::Error for LoadDomError{}
fn load_dom<R:Read>(input:R)->Result<rbx_dom_weak::WeakDom,LoadDomError>{
let mut buf=std::io::BufReader::new(input); let mut buf=std::io::BufReader::new(input);
let peek=std::io::BufRead::fill_buf(&mut buf)?; let peek=std::io::BufRead::fill_buf(&mut buf).map_err(LoadDomError::IO)?;
match &peek[0..4]{ match &peek[0..4]{
b"<rob"=>{ b"<rob"=>{
match &peek[4..8]{ match &peek[4..8]{
b"lox!"=>rbx_binary::from_reader(buf).map_err(anyhow::Error::msg), b"lox!"=>rbx_binary::from_reader(buf).map_err(LoadDomError::RbxBinary),
b"lox "=>rbx_xml::from_reader_default(buf).map_err(anyhow::Error::msg), b"lox "=>rbx_xml::from_reader_default(buf).map_err(LoadDomError::RbxXml),
other=>Err(anyhow::Error::msg(format!("Unknown Roblox file type {:?}",other))), other=>Err(LoadDomError::UnknownRobloxFile(other.try_into().unwrap())),
} }
}, },
_=>Err(anyhow::Error::msg("unsupported file type")), _=>Err(LoadDomError::UnsupportedFile),
} }
} }