diff --git a/src/main.rs b/src/main.rs index b967d55..19b1871 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,7 @@ enum Commands{ DownloadGroupInventoryJson(DownloadGroupInventoryJsonSubcommand), CreateAsset(CreateAssetSubcommand), CreateAssetMedia(CreateAssetMediaSubcommand), + CreateAssetMedias(CreateAssetMediasSubcommand), UploadAsset(UpdateAssetSubcommand), UploadAssetMedia(UpdateAssetMediaSubcommand), UploadPlace(UpdatePlaceSubcommand), @@ -128,6 +129,32 @@ struct CreateAssetMediaSubcommand{ expected_price:Option, } #[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, + #[arg(long,group="api_key",required=true)] + api_key_envvar:Option, + #[arg(long,group="api_key",required=true)] + api_key_file:Option, + #[arg(long,group="cookie",required=true)] + cookie_literal:Option, + #[arg(long,group="cookie",required=true)] + cookie_envvar:Option, + #[arg(long,group="cookie",required=true)] + cookie_file:Option, + #[arg(long)] + description:Option, + #[arg(long,group="creator",required=true)] + creator_user_id:Option, + #[arg(long,group="creator",required=true)] + creator_group_id:Option, + /// Expected price limits how much robux can be spent to create the asset (defaults to 0) + #[arg(long)] + expected_price:Option, + input_files:Vec, +} +#[derive(Args)] struct UpdateAssetSubcommand{ #[arg(long)] asset_id:AssetID, @@ -424,6 +451,26 @@ async fn main()->AResult<()>{ description:subcommand.description.unwrap_or_else(||String::with_capacity(0)), expected_price:subcommand.expected_price, }).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{ cookie:cookie_from_args( subcommand.cookie_literal, @@ -614,6 +661,133 @@ async fn create_asset_media(config:CreateAssetMediaConfig)->AResult<()>{ 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, + creator:rbx_asset::cloud::Creator, + expected_price:Option, +} + +#[derive(Debug)] +enum CreateAssetMediasError{ + NoFileStem(PathBuf), + UnknownFourCC(Option>), +} +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::>().await; + for (file_name,asset_url) in asset_id_list{ + println!("{}={}",file_name,asset_url); + } + Ok(()) +} + struct UploadAssetConfig{ cookie:Cookie, asset_id:AssetID, @@ -859,18 +1033,34 @@ async fn download_history(mut config:DownloadHistoryConfig)->AResult<()>{ Ok(()) } -fn load_dom(input:R)->AResult{ +#[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(input:R)->Result{ 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]{ b"{ match &peek[4..8]{ - b"lox!"=>rbx_binary::from_reader(buf).map_err(anyhow::Error::msg), - b"lox "=>rbx_xml::from_reader_default(buf).map_err(anyhow::Error::msg), - other=>Err(anyhow::Error::msg(format!("Unknown Roblox file type {:?}",other))), + b"lox!"=>rbx_binary::from_reader(buf).map_err(LoadDomError::RbxBinary), + b"lox "=>rbx_xml::from_reader_default(buf).map_err(LoadDomError::RbxXml), + other=>Err(LoadDomError::UnknownRobloxFile(other.try_into().unwrap())), } }, - _=>Err(anyhow::Error::msg("unsupported file type")), + _=>Err(LoadDomError::UnsupportedFile), } }