diff --git a/Cargo.lock b/Cargo.lock index dbbb8ce..7672f63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,21 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.19" @@ -133,6 +148,21 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.5.39" @@ -534,6 +564,30 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -762,6 +816,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "object" version = "0.36.7" @@ -860,6 +923,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -884,6 +956,35 @@ version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +[[package]] +name = "rand" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + [[package]] name = "reqwest" version = "0.12.19" @@ -942,10 +1043,11 @@ dependencies = [ [[package]] name = "rreview" -version = "1.0.0" +version = "1.1.0" dependencies = [ "clap", "futures", + "rand", "siphasher", "submissions-api", "tokio", @@ -1153,10 +1255,11 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "submissions-api" -version = "0.7.2" +version = "0.8.1" source = "sparse+https://git.itzana.me/api/packages/strafesnet/cargo/" -checksum = "f0a098bbc31ecadff0237a2979cda54b2369c76e3a1ce7b86ff4643bfbf4822f" +checksum = "a08deea49e9e34f2f2f23219f4a565681b4c1ae46f8012496d9a8fe10897efd3" dependencies = [ + "chrono", "reqwest", "serde", "serde_json", @@ -1521,6 +1624,41 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.1.1" @@ -1677,6 +1815,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index 95d6e97..f83cfbd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rreview" -version = "1.0.0" +version = "1.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -8,8 +8,9 @@ edition = "2021" [dependencies] clap = { version = "4.4.2", features = ["derive"] } futures = "0.3.31" +rand = "0.9.1" siphasher = "1.0.1" -submissions-api = { version = "0.7.2", registry = "strafesnet" } +submissions-api = { version = "0.8.1", registry = "strafesnet" } tokio = { version = "1.42.0", features = ["fs", "macros", "rt-multi-thread"] } [profile.release] diff --git a/src/main.rs b/src/main.rs index 1027d6c..f6995c8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,7 @@ use clap::{Args,Parser,Subcommand}; use futures::{StreamExt,TryStreamExt}; +use rand::seq::SliceRandom; +use std::io::Write; use std::path::PathBuf; const READ_CONCURRENCY:usize=16; @@ -15,12 +17,20 @@ struct Cli{ #[derive(Subcommand)] enum Commands{ + Release(ReleaseCommand), RepairDuplicates(RepairDuplicatesCommand), RepairPolicies(RepairPoliciesCommand), Review(ReviewCommand), UploadScripts(UploadScriptsCommand), } +#[derive(Args)] +struct ReleaseCommand{ + #[arg(long)] + session_id_file:PathBuf, + #[arg(long)] + api_url:String, +} #[derive(Args)] struct RepairDuplicatesCommand{ #[arg(long)] @@ -54,6 +64,10 @@ struct UploadScriptsCommand{ async fn main(){ let cli=Cli::parse(); match cli.command{ + Commands::Release(command)=>release(ReleaseConfig{ + session_id:std::fs::read_to_string(command.session_id_file).unwrap(), + api_url:command.api_url, + }).await.unwrap(), Commands::RepairDuplicates(command)=>repair_duplicates(RepairDuplicatesConfig{ session_id:std::fs::read_to_string(command.session_id_file).unwrap(), api_url:command.api_url, @@ -103,13 +117,13 @@ enum ReviewError{ Cookie(submissions_api::CookieError), Reqwest(submissions_api::ReqwestError), GetPolicies(submissions_api::Error), - GetScriptFromHash(submissions_api::types::SingleItemError), + GetScriptFromHash(submissions_api::types::ScriptSingleItemError), NoScript, WriteCurrent(std::io::Error), ActionIO(std::io::Error), PurgeScript(submissions_api::Error), ReadCurrent(std::io::Error), - DeduplicateModified(submissions_api::types::SingleItemError), + DeduplicateModified(submissions_api::types::ScriptSingleItemError), UploadModified(submissions_api::Error), UpdateScriptPolicy(submissions_api::Error), } @@ -236,10 +250,10 @@ enum ScriptUploadError{ AllowedMap(GetMapError), ReplaceMap(GetMapError), BlockedSet(std::io::Error), - GetOrCreate(GOCError), - GetOrCreatePolicyReplace(GOCError), - GetOrCreatePolicyAllowed(GOCError), - GetOrCreatePolicyBlocked(GOCError), + GetOrCreate(GOCScriptError), + GetOrCreatePolicyReplace(GOCScriptPolicyError), + GetOrCreatePolicyAllowed(GOCScriptPolicyError), + GetOrCreatePolicyBlocked(GOCScriptPolicyError), } fn read_dir_stream(dir:tokio::fs::ReadDir)->impl futures::stream::Stream<Item=std::io::Result<tokio::fs::DirEntry>>{ @@ -318,10 +332,11 @@ fn hash_format(hash:u64)->String{ format!("{:016x}",hash) } -type GOCError=submissions_api::types::SingleItemError; -type GOCResult=Result<submissions_api::types::ScriptID,GOCError>; +type GOCScriptError=submissions_api::types::ScriptSingleItemError; +type GOCScriptPolicyError=submissions_api::types::ScriptPolicySingleItemError; +type GOCScriptResult=Result<submissions_api::types::ScriptID,GOCScriptError>; -async fn get_or_create_script(api:&submissions_api::external::Context,source:&str)->GOCResult{ +async fn get_or_create_script(api:&submissions_api::external::Context,source:&str)->GOCScriptResult{ let script_response=api.get_script_from_hash(submissions_api::types::HashRequest{ hash:hash_format(hash_source(source)).as_str(), }).await?; @@ -333,7 +348,7 @@ async fn get_or_create_script(api:&submissions_api::external::Context,source:&st Source:source, ResourceType:submissions_api::types::ResourceType::Unknown, ResourceID:None, - }).await.map_err(GOCError::Other)?.ScriptID + }).await.map_err(GOCScriptError::Other)?.ScriptID }) } @@ -341,7 +356,7 @@ async fn check_or_create_script_poicy( api:&submissions_api::external::Context, hash:&str, script_policy:submissions_api::types::CreateScriptPolicyRequest, -)->Result<(),GOCError>{ +)->Result<(),GOCScriptPolicyError>{ let script_policy_result=api.get_script_policy_from_hash(submissions_api::types::HashRequest{ hash, }).await?; @@ -355,7 +370,7 @@ async fn check_or_create_script_poicy( }, None=>{ // create a new policy - api.create_script_policy(script_policy).await.map_err(GOCError::Other)?; + api.create_script_policy(script_policy).await.map_err(GOCScriptPolicyError::Other)?; } } @@ -464,7 +479,7 @@ enum RepairPoliciesError{ Cookie(submissions_api::CookieError), Reqwest(submissions_api::ReqwestError), GetPolicies(submissions_api::Error), - GetScripts(submissions_api::types::SingleItemError), + GetScripts(submissions_api::types::ScriptSingleItemError), UpdateScriptPolicy(submissions_api::Error), } @@ -577,3 +592,158 @@ async fn repair_duplicates(config:RepairDuplicatesConfig)->Result<(),RepairDupli Ok(()) } + +#[allow(dead_code)] +#[derive(Debug)] +enum ReleaseError{ + Cookie(submissions_api::CookieError), + Reqwest(submissions_api::ReqwestError), + GetSubmissions(submissions_api::Error), + GetMaps(submissions_api::Error), + Io(std::io::Error), + Release(submissions_api::Error), +} + +struct ReleaseConfig{ + session_id:String, + api_url:String, +} +async fn release(config:ReleaseConfig)->Result<(),ReleaseError>{ + let cookie=submissions_api::Cookie::new(&config.session_id).map_err(ReleaseError::Cookie)?; + let api=&submissions_api::external::Context::new(config.api_url,cookie).map_err(ReleaseError::Reqwest)?; + + const LIMIT:u32=100; + const ONE_HOUR:i64=60*60; + const ONE_DAY:i64=24*ONE_HOUR; + const ONE_WEEK:i64=7*ONE_DAY; + const FRIDAY:i64=2*ONE_DAY; + const PEAK_HOURS:i64=-7*ONE_HOUR; + + // determine maps ready to be released + let mut submissions_pending_release=std::collections::BTreeMap::new(); + { + println!("Downloading submissions pending release..."); + let mut page=1; + loop{ + let submissions=api.get_submissions(submissions_api::types::GetSubmissionsRequest{ + Page:page, + Limit:LIMIT, + DisplayName:None, + Creator:None, + GameID:None, + Sort:None, + Submitter:None, + AssetID:None, + UploadedAssetID:None, + StatusID:Some(submissions_api::types::SubmissionStatus::Uploaded), + }).await.map_err(ReleaseError::GetSubmissions)?; + let len=submissions.Submissions.len(); + for submission in submissions.Submissions{ + submissions_pending_release.entry(submission.GameID).or_insert(Vec::new()).push(submission); + } + if len<LIMIT as usize{ + break; + }else{ + page+=1; + } + } + } + // If there is nothing to release, exit immediately + if submissions_pending_release.is_empty(){ + println!("Nothing to release!"); + return Ok(()); + } + + // determine the most recent map release date + // if it's in the past, generate a Friday 10AM timestamp instead + let it={ + println!("Determining most recent release dates..."); + let mut latest_date=std::collections::HashMap::new(); + let mut page=1; + loop{ + let maps=api.get_maps(submissions_api::types::GetMapsRequest{ + Page:page, + Limit:LIMIT, + DisplayName:None, + Creator:None, + GameID:None, + Sort:None,//TODO: sort by date to cut down requests + }).await.map_err(ReleaseError::GetMaps)?; + let len=maps.len(); + for map in maps{ + latest_date + .entry(map.GameID) + .and_modify(|date| + *date=map.Date.min(*date) + ) + .or_insert(map.Date); + } + if len<LIMIT as usize{ + break; + }else{ + page+=1; + } + } + + // breaks on Sun 4 Dec 292277026596 + let now=std::time::UNIX_EPOCH.elapsed().unwrap().as_secs() as i64; + + // If the date is in the past, unset it + latest_date.retain(|_,&mut date|now<date); + + submissions_pending_release.into_iter().map(move|(game,pending)|{ + let start_date=match latest_date.get(&game){ + Some(&date)=>{ + // round to friday + (date+(ONE_WEEK>>1)-FRIDAY)/ONE_WEEK*ONE_WEEK+FRIDAY+PEAK_HOURS + // add a week + +ONE_WEEK + }, + // find soonest friday + None=>((now-FRIDAY) as u64).next_multiple_of(ONE_WEEK as u64) as i64+FRIDAY+PEAK_HOURS + }; + + (game,start_date,pending) + }) + }; + + let mut rng=rand::rng(); + + for (game,start_date,mut pending) in it{ + // shuffle maps + pending.shuffle(&mut rng); + + // schedule one per week + let schedule:&Vec<_>=&pending.into_iter().enumerate().map(|(i,submission)|{ + let release_date=(std::time::UNIX_EPOCH+std::time::Duration::from_secs(( + start_date+i as i64*ONE_WEEK + ) as u64)).into(); + println!("Schedule {:?} {} at {}",submission.ID,submission.DisplayName,release_date); + submissions_api::types::ReleaseInfo{ + Date:release_date, + SubmissionID:submission.ID, + } + }).collect(); + + // ask to confirm schedule + print!("Accept this release schedule for {game:?}? [y/N]: "); + std::io::stdout().flush().map_err(ReleaseError::Io)?; + + let mut input=String::new(); + std::io::stdin().read_line(&mut input).map_err(ReleaseError::Io)?; + match input.trim(){ + "y"|"Y"=>(), + _=>{ + println!("Quitting."); + return Ok(()); + }, + } + + // send it + api.release_submissions(submissions_api::types::ReleaseRequest{ + schedule, + }).await.map_err(ReleaseError::Release)?; + } + + Ok(()) +}